VariaReEncoder / garmin_varia_transcode.sh
Newer Older
987 lines | 29.764kb
Bogdan Timofte authored a month ago
1
#!/usr/bin/env bash
2
set -euo pipefail
3

            
4
# Garmin Varia batch transcoder (macOS/Linux)
5
#
6
# Apple compatibility note:
7
# For HEVC outputs we explicitly set -tag:v hvc1 (not hev1), which is required
8
# for reliable QuickTime / Photos compatibility.
9
#
10
# Encoding strategy note:
11
# Do not derive output bitrate mechanically from fixed Garmin source sizes.
12
# Prefer CRF (software) or constrained VBR (hardware), as used below.
13

            
14
SCRIPT_NAME="$(basename "$0")"
Bogdan Timofte authored a month ago
15
TOOL_NAME="ffmpeg"
Bogdan Timofte authored a month ago
16

            
17
DEFAULT_SOURCE="."
18
DEFAULT_MODE="hardware"
19
DEFAULT_EXTENSIONS="mp4,mov,avi,m4v"
20
DEFAULT_CRF_HEVC=20
21
DEFAULT_CRF_H264=19
22

            
Bogdan Timofte authored a month ago
23
# Garmin publicly declares a 140-degree field of view.
24
# We infer practical focal metadata from this single spec.
25
# 35mm-equivalent focal length is integer-only in EXIF, so we round to 7 mm.
26
GARMIN_DECLARED_FOV_DEG=140
27
GARMIN_INFERRED_FOCAL_MM="6.5"
28
GARMIN_INFERRED_FOCAL_35MM_MM="7"
29

            
Bogdan Timofte authored a month ago
30
SOURCE_DIR="$DEFAULT_SOURCE"
31
DEST_DIR="$DEFAULT_SOURCE"
32
MODE="$DEFAULT_MODE"
33
EXTENSIONS_CSV="$DEFAULT_EXTENSIONS"
34
CRF_OVERRIDE=""
35
OVERWRITE=true
36
DRY_RUN=false
37
RECURSIVE=true
38
SINGLE_FILE=""
39
VERBOSE=false
40
QUIET_FFMPEG=false
41
MOVE_SOURCE=false
42
SOURCE_PROVIDED=false
43
DEST_PROVIDED=false
44

            
45
HAS_VIDEOTOOLBOX=false
46
HAS_LIBX265=false
47
HAS_LIBX264=false
48

            
49
ENCODER_KIND=""
50
VIDEO_CODEC=""
51
VIDEO_CRF=""
52
VIDEO_ARGS=()
53
FIND_EXT_EXPR=()
54

            
55
VIDEOS_PROCESSED=0
56
VIDEOS_SKIPPED=0
57
JSON_COPIED=0
58
JSON_SKIPPED=0
59
ERRORS=0
60
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
61
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
62

            
63
DURATION_TOLERANCE_SEC=1.0
64

            
65
usage() {
66
  cat <<'EOF'
67
Usage:
68
  garmin_varia_transcode.sh [options]
69

            
70
Options:
71
  -s, --source, --input DIR       Source directory or single file (default: current directory)
72
  -d, --destination, --output DIR Destination directory (default: current directory)
73
  --mode MODE                     Encoding mode (default: hardware); see Encoding Modes below
74
  --crf N                         CRF value for quality/compat modes (lower = better; default: 20/19)
75
  --no-overwrite                  Skip files that already exist at destination (default: overwrite)
76
  --dry-run                       Print actions without writing files
77
  --no-recursive                  Process only the top-level source directory (default: recursive)
78
  --extensions LIST               Comma-separated video extensions (default: mp4,mov,avi,m4v)
79
  --verbose                       Log each operation with timestamp; show ffmpeg/ffprobe output
80
  --move-source                   Remove source file only after strict post-encode validation
81
  -h, --help                      Show this help
82

            
83
Encoding Modes:
84
  hardware   Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only.
85
             ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro).
86
             Best choice when running on battery or transcoding large libraries.
87
             Output quality is good for dashcam/action footage.
88
             Falls back to an error on Linux or if videotoolbox is absent.
89

            
90
  auto       Like hardware on macOS with videotoolbox, otherwise falls back to quality,
91
             then compat. Safe cross-platform default when the machine is unknown.
92

            
93
  quality    Uses libx265 (software HEVC, CRF 20). Platform-independent.
94
             ~50s per 30s clip, ~80W (measured on Apple Silicon MacBook Pro).
95
             Best compression ratio and quality for archival, high-resolution, or visually
96
             complex sources (e.g. cinema, screen recordings).
97
             Overkill for dashcam footage; prefer hardware or auto for those.
98

            
99
  compat     Uses libx264 (software H.264, CRF 19). Maximum player compatibility.
100
             Use when the destination player cannot decode HEVC (older TVs, Android 4.x,
101
             web browsers without HEVC support). Larger files than HEVC modes.
102

            
103
Behavior:
104
  - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible)
105
  - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility
Bogdan Timofte authored a month ago
106
  - Metadata is restored from source to output with exiftool
107
  - Garmin camera model is also mapped to standard Make/Model tags for compatibility
108
  - Inferred lens metadata is written from Garmin's declared 140-degree FOV
109
  - Transcoding encoder/mode is written in metadata (Software)
Bogdan Timofte authored a month ago
110
  - Original directory structure is preserved under destination
111
  - When --source points to a file, only that file is processed
112
  - JSON sidecar files found in source are copied 1:1 to destination
113
  - telemetry_manifest.json is created in destination as a placeholder contract
114

            
115
Examples:
116
  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
117
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
118
  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
119
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
120
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
121
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --move-source
122
EOF
123
}
124

            
125
log_msg() {
126
  local level="$1"
127
  shift
128
  local ts
129
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
130
  echo "[$ts] [$level] $*"
131
}
132

            
133
# Verbose-only log: suppressed in default quiet mode
134
vlog_msg() {
135
  [[ "$VERBOSE" == true ]] && log_msg "$@"
136
  return 0
137
}
138

            
139
# Quiet-mode per-file progress line
140
log_progress() {
141
  local input_file="$1"
Bogdan Timofte authored a month ago
142
  local real_elapsed_sec="$2"
143
  local encode_elapsed_sec="$3"
Bogdan Timofte authored a month ago
144
  local ts
145
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
146
  echo "$ts : Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
147
}
148

            
149
die() {
150
  log_msg "ERROR" "$*"
151
  exit 1
152
}
153

            
154
format_seconds() {
155
  local sec="$1"
156
  local h m s
157
  h=$((sec / 3600))
158
  m=$(((sec % 3600) / 60))
159
  s=$((sec % 60))
160
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
161
}
162

            
163
make_temp_log_file() {
164
  local temp_path
165
  local base_tmp="${TMPDIR:-/tmp}"
166
  base_tmp="${base_tmp%/}"
167

            
168
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
169
  if [[ -z "$temp_path" ]]; then
170
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
171
  fi
172

            
173
  printf '%s\n' "$temp_path"
174
}
175

            
176
require_value() {
177
  local flag="$1"
178
  local value="${2:-}"
179
  if [[ -z "$value" ]]; then
180
    die "Missing value for $flag"
181
  fi
182
}
183

            
184
to_abs_path() {
185
  local p="$1"
186
  if [[ "$p" = /* ]]; then
187
    printf '%s\n' "$p"
188
  else
189
    printf '%s\n' "$PWD/$p"
190
  fi
191
}
192

            
193
path_is_within() {
194
  local child="$1"
195
  local parent="$2"
196
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
197
}
198

            
199
join_cmd_for_log() {
200
  local out=""
201
  local arg
202
  for arg in "$@"; do
203
    out+="$(printf '%q' "$arg") "
204
  done
205
  printf '%s\n' "${out% }"
206
}
207

            
208
parse_args() {
209
  while [[ $# -gt 0 ]]; do
210
    case "$1" in
211
      -s|--source|--input)
212
        require_value "$1" "${2:-}"
213
        SOURCE_DIR="$2"
214
        SOURCE_PROVIDED=true
215
        shift 2
216
        ;;
217
      -d|--destination|--output)
218
        require_value "$1" "${2:-}"
219
        DEST_DIR="$2"
220
        DEST_PROVIDED=true
221
        shift 2
222
        ;;
223
      --mode)
224
        require_value "$1" "${2:-}"
225
        MODE="$2"
226
        shift 2
227
        ;;
228
      --crf)
229
        require_value "$1" "${2:-}"
230
        CRF_OVERRIDE="$2"
231
        shift 2
232
        ;;
233
      --overwrite)
234
        OVERWRITE=true
235
        shift
236
        ;;
237
      --no-overwrite)
238
        OVERWRITE=false
239
        shift
240
        ;;
241
      --dry-run)
242
        DRY_RUN=true
243
        shift
244
        ;;
245
      --recursive)
246
        RECURSIVE=true
247
        shift
248
        ;;
249
      --no-recursive)
250
        RECURSIVE=false
251
        shift
252
        ;;
253
      --extensions)
254
        require_value "$1" "${2:-}"
255
        EXTENSIONS_CSV="$2"
256
        shift 2
257
        ;;
258
      --single)
259
        require_value "$1" "${2:-}"
260
        SINGLE_FILE="$2"
261
        shift 2
262
        ;;
263
      --verbose)
264
        VERBOSE=true
265
        shift
266
        ;;
267
      --quiet-ffmpeg)
268
        QUIET_FFMPEG=true
269
        shift
270
        ;;
271
      --move-source)
272
        MOVE_SOURCE=true
273
        shift
274
        ;;
275
      -h|--help)
276
        usage
277
        exit 0
278
        ;;
279
      *)
280
        die "Unknown argument: $1"
281
        ;;
282
    esac
283
  done
284

            
285
  case "$MODE" in
286
    auto|hardware|quality|compat) ;;
287
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
288
  esac
289

            
290
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
291
    die "At least one of --source or --destination must be provided"
292
  fi
293

            
294
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
295
    die "--crf must be an integer"
296
  fi
297
}
298

            
299
check_tools() {
300
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
301
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
302
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
303
}
304

            
305
restore_metadata_with_exiftool() {
306
  local input_file="$1"
307
  local output_file="$2"
308

            
309
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
310

            
311
  if [[ "$VERBOSE" == true ]]; then
312
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
313
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
314
      return 0
315
    fi
316
  else
317
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
318
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
319
      return 0
320
    fi
321
  fi
322

            
323
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
324
  return 1
325
}
326

            
327
map_garmin_model_to_standard_tags() {
328
  local input_file="$1"
329
  local output_file="$2"
330
  local garmin_model
331

            
332
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
333
  if [[ -z "$garmin_model" ]]; then
334
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
335
    return 0
336
  fi
337

            
338
  vlog_msg "CHECKPOINT" "model_map_start: $output_file (GarminModel=$garmin_model)"
339

            
340
  if [[ "$VERBOSE" == true ]]; then
341
    if exiftool -overwrite_original -m \
342
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
343
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
344
      -Make="Garmin" -Model="Varia RCT715" \
345
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
346
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
347
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
348
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
349
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
350
      "$output_file"; then
351
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
352
      return 0
353
    fi
354
  else
355
    if exiftool -overwrite_original -m -q -q \
356
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
357
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
358
      -Make="Garmin" -Model="Varia RCT715" \
359
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
360
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
361
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
362
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
363
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
364
      "$output_file" >/dev/null 2>&1; then
365
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
366
      return 0
367
    fi
368
  fi
369

            
370
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
371
  return 1
372
}
373

            
374
write_transcode_encoder_metadata() {
375
  local output_file="$1"
376
  local encoder_meta
377

            
378
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
379

            
380
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
381

            
382
  if [[ "$VERBOSE" == true ]]; then
383
    if exiftool -overwrite_original -m \
384
      -Software="$encoder_meta" \
385
      -UserData:Software="$encoder_meta" \
386
      "$output_file"; then
387
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
388
      return 0
389
    fi
390
  else
391
    if exiftool -overwrite_original -m -q -q \
392
      -Software="$encoder_meta" \
393
      -UserData:Software="$encoder_meta" \
394
      "$output_file" >/dev/null 2>&1; then
395
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
396
      return 0
397
    fi
398
  fi
399

            
400
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
401
  return 1
Bogdan Timofte authored a month ago
402
}
403

            
404
detect_encoders() {
405
  local encoders
406
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
407

            
408
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
409
    HAS_VIDEOTOOLBOX=true
410
  fi
411
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
412
    HAS_LIBX265=true
413
  fi
414
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
415
    HAS_LIBX264=true
416
  fi
417

            
418
  if [[ "$VERBOSE" == true ]]; then
419
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
420
  fi
421
}
422

            
423
resolve_encoder() {
424
  local os_name
425
  os_name="$(uname -s)"
426

            
427
  case "$MODE" in
428
    auto)
429
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
430
        ENCODER_KIND="hardware"
431
      elif [[ "$HAS_LIBX265" == true ]]; then
432
        ENCODER_KIND="quality"
433
      elif [[ "$HAS_LIBX264" == true ]]; then
434
        ENCODER_KIND="compat"
435
      else
436
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
437
      fi
438
      ;;
439
    hardware)
440
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
441
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
442
      ENCODER_KIND="hardware"
443
      ;;
444
    quality)
445
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
446
      ENCODER_KIND="quality"
447
      ;;
448
    compat)
449
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
450
      ENCODER_KIND="compat"
451
      ;;
452
  esac
453

            
454
  case "$ENCODER_KIND" in
455
    hardware)
456
      VIDEO_CODEC="hevc_videotoolbox"
457
      VIDEO_CRF=""
458
      ;;
459
    quality)
460
      VIDEO_CODEC="libx265"
461
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
462
      ;;
463
    compat)
464
      VIDEO_CODEC="libx264"
465
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
466
      ;;
467
  esac
468

            
469
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
470
}
471

            
472
probe_has_audio() {
473
  local input_file="$1"
474
  local out
475
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
476
  [[ -n "$out" ]]
477
}
478

            
479
print_verbose_probe() {
480
  local input_file="$1"
481
  ffprobe -v error \
482
    -show_entries format=filename,format_name,duration,bit_rate,start_time:stream=index,codec_type,codec_name,profile,pix_fmt,width,height,avg_frame_rate,r_frame_rate,bit_rate \
483
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
484
}
485

            
486
ffprobe_duration_or_empty() {
487
  local file_path="$1"
488
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
489
}
490

            
491
ffprobe_video_codec_or_empty() {
492
  local file_path="$1"
493
  ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
494
}
495

            
496
validate_transcoded_output() {
497
  local input_file="$1"
498
  local output_file="$2"
499

            
500
  if [[ ! -f "$output_file" ]]; then
501
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
502
    return 1
503
  fi
504

            
505
  local expected_codec actual_codec
506
  case "$ENCODER_KIND" in
507
    hardware|quality) expected_codec="hevc" ;;
508
    compat) expected_codec="h264" ;;
509
    *)
510
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
511
      return 1
512
      ;;
513
  esac
514

            
515
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
516
  if [[ -z "$actual_codec" ]]; then
517
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
518
    return 1
519
  fi
520
  if [[ "$actual_codec" != "$expected_codec" ]]; then
521
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
522
    return 1
523
  fi
524

            
525
  local in_duration out_duration duration_delta
526
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
527
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
528

            
529
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
530
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
531
    return 1
532
  fi
533

            
534
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
535
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
536
    log_msg "ERROR" "Validation failed: duration delta too large for $output_file (input=${in_duration}s output=${out_duration}s delta=${duration_delta}s tolerance=${DURATION_TOLERANCE_SEC}s)"
537
    return 1
538
  fi
539

            
540
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
541
  return 0
542
}
543

            
544
build_video_args() {
545
  VIDEO_ARGS=()
546

            
547
  case "$ENCODER_KIND" in
548
    hardware)
549
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
550
      ;;
551
    quality)
552
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
553
      ;;
554
    compat)
555
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
556
      ;;
557
  esac
558
}
559

            
560
normalize_source_dir() {
561
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
562
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
563
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
564
}
565

            
566
normalize_dest_dir() {
567
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
568
}
569

            
570
collect_extensions() {
571
  local raw="$EXTENSIONS_CSV"
572
  local token
573
  EXT_LIST=()
574

            
575
  IFS=',' read -r -a tokens <<< "$raw"
576
  for token in "${tokens[@]}"; do
577
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
578
    token="${token#.}"
579
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
580
    [[ -n "$token" ]] && EXT_LIST+=("$token")
581
  done
582

            
583
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
584
    die "No valid extensions after parsing --extensions"
585
  fi
586
}
587

            
588
build_find_expr_for_extensions() {
589
  FIND_EXT_EXPR=()
590
  local ext
591
  for ext in "${EXT_LIST[@]}"; do
592
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
593
  done
594
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
595
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
596
  fi
597
}
598

            
599
rel_path_from_source() {
600
  local abs_file="$1"
601
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
602
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
603
  else
604
    printf '%s\n' "$(basename "$abs_file")"
605
  fi
606
}
607

            
608
collect_video_files() {
609
  VIDEO_FILES=()
610

            
611
  if [[ -n "$SINGLE_FILE" ]]; then
612
    local single_abs
613
    single_abs="$(to_abs_path "$SINGLE_FILE")"
614
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
615
    VIDEO_FILES+=("$single_abs")
616
    return
617
  fi
618

            
619
  build_find_expr_for_extensions
620

            
621
  while IFS= read -r -d '' file; do
622
    VIDEO_FILES+=("$file")
623
  done < <(
624
    if [[ "$RECURSIVE" == true ]]; then
625
      find "$SOURCE_DIR" \
626
        -path "$DEST_DIR" -prune -o \
627
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
628
    else
629
      find "$SOURCE_DIR" \
630
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
631
    fi
632
  )
633
}
634

            
635
process_video_file() {
636
  local input_file="$1"
637
  local rel_path output_file out_dir
Bogdan Timofte authored a month ago
638
  local file_started_at file_ended_at file_real_elapsed_sec
639
  local encode_started_at encode_ended_at encode_elapsed_sec=0
640
  local post_elapsed_sec
641

            
642
  file_started_at="$(date +%s)"
643
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
644

            
645
  rel_path="$(rel_path_from_source "$input_file")"
646
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
647
  out_dir="$(dirname "$output_file")"
648

            
649
  mkdir -p "$out_dir"
650

            
651
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
652
    vlog_msg "SKIP" "Video exists: $output_file"
653
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
654
    return
655
  fi
656

            
657
  local has_audio=false
658
  if probe_has_audio "$input_file"; then
659
    has_audio=true
660
  fi
661

            
662
  if [[ "$VERBOSE" == true ]]; then
663
    log_msg "INFO" "ffprobe summary: $input_file"
664
    print_verbose_probe "$input_file"
665
    log_msg "INFO" "Audio detected: $has_audio"
666
  fi
667

            
668
  build_video_args
669

            
670
  local cmd=(ffmpeg -hide_banner)
671
  if [[ "$OVERWRITE" == true ]]; then
672
    cmd+=( -y )
673
  else
674
    cmd+=( -n )
675
  fi
676

            
677
  cmd+=( -i "$input_file" -map 0:v:0 )
678

            
679
  if [[ "$has_audio" == true ]]; then
680
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
681
  fi
682

            
683
  cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$output_file" )
684

            
685
  if [[ "$VERBOSE" == true ]]; then
686
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
687
  fi
688

            
689
  if [[ "$DRY_RUN" == true ]]; then
690
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
691
    return 0
692
  fi
693

            
694
  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
Bogdan Timofte authored a month ago
695
  encode_started_at="$(date +%s)"
696
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
697

            
698
  local ffmpeg_rc=0
699
  if [[ "$VERBOSE" == true ]]; then
700
    # Verbose: show ffmpeg output directly
701
    if "${cmd[@]}"; then
702
      :
703
    else
704
      ffmpeg_rc=$?
705
    fi
706
  else
707
    # Quiet (default): redirect ffmpeg output; keep log on failure
708
    local ffmpeg_log
709
    ffmpeg_log="$(make_temp_log_file)"
710
    if [[ -z "$ffmpeg_log" ]]; then
711
      ERRORS=$((ERRORS + 1))
712
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
713
      return 1
714
    fi
715
    if "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
716
      rm -f "$ffmpeg_log"
717
    else
718
      ffmpeg_rc=$?
719
    fi
720
  fi
721

            
Bogdan Timofte authored a month ago
722
  encode_ended_at="$(date +%s)"
723
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
724
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
725

            
726
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
727
    file_ended_at="$(date +%s)"
728
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
729
    local encode_elapsed_fmt
730
    local real_elapsed_fmt
731
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
732
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
733

            
Bogdan Timofte authored a month ago
734
    ERRORS=$((ERRORS + 1))
735
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
736
      log_msg "ERROR" "ffmpeg failed: $input_file (real=${file_real_elapsed_sec}s / $real_elapsed_fmt, encode=${encode_elapsed_sec}s / $encode_elapsed_fmt, rc=$ffmpeg_rc)"
Bogdan Timofte authored a month ago
737
    else
738
      local ts
739
      ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
740
      echo "$ts : Transcoding $input_file ... FAILED after ${file_real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
Bogdan Timofte authored a month ago
741
      rm -f "${ffmpeg_log:-}"
742
    fi
743
    return 1
744
  fi
745

            
Bogdan Timofte authored a month ago
746
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
747

            
748
  if ! restore_metadata_with_exiftool "$input_file" "$output_file"; then
749
    ERRORS=$((ERRORS + 1))
750
    return 1
751
  fi
752

            
753
  if ! map_garmin_model_to_standard_tags "$input_file" "$output_file"; then
754
    ERRORS=$((ERRORS + 1))
755
    return 1
756
  fi
757

            
758
  if ! write_transcode_encoder_metadata "$output_file"; then
759
    ERRORS=$((ERRORS + 1))
760
    return 1
761
  fi
Bogdan Timofte authored a month ago
762

            
763
  if [[ "$MOVE_SOURCE" == true ]]; then
764
    if ! validate_transcoded_output "$input_file" "$output_file"; then
765
      ERRORS=$((ERRORS + 1))
766
      return 1
767
    fi
768
  fi
769

            
770
  touch -r "$input_file" "$output_file" || true
771

            
772
  if [[ "$MOVE_SOURCE" == true ]]; then
773
    if rm -f "$input_file"; then
774
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
775
    else
776
      ERRORS=$((ERRORS + 1))
777
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
778
      return 1
779
    fi
780
  fi
781

            
Bogdan Timofte authored a month ago
782
  file_ended_at="$(date +%s)"
783
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
784
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
785
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
786
    post_elapsed_sec=0
787
  fi
788
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
789
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
790

            
Bogdan Timofte authored a month ago
791
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
792

            
793
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
794
    log_msg "INFO" "Transcoded: $output_file (real=${file_real_elapsed_sec}s / $(format_seconds "$file_real_elapsed_sec"), encode=${encode_elapsed_sec}s / $(format_seconds "$encode_elapsed_sec"), post=${post_elapsed_sec}s / $(format_seconds "$post_elapsed_sec"))"
Bogdan Timofte authored a month ago
795
  else
796
    local display_path="${input_file#$PWD/}"
Bogdan Timofte authored a month ago
797
    log_progress "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
798
  fi
799

            
800
  return 0
801
}
802

            
803
copy_one_json() {
804
  local json_file="$1"
805
  local rel_json dst_json dst_dir
806

            
807
  rel_json="$(rel_path_from_source "$json_file")"
808
  dst_json="$DEST_DIR/$rel_json"
809
  dst_dir="$(dirname "$dst_json")"
810

            
811
  mkdir -p "$dst_dir"
812

            
813
  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
814
    JSON_SKIPPED=$((JSON_SKIPPED + 1))
815
    vlog_msg "SKIP" "JSON exists: $dst_json"
816
    return
817
  fi
818

            
819
  if [[ "$DRY_RUN" == true ]]; then
820
    JSON_COPIED=$((JSON_COPIED + 1))
821
    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
822
    return
823
  fi
824

            
825
  if cp -f "$json_file" "$dst_json"; then
826
    touch -r "$json_file" "$dst_json" || true
827
    JSON_COPIED=$((JSON_COPIED + 1))
828
    vlog_msg "INFO" "Copied JSON: $dst_json"
829
  else
830
    ERRORS=$((ERRORS + 1))
831
    log_msg "ERROR" "Failed to copy JSON: $json_file"
832
  fi
833
}
834

            
835
copy_sidecars_json() {
836
  local json_files=()
837

            
838
  if [[ -n "$SINGLE_FILE" ]]; then
839
    local single_abs rel_path candidate
840
    single_abs="$(to_abs_path "$SINGLE_FILE")"
841
    rel_path="$(rel_path_from_source "$single_abs")"
842
    candidate="$SOURCE_DIR/${rel_path%.*}.json"
843
    if [[ -f "$candidate" ]]; then
844
      json_files+=("$candidate")
845
    fi
846
  else
847
    while IFS= read -r -d '' jf; do
848
      json_files+=("$jf")
849
    done < <(
850
      if [[ "$RECURSIVE" == true ]]; then
851
        find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
852
      else
853
        find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
854
      fi
855
    )
856
  fi
857

            
858
  if [[ ${#json_files[@]} -eq 0 ]]; then
859
    vlog_msg "INFO" "No JSON sidecars found to copy"
860
    return
861
  fi
862

            
863
  local jf
864
  for jf in "${json_files[@]}"; do
865
    copy_one_json "$jf"
866
  done
867
}
868

            
869
write_manifest() {
870
  local manifest_path="$DEST_DIR/telemetry_manifest.json"
871

            
872
  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
873
    vlog_msg "SKIP" "Manifest exists: $manifest_path"
874
    return
875
  fi
876

            
877
  if [[ "$DRY_RUN" == true ]]; then
878
    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
879
    return
880
  fi
881

            
882
  mkdir -p "$DEST_DIR"
883

            
884
  cat > "$manifest_path" <<EOF
885
{
886
  "schema_version": "0.1-draft",
887
  "purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
888
  "fields_target": [
889
    "power_w",
890
    "speed_kmh",
891
    "heart_rate_bpm",
892
    "cadence_rpm",
893
    "gps"
894
  ],
895
  "sync_methods": [
896
    "auto_timestamp_plus_offset",
897
    "manual_offset_ms"
898
  ],
899
  "notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
900
}
901
EOF
902

            
903
  vlog_msg "INFO" "Wrote manifest: $manifest_path"
904
}
905

            
906
main() {
907
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
908
  total_started_at="$(date +%s)"
909

            
910
  parse_args "$@"
911
  check_tools
912

            
913
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
914
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
915
    SINGLE_FILE="$SOURCE_DIR"
916
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
917
  fi
918

            
919
  normalize_source_dir
920
  normalize_dest_dir
921
  collect_extensions
922

            
923
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
924
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
925
  fi
926

            
927
  detect_encoders
928
  resolve_encoder
929

            
930
  collect_video_files
931
  if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
932
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
933
  fi
934

            
935
  local f
936
  for f in "${VIDEO_FILES[@]}"; do
937
    if ! process_video_file "$f"; then
938
      log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
939
      break
940
    fi
941
  done
942

            
943
  if [[ "$ERRORS" -eq 0 ]]; then
944
    copy_sidecars_json
945
    write_manifest
946
  else
947
    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
948
  fi
949

            
950
  total_ended_at="$(date +%s)"
951
  total_elapsed_sec=$((total_ended_at - total_started_at))
952
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
953

            
954
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
955
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
956
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
957
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
958

            
Bogdan Timofte authored a month ago
959
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
960
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
961
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
962
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
963
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
964
  fi
965

            
966
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
967
  if [[ "$file_post_total_sec" -lt 0 ]]; then
968
    file_post_total_sec=0
969
  fi
970
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
971

            
972
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
973
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
974
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
975
  fi
Bogdan Timofte authored a month ago
976
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
977

            
978
  log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
Bogdan Timofte authored a month ago
979
  log_msg "INFO" "Timing: run_real=${total_elapsed_sec}s / $total_elapsed_fmt, files_real_total=${TOTAL_FILE_REAL_TIME_SEC}s / $(format_seconds "$TOTAL_FILE_REAL_TIME_SEC"), files_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), files_post_total=${file_post_total_sec}s / $file_post_total_fmt, run_non_file_overhead=${run_non_file_overhead_sec}s / $run_non_file_overhead_fmt"
980
  log_msg "INFO" "Timing(avg): file_real_avg=${avg_file_real_time_sec}s / $avg_file_real_time_fmt, file_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
Bogdan Timofte authored a month ago
981

            
982
  if [[ "$ERRORS" -gt 0 ]]; then
983
    exit 1
984
  fi
985
}
986

            
987
main "$@"