VariaReEncoder / garmin_varia_transcode.sh
Newer Older
818 lines | 22.645kb
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")"
15

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

            
22
SOURCE_DIR="$DEFAULT_SOURCE"
23
DEST_DIR="$DEFAULT_SOURCE"
24
MODE="$DEFAULT_MODE"
25
EXTENSIONS_CSV="$DEFAULT_EXTENSIONS"
26
CRF_OVERRIDE=""
27
OVERWRITE=true
28
DRY_RUN=false
29
RECURSIVE=true
30
SINGLE_FILE=""
31
VERBOSE=false
32
QUIET_FFMPEG=false
33
MOVE_SOURCE=false
34
SOURCE_PROVIDED=false
35
DEST_PROVIDED=false
36

            
37
HAS_VIDEOTOOLBOX=false
38
HAS_LIBX265=false
39
HAS_LIBX264=false
40

            
41
ENCODER_KIND=""
42
VIDEO_CODEC=""
43
VIDEO_CRF=""
44
VIDEO_ARGS=()
45
FIND_EXT_EXPR=()
46

            
47
VIDEOS_PROCESSED=0
48
VIDEOS_SKIPPED=0
49
JSON_COPIED=0
50
JSON_SKIPPED=0
51
ERRORS=0
52
TOTAL_VIDEO_TIME_SEC=0
53

            
54
DURATION_TOLERANCE_SEC=1.0
55

            
56
usage() {
57
  cat <<'EOF'
58
Usage:
59
  garmin_varia_transcode.sh [options]
60

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

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

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

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

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

            
94
Behavior:
95
  - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible)
96
  - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility
97
  - Original directory structure is preserved under destination
98
  - When --source points to a file, only that file is processed
99
  - JSON sidecar files found in source are copied 1:1 to destination
100
  - telemetry_manifest.json is created in destination as a placeholder contract
101

            
102
Examples:
103
  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
104
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
105
  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
106
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
107
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
108
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --move-source
109
EOF
110
}
111

            
112
log_msg() {
113
  local level="$1"
114
  shift
115
  local ts
116
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
117
  echo "[$ts] [$level] $*"
118
}
119

            
120
# Verbose-only log: suppressed in default quiet mode
121
vlog_msg() {
122
  [[ "$VERBOSE" == true ]] && log_msg "$@"
123
  return 0
124
}
125

            
126
# Quiet-mode per-file progress line
127
log_progress() {
128
  local input_file="$1"
129
  local elapsed_sec="$2"
130
  local ts
131
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
132
  echo "$ts : Transcoding $input_file ... done in ${elapsed_sec}s"
133
}
134

            
135
die() {
136
  log_msg "ERROR" "$*"
137
  exit 1
138
}
139

            
140
format_seconds() {
141
  local sec="$1"
142
  local h m s
143
  h=$((sec / 3600))
144
  m=$(((sec % 3600) / 60))
145
  s=$((sec % 60))
146
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
147
}
148

            
149
make_temp_log_file() {
150
  local temp_path
151
  local base_tmp="${TMPDIR:-/tmp}"
152
  base_tmp="${base_tmp%/}"
153

            
154
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
155
  if [[ -z "$temp_path" ]]; then
156
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
157
  fi
158

            
159
  printf '%s\n' "$temp_path"
160
}
161

            
162
require_value() {
163
  local flag="$1"
164
  local value="${2:-}"
165
  if [[ -z "$value" ]]; then
166
    die "Missing value for $flag"
167
  fi
168
}
169

            
170
to_abs_path() {
171
  local p="$1"
172
  if [[ "$p" = /* ]]; then
173
    printf '%s\n' "$p"
174
  else
175
    printf '%s\n' "$PWD/$p"
176
  fi
177
}
178

            
179
path_is_within() {
180
  local child="$1"
181
  local parent="$2"
182
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
183
}
184

            
185
join_cmd_for_log() {
186
  local out=""
187
  local arg
188
  for arg in "$@"; do
189
    out+="$(printf '%q' "$arg") "
190
  done
191
  printf '%s\n' "${out% }"
192
}
193

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

            
271
  case "$MODE" in
272
    auto|hardware|quality|compat) ;;
273
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
274
  esac
275

            
276
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
277
    die "At least one of --source or --destination must be provided"
278
  fi
279

            
280
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
281
    die "--crf must be an integer"
282
  fi
283
}
284

            
285
check_tools() {
286
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
287
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
288
}
289

            
290
detect_encoders() {
291
  local encoders
292
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
293

            
294
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
295
    HAS_VIDEOTOOLBOX=true
296
  fi
297
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
298
    HAS_LIBX265=true
299
  fi
300
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
301
    HAS_LIBX264=true
302
  fi
303

            
304
  if [[ "$VERBOSE" == true ]]; then
305
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
306
  fi
307
}
308

            
309
resolve_encoder() {
310
  local os_name
311
  os_name="$(uname -s)"
312

            
313
  case "$MODE" in
314
    auto)
315
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
316
        ENCODER_KIND="hardware"
317
      elif [[ "$HAS_LIBX265" == true ]]; then
318
        ENCODER_KIND="quality"
319
      elif [[ "$HAS_LIBX264" == true ]]; then
320
        ENCODER_KIND="compat"
321
      else
322
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
323
      fi
324
      ;;
325
    hardware)
326
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
327
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
328
      ENCODER_KIND="hardware"
329
      ;;
330
    quality)
331
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
332
      ENCODER_KIND="quality"
333
      ;;
334
    compat)
335
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
336
      ENCODER_KIND="compat"
337
      ;;
338
  esac
339

            
340
  case "$ENCODER_KIND" in
341
    hardware)
342
      VIDEO_CODEC="hevc_videotoolbox"
343
      VIDEO_CRF=""
344
      ;;
345
    quality)
346
      VIDEO_CODEC="libx265"
347
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
348
      ;;
349
    compat)
350
      VIDEO_CODEC="libx264"
351
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
352
      ;;
353
  esac
354

            
355
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
356
}
357

            
358
probe_has_audio() {
359
  local input_file="$1"
360
  local out
361
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
362
  [[ -n "$out" ]]
363
}
364

            
365
print_verbose_probe() {
366
  local input_file="$1"
367
  ffprobe -v error \
368
    -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 \
369
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
370
}
371

            
372
ffprobe_duration_or_empty() {
373
  local file_path="$1"
374
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
375
}
376

            
377
ffprobe_video_codec_or_empty() {
378
  local file_path="$1"
379
  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
380
}
381

            
382
validate_transcoded_output() {
383
  local input_file="$1"
384
  local output_file="$2"
385

            
386
  if [[ ! -f "$output_file" ]]; then
387
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
388
    return 1
389
  fi
390

            
391
  local expected_codec actual_codec
392
  case "$ENCODER_KIND" in
393
    hardware|quality) expected_codec="hevc" ;;
394
    compat) expected_codec="h264" ;;
395
    *)
396
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
397
      return 1
398
      ;;
399
  esac
400

            
401
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
402
  if [[ -z "$actual_codec" ]]; then
403
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
404
    return 1
405
  fi
406
  if [[ "$actual_codec" != "$expected_codec" ]]; then
407
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
408
    return 1
409
  fi
410

            
411
  local in_duration out_duration duration_delta
412
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
413
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
414

            
415
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
416
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
417
    return 1
418
  fi
419

            
420
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
421
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
422
    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)"
423
    return 1
424
  fi
425

            
426
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
427
  return 0
428
}
429

            
430
build_video_args() {
431
  VIDEO_ARGS=()
432

            
433
  case "$ENCODER_KIND" in
434
    hardware)
435
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
436
      ;;
437
    quality)
438
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
439
      ;;
440
    compat)
441
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
442
      ;;
443
  esac
444
}
445

            
446
normalize_source_dir() {
447
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
448
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
449
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
450
}
451

            
452
normalize_dest_dir() {
453
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
454
}
455

            
456
collect_extensions() {
457
  local raw="$EXTENSIONS_CSV"
458
  local token
459
  EXT_LIST=()
460

            
461
  IFS=',' read -r -a tokens <<< "$raw"
462
  for token in "${tokens[@]}"; do
463
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
464
    token="${token#.}"
465
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
466
    [[ -n "$token" ]] && EXT_LIST+=("$token")
467
  done
468

            
469
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
470
    die "No valid extensions after parsing --extensions"
471
  fi
472
}
473

            
474
build_find_expr_for_extensions() {
475
  FIND_EXT_EXPR=()
476
  local ext
477
  for ext in "${EXT_LIST[@]}"; do
478
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
479
  done
480
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
481
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
482
  fi
483
}
484

            
485
rel_path_from_source() {
486
  local abs_file="$1"
487
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
488
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
489
  else
490
    printf '%s\n' "$(basename "$abs_file")"
491
  fi
492
}
493

            
494
collect_video_files() {
495
  VIDEO_FILES=()
496

            
497
  if [[ -n "$SINGLE_FILE" ]]; then
498
    local single_abs
499
    single_abs="$(to_abs_path "$SINGLE_FILE")"
500
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
501
    VIDEO_FILES+=("$single_abs")
502
    return
503
  fi
504

            
505
  build_find_expr_for_extensions
506

            
507
  while IFS= read -r -d '' file; do
508
    VIDEO_FILES+=("$file")
509
  done < <(
510
    if [[ "$RECURSIVE" == true ]]; then
511
      find "$SOURCE_DIR" \
512
        -path "$DEST_DIR" -prune -o \
513
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
514
    else
515
      find "$SOURCE_DIR" \
516
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
517
    fi
518
  )
519
}
520

            
521
process_video_file() {
522
  local input_file="$1"
523
  local rel_path output_file out_dir
524

            
525
  rel_path="$(rel_path_from_source "$input_file")"
526
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
527
  out_dir="$(dirname "$output_file")"
528

            
529
  mkdir -p "$out_dir"
530

            
531
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
532
    vlog_msg "SKIP" "Video exists: $output_file"
533
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
534
    return
535
  fi
536

            
537
  local has_audio=false
538
  if probe_has_audio "$input_file"; then
539
    has_audio=true
540
  fi
541

            
542
  if [[ "$VERBOSE" == true ]]; then
543
    log_msg "INFO" "ffprobe summary: $input_file"
544
    print_verbose_probe "$input_file"
545
    log_msg "INFO" "Audio detected: $has_audio"
546
  fi
547

            
548
  build_video_args
549

            
550
  local cmd=(ffmpeg -hide_banner)
551
  if [[ "$OVERWRITE" == true ]]; then
552
    cmd+=( -y )
553
  else
554
    cmd+=( -n )
555
  fi
556

            
557
  cmd+=( -i "$input_file" -map 0:v:0 )
558

            
559
  if [[ "$has_audio" == true ]]; then
560
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
561
  fi
562

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

            
565
  if [[ "$VERBOSE" == true ]]; then
566
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
567
  fi
568

            
569
  if [[ "$DRY_RUN" == true ]]; then
570
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
571
    return 0
572
  fi
573

            
574
  local started_at ended_at elapsed_sec elapsed_fmt
575
  started_at="$(date +%s)"
576

            
577
  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
578

            
579
  local ffmpeg_rc=0
580
  if [[ "$VERBOSE" == true ]]; then
581
    # Verbose: show ffmpeg output directly
582
    if "${cmd[@]}"; then
583
      :
584
    else
585
      ffmpeg_rc=$?
586
    fi
587
  else
588
    # Quiet (default): redirect ffmpeg output; keep log on failure
589
    local ffmpeg_log
590
    ffmpeg_log="$(make_temp_log_file)"
591
    if [[ -z "$ffmpeg_log" ]]; then
592
      ERRORS=$((ERRORS + 1))
593
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
594
      return 1
595
    fi
596
    if "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
597
      rm -f "$ffmpeg_log"
598
    else
599
      ffmpeg_rc=$?
600
    fi
601
  fi
602

            
603
  ended_at="$(date +%s)"
604
  elapsed_sec=$((ended_at - started_at))
605
  elapsed_fmt="$(format_seconds "$elapsed_sec")"
606

            
607
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
608
    ERRORS=$((ERRORS + 1))
609
    if [[ "$VERBOSE" == true ]]; then
610
      log_msg "ERROR" "ffmpeg failed: $input_file (elapsed=${elapsed_sec}s / $elapsed_fmt, rc=$ffmpeg_rc)"
611
    else
612
      local ts
613
      ts="$(date '+%Y-%m-%d %H:%M:%S')"
614
      echo "$ts : Transcoding $input_file ... FAILED (rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
615
      rm -f "${ffmpeg_log:-}"
616
    fi
617
    return 1
618
  fi
619

            
620
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + elapsed_sec))
621

            
622
  if [[ "$MOVE_SOURCE" == true ]]; then
623
    if ! validate_transcoded_output "$input_file" "$output_file"; then
624
      ERRORS=$((ERRORS + 1))
625
      return 1
626
    fi
627
  fi
628

            
629
  touch -r "$input_file" "$output_file" || true
630

            
631
  if [[ "$MOVE_SOURCE" == true ]]; then
632
    if rm -f "$input_file"; then
633
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
634
    else
635
      ERRORS=$((ERRORS + 1))
636
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
637
      return 1
638
    fi
639
  fi
640

            
641
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
642

            
643
  if [[ "$VERBOSE" == true ]]; then
644
    log_msg "INFO" "Transcoded: $output_file (elapsed=${elapsed_sec}s / $elapsed_fmt)"
645
  else
646
    local display_path="${input_file#$PWD/}"
647
    log_progress "$display_path" "$elapsed_sec"
648
  fi
649

            
650
  return 0
651
}
652

            
653
copy_one_json() {
654
  local json_file="$1"
655
  local rel_json dst_json dst_dir
656

            
657
  rel_json="$(rel_path_from_source "$json_file")"
658
  dst_json="$DEST_DIR/$rel_json"
659
  dst_dir="$(dirname "$dst_json")"
660

            
661
  mkdir -p "$dst_dir"
662

            
663
  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
664
    JSON_SKIPPED=$((JSON_SKIPPED + 1))
665
    vlog_msg "SKIP" "JSON exists: $dst_json"
666
    return
667
  fi
668

            
669
  if [[ "$DRY_RUN" == true ]]; then
670
    JSON_COPIED=$((JSON_COPIED + 1))
671
    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
672
    return
673
  fi
674

            
675
  if cp -f "$json_file" "$dst_json"; then
676
    touch -r "$json_file" "$dst_json" || true
677
    JSON_COPIED=$((JSON_COPIED + 1))
678
    vlog_msg "INFO" "Copied JSON: $dst_json"
679
  else
680
    ERRORS=$((ERRORS + 1))
681
    log_msg "ERROR" "Failed to copy JSON: $json_file"
682
  fi
683
}
684

            
685
copy_sidecars_json() {
686
  local json_files=()
687

            
688
  if [[ -n "$SINGLE_FILE" ]]; then
689
    local single_abs rel_path candidate
690
    single_abs="$(to_abs_path "$SINGLE_FILE")"
691
    rel_path="$(rel_path_from_source "$single_abs")"
692
    candidate="$SOURCE_DIR/${rel_path%.*}.json"
693
    if [[ -f "$candidate" ]]; then
694
      json_files+=("$candidate")
695
    fi
696
  else
697
    while IFS= read -r -d '' jf; do
698
      json_files+=("$jf")
699
    done < <(
700
      if [[ "$RECURSIVE" == true ]]; then
701
        find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
702
      else
703
        find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
704
      fi
705
    )
706
  fi
707

            
708
  if [[ ${#json_files[@]} -eq 0 ]]; then
709
    vlog_msg "INFO" "No JSON sidecars found to copy"
710
    return
711
  fi
712

            
713
  local jf
714
  for jf in "${json_files[@]}"; do
715
    copy_one_json "$jf"
716
  done
717
}
718

            
719
write_manifest() {
720
  local manifest_path="$DEST_DIR/telemetry_manifest.json"
721

            
722
  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
723
    vlog_msg "SKIP" "Manifest exists: $manifest_path"
724
    return
725
  fi
726

            
727
  if [[ "$DRY_RUN" == true ]]; then
728
    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
729
    return
730
  fi
731

            
732
  mkdir -p "$DEST_DIR"
733

            
734
  cat > "$manifest_path" <<EOF
735
{
736
  "schema_version": "0.1-draft",
737
  "purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
738
  "fields_target": [
739
    "power_w",
740
    "speed_kmh",
741
    "heart_rate_bpm",
742
    "cadence_rpm",
743
    "gps"
744
  ],
745
  "sync_methods": [
746
    "auto_timestamp_plus_offset",
747
    "manual_offset_ms"
748
  ],
749
  "notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
750
}
751
EOF
752

            
753
  vlog_msg "INFO" "Wrote manifest: $manifest_path"
754
}
755

            
756
main() {
757
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
758
  total_started_at="$(date +%s)"
759

            
760
  parse_args "$@"
761
  check_tools
762

            
763
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
764
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
765
    SINGLE_FILE="$SOURCE_DIR"
766
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
767
  fi
768

            
769
  normalize_source_dir
770
  normalize_dest_dir
771
  collect_extensions
772

            
773
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
774
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
775
  fi
776

            
777
  detect_encoders
778
  resolve_encoder
779

            
780
  collect_video_files
781
  if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
782
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
783
  fi
784

            
785
  local f
786
  for f in "${VIDEO_FILES[@]}"; do
787
    if ! process_video_file "$f"; then
788
      log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
789
      break
790
    fi
791
  done
792

            
793
  if [[ "$ERRORS" -eq 0 ]]; then
794
    copy_sidecars_json
795
    write_manifest
796
  else
797
    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
798
  fi
799

            
800
  total_ended_at="$(date +%s)"
801
  total_elapsed_sec=$((total_ended_at - total_started_at))
802
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
803

            
804
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
805
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
806
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
807
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
808
  fi
809

            
810
  log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
811
  log_msg "INFO" "Timing: total_elapsed=${total_elapsed_sec}s / $total_elapsed_fmt, video_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), video_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
812

            
813
  if [[ "$ERRORS" -gt 0 ]]; then
814
    exit 1
815
  fi
816
}
817

            
818
main "$@"