VariaReEncoder / garmin_varia_transcode.sh
Newer Older
991 lines | 29.928kb
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" \
Bogdan Timofte authored a month ago
344
      -Keys:CompatibleBrands="isom, iso2, mp41" \
345
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
346
      -Make="Garmin" -Model="Varia RCT715" \
347
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
348
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
349
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
350
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
351
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
352
      "$output_file"; then
353
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
354
      return 0
355
    fi
356
  else
357
    if exiftool -overwrite_original -m -q -q \
358
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
359
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
360
      -Keys:CompatibleBrands="isom, iso2, mp41" \
361
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
362
      -Make="Garmin" -Model="Varia RCT715" \
363
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
364
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
365
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
366
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
367
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
368
      "$output_file" >/dev/null 2>&1; then
369
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
370
      return 0
371
    fi
372
  fi
373

            
374
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
375
  return 1
376
}
377

            
378
write_transcode_encoder_metadata() {
379
  local output_file="$1"
380
  local encoder_meta
381

            
382
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
383

            
384
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
385

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

            
404
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
405
  return 1
Bogdan Timofte authored a month ago
406
}
407

            
408
detect_encoders() {
409
  local encoders
410
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
411

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

            
422
  if [[ "$VERBOSE" == true ]]; then
423
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
424
  fi
425
}
426

            
427
resolve_encoder() {
428
  local os_name
429
  os_name="$(uname -s)"
430

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

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

            
473
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
474
}
475

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

            
483
print_verbose_probe() {
484
  local input_file="$1"
485
  ffprobe -v error \
486
    -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 \
487
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
488
}
489

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

            
495
ffprobe_video_codec_or_empty() {
496
  local file_path="$1"
497
  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
498
}
499

            
500
validate_transcoded_output() {
501
  local input_file="$1"
502
  local output_file="$2"
503

            
504
  if [[ ! -f "$output_file" ]]; then
505
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
506
    return 1
507
  fi
508

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

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

            
529
  local in_duration out_duration duration_delta
530
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
531
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
532

            
533
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
534
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
535
    return 1
536
  fi
537

            
538
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
539
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
540
    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)"
541
    return 1
542
  fi
543

            
544
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
545
  return 0
546
}
547

            
548
build_video_args() {
549
  VIDEO_ARGS=()
550

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

            
564
normalize_source_dir() {
565
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
566
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
567
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
568
}
569

            
570
normalize_dest_dir() {
571
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
572
}
573

            
574
collect_extensions() {
575
  local raw="$EXTENSIONS_CSV"
576
  local token
577
  EXT_LIST=()
578

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

            
587
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
588
    die "No valid extensions after parsing --extensions"
589
  fi
590
}
591

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

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

            
612
collect_video_files() {
613
  VIDEO_FILES=()
614

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

            
623
  build_find_expr_for_extensions
624

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

            
639
process_video_file() {
640
  local input_file="$1"
641
  local rel_path output_file out_dir
Bogdan Timofte authored a month ago
642
  local file_started_at file_ended_at file_real_elapsed_sec
643
  local encode_started_at encode_ended_at encode_elapsed_sec=0
644
  local post_elapsed_sec
645

            
646
  file_started_at="$(date +%s)"
647
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
648

            
649
  rel_path="$(rel_path_from_source "$input_file")"
650
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
651
  out_dir="$(dirname "$output_file")"
652

            
653
  mkdir -p "$out_dir"
654

            
655
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
656
    vlog_msg "SKIP" "Video exists: $output_file"
657
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
658
    return
659
  fi
660

            
661
  local has_audio=false
662
  if probe_has_audio "$input_file"; then
663
    has_audio=true
664
  fi
665

            
666
  if [[ "$VERBOSE" == true ]]; then
667
    log_msg "INFO" "ffprobe summary: $input_file"
668
    print_verbose_probe "$input_file"
669
    log_msg "INFO" "Audio detected: $has_audio"
670
  fi
671

            
672
  build_video_args
673

            
674
  local cmd=(ffmpeg -hide_banner)
675
  if [[ "$OVERWRITE" == true ]]; then
676
    cmd+=( -y )
677
  else
678
    cmd+=( -n )
679
  fi
680

            
681
  cmd+=( -i "$input_file" -map 0:v:0 )
682

            
683
  if [[ "$has_audio" == true ]]; then
684
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
685
  fi
686

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

            
689
  if [[ "$VERBOSE" == true ]]; then
690
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
691
  fi
692

            
693
  if [[ "$DRY_RUN" == true ]]; then
694
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
695
    return 0
696
  fi
697

            
698
  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
Bogdan Timofte authored a month ago
699
  encode_started_at="$(date +%s)"
700
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
701

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

            
Bogdan Timofte authored a month ago
726
  encode_ended_at="$(date +%s)"
727
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
728
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
729

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

            
Bogdan Timofte authored a month ago
738
    ERRORS=$((ERRORS + 1))
739
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
740
      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
741
    else
742
      local ts
743
      ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
744
      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
745
      rm -f "${ffmpeg_log:-}"
746
    fi
747
    return 1
748
  fi
749

            
Bogdan Timofte authored a month ago
750
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
751

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

            
757
  if ! map_garmin_model_to_standard_tags "$input_file" "$output_file"; then
758
    ERRORS=$((ERRORS + 1))
759
    return 1
760
  fi
761

            
762
  if ! write_transcode_encoder_metadata "$output_file"; then
763
    ERRORS=$((ERRORS + 1))
764
    return 1
765
  fi
Bogdan Timofte authored a month ago
766

            
767
  if [[ "$MOVE_SOURCE" == true ]]; then
768
    if ! validate_transcoded_output "$input_file" "$output_file"; then
769
      ERRORS=$((ERRORS + 1))
770
      return 1
771
    fi
772
  fi
773

            
774
  touch -r "$input_file" "$output_file" || true
775

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

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

            
Bogdan Timofte authored a month ago
795
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
796

            
797
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
798
    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
799
  else
800
    local display_path="${input_file#$PWD/}"
Bogdan Timofte authored a month ago
801
    log_progress "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
802
  fi
803

            
804
  return 0
805
}
806

            
807
copy_one_json() {
808
  local json_file="$1"
809
  local rel_json dst_json dst_dir
810

            
811
  rel_json="$(rel_path_from_source "$json_file")"
812
  dst_json="$DEST_DIR/$rel_json"
813
  dst_dir="$(dirname "$dst_json")"
814

            
815
  mkdir -p "$dst_dir"
816

            
817
  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
818
    JSON_SKIPPED=$((JSON_SKIPPED + 1))
819
    vlog_msg "SKIP" "JSON exists: $dst_json"
820
    return
821
  fi
822

            
823
  if [[ "$DRY_RUN" == true ]]; then
824
    JSON_COPIED=$((JSON_COPIED + 1))
825
    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
826
    return
827
  fi
828

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

            
839
copy_sidecars_json() {
840
  local json_files=()
841

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

            
862
  if [[ ${#json_files[@]} -eq 0 ]]; then
863
    vlog_msg "INFO" "No JSON sidecars found to copy"
864
    return
865
  fi
866

            
867
  local jf
868
  for jf in "${json_files[@]}"; do
869
    copy_one_json "$jf"
870
  done
871
}
872

            
873
write_manifest() {
874
  local manifest_path="$DEST_DIR/telemetry_manifest.json"
875

            
876
  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
877
    vlog_msg "SKIP" "Manifest exists: $manifest_path"
878
    return
879
  fi
880

            
881
  if [[ "$DRY_RUN" == true ]]; then
882
    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
883
    return
884
  fi
885

            
886
  mkdir -p "$DEST_DIR"
887

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

            
907
  vlog_msg "INFO" "Wrote manifest: $manifest_path"
908
}
909

            
910
main() {
911
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
912
  total_started_at="$(date +%s)"
913

            
914
  parse_args "$@"
915
  check_tools
916

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

            
923
  normalize_source_dir
924
  normalize_dest_dir
925
  collect_extensions
926

            
927
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
928
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
929
  fi
930

            
931
  detect_encoders
932
  resolve_encoder
933

            
934
  collect_video_files
935
  if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
936
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
937
  fi
938

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

            
947
  if [[ "$ERRORS" -eq 0 ]]; then
948
    copy_sidecars_json
949
    write_manifest
950
  else
951
    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
952
  fi
953

            
954
  total_ended_at="$(date +%s)"
955
  total_elapsed_sec=$((total_ended_at - total_started_at))
956
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
957

            
958
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
959
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
960
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
961
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
962

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

            
970
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
971
  if [[ "$file_post_total_sec" -lt 0 ]]; then
972
    file_post_total_sec=0
973
  fi
974
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
975

            
976
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
977
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
978
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
979
  fi
Bogdan Timofte authored a month ago
980
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
981

            
982
  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
983
  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"
984
  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
985

            
986
  if [[ "$ERRORS" -gt 0 ]]; then
987
    exit 1
988
  fi
989
}
990

            
991
main "$@"