VariaReEncoder / garmin_varia_transcode.sh
Newer Older
987 lines | 29.97kb
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

            
Bogdan Timofte authored a month ago
14
TOOL_NAME="ffmpeg"
Bogdan Timofte authored a month ago
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

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

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

            
43
HAS_VIDEOTOOLBOX=false
44
HAS_LIBX265=false
45
HAS_LIBX264=false
46

            
47
ENCODER_KIND=""
48
VIDEO_CODEC=""
49
VIDEO_CRF=""
50
VIDEO_ARGS=()
51
FIND_EXT_EXPR=()
52

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

            
61
DURATION_TOLERANCE_SEC=1.0
62

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

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

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

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

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

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

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

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

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

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

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

            
147
die() {
148
  log_msg "ERROR" "$*"
149
  exit 1
150
}
151

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

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

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

            
171
  printf '%s\n' "$temp_path"
172
}
173

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

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

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

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

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

            
279
  case "$MODE" in
280
    auto|hardware|quality|compat) ;;
281
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
282
  esac
283

            
284
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
285
    die "At least one of --source or --destination must be provided"
286
  fi
287

            
288
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
289
    die "--crf must be an integer"
290
  fi
291
}
292

            
293
check_tools() {
294
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
295
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
296
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
297
}
298

            
299
restore_metadata_with_exiftool() {
300
  local input_file="$1"
301
  local output_file="$2"
302

            
303
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
304

            
305
  if [[ "$VERBOSE" == true ]]; then
306
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
307
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
308
      return 0
309
    fi
310
  else
311
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
312
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
313
      return 0
314
    fi
315
  fi
316

            
317
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
318
  return 1
319
}
320

            
321
map_garmin_model_to_standard_tags() {
322
  local input_file="$1"
323
  local output_file="$2"
324
  local garmin_model
325

            
326
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
327
  if [[ -z "$garmin_model" ]]; then
328
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
329
    return 0
330
  fi
331

            
Bogdan Timofte authored a month ago
332
  # garmin_model confirms this is a Garmin Varia source; fixed metadata values are
333
  # used below because Garmin does not embed a parseable model string we can map.
Bogdan Timofte authored a month ago
334
  vlog_msg "CHECKPOINT" "model_map_start: $output_file (GarminModel=$garmin_model)"
335

            
336
  if [[ "$VERBOSE" == true ]]; then
337
    if exiftool -overwrite_original -m \
338
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
339
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
340
      -Keys:CompatibleBrands="isom, iso2, mp41" \
341
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
342
      -Make="Garmin" -Model="Varia RCT715" \
343
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
344
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
345
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
346
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
347
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
348
      "$output_file"; then
349
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
350
      return 0
351
    fi
352
  else
353
    if exiftool -overwrite_original -m -q -q \
354
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
355
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
356
      -Keys:CompatibleBrands="isom, iso2, mp41" \
357
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
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 "$@"