VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1506 lines | 46.402kb
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
Bogdan Timofte authored a month ago
42
FAIL_FAST=true
43
SOURCE_READABLE_MODE="normal"
44
APPLE_REPACK_FALLBACK=true
45
REPACKED_SOURCE_PATH=""
Bogdan Timofte authored a month ago
46

            
47
HAS_VIDEOTOOLBOX=false
48
HAS_LIBX265=false
49
HAS_LIBX264=false
Bogdan Timofte authored a month ago
50
HAS_AVCONVERT=false
Bogdan Timofte authored a month ago
51

            
52
ENCODER_KIND=""
53
VIDEO_CODEC=""
54
VIDEO_CRF=""
55
VIDEO_ARGS=()
56
FIND_EXT_EXPR=()
Bogdan Timofte authored a month ago
57
EXT_LIST=()
58
VIDEO_FILES=()
Bogdan Timofte authored a month ago
59

            
60
VIDEOS_PROCESSED=0
61
VIDEOS_SKIPPED=0
62
JSON_COPIED=0
63
JSON_SKIPPED=0
64
ERRORS=0
Bogdan Timofte authored a month ago
65
INVALID_SOURCES_SKIPPED=0
66
DESTINATION_FAILURES=0
Bogdan Timofte authored a month ago
67
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
68
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
69

            
70
DURATION_TOLERANCE_SEC=1.0
Bogdan Timofte authored a month ago
71
STOP_AFTER_CURRENT=false
72
INTERRUPT_COUNT=0
73
CURRENT_FFMPEG_PID=""
74
PROGRESS_LINE_OPEN=false
Bogdan Timofte authored a month ago
75

            
76
usage() {
77
  cat <<'EOF'
78
Usage:
79
  garmin_varia_transcode.sh [options]
80

            
81
Options:
82
  -s, --source, --input DIR       Source directory or single file (default: current directory)
83
  -d, --destination, --output DIR Destination directory (default: current directory)
84
  --mode MODE                     Encoding mode (default: hardware); see Encoding Modes below
85
  --crf N                         CRF value for quality/compat modes (lower = better; default: 20/19)
86
  --no-overwrite                  Skip files that already exist at destination (default: overwrite)
87
  --dry-run                       Print actions without writing files
88
  --no-recursive                  Process only the top-level source directory (default: recursive)
89
  --extensions LIST               Comma-separated video extensions (default: mp4,mov,avi,m4v)
90
  --verbose                       Log each operation with timestamp; show ffmpeg/ffprobe output
Bogdan Timofte authored a month ago
91
  --delete-source                 Delete source file only after strict post-encode validation
92
  --keep-going                    Continue after source-file failures (default: stop)
93
  --unattended                    Preset for long runs: --delete-source + --keep-going
94
  --no-apple-repack-fallback      Disable macOS avconvert fallback for unreadable MP4/MOV sources
95
  --apple-repack-fallback         Enable macOS avconvert fallback (default)
Bogdan Timofte authored a month ago
96
  -h, --help                      Show this help
97

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

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

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

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

            
118
Behavior:
119
  - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible)
120
  - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility
Bogdan Timofte authored a month ago
121
  - Metadata is restored from source to output with exiftool
122
  - Garmin camera model is also mapped to standard Make/Model tags for compatibility
123
  - Inferred lens metadata is written from Garmin's declared 140-degree FOV
124
  - Transcoding encoder/mode is written in metadata (Software)
Bogdan Timofte authored a month ago
125
  - Original directory structure is preserved under destination
126
  - When --source points to a file, only that file is processed
127
  - JSON sidecar files found in source are copied 1:1 to destination
128
  - telemetry_manifest.json is created in destination as a placeholder contract
129

            
130
Examples:
131
  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
132
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
133
  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
134
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
135
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
Bogdan Timofte authored a month ago
136
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --delete-source
137
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --unattended
Bogdan Timofte authored a month ago
138
EOF
139
}
140

            
141
log_msg() {
142
  local level="$1"
143
  shift
144
  local ts
145
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
146
  echo "[$ts] [$level] $*"
147
}
148

            
149
# Verbose-only log: suppressed in default quiet mode
150
vlog_msg() {
151
  [[ "$VERBOSE" == true ]] && log_msg "$@"
152
  return 0
153
}
154

            
Bogdan Timofte authored a month ago
155
# Quiet-mode per-file progress lines
156
log_progress_start() {
Bogdan Timofte authored a month ago
157
  local input_file="$1"
Bogdan Timofte authored a month ago
158
  local ts
159
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
160
  printf '%s : Transcoding %s ...' "$ts" "$input_file"
161
  PROGRESS_LINE_OPEN=true
162
}
163

            
164
log_progress_done() {
Bogdan Timofte authored a month ago
165
  local real_elapsed_sec="$2"
166
  local encode_elapsed_sec="$3"
Bogdan Timofte authored a month ago
167
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
168
    printf ' done in %ss (encode %ss)\n' "$real_elapsed_sec" "$encode_elapsed_sec"
169
    PROGRESS_LINE_OPEN=false
170
    return
171
  fi
172

            
173
  local input_file="$1"
Bogdan Timofte authored a month ago
174
  local ts
175
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
176
  echo "$ts : Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
177
}
178

            
Bogdan Timofte authored a month ago
179
log_progress_failed() {
180
  local input_file="$1"
181
  local real_elapsed_sec="$2"
182
  local encode_elapsed_sec="$3"
183
  local ffmpeg_rc="$4"
184
  local ffmpeg_log="$5"
185

            
186
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
187
    printf ' FAILED after %ss (encode %ss, rc=%s, log=%s)\n' "$real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "${ffmpeg_log:-n/a}"
188
    PROGRESS_LINE_OPEN=false
189
    return
190
  fi
191

            
192
  local ts
193
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
194
  echo "$ts : Transcoding $input_file ... FAILED after ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
195
}
196

            
197
log_progress_skipped_unreadable() {
198
  local input_file="$1"
199

            
200
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
201
    printf ' SKIPPED (unreadable/corrupted source)\n'
202
    PROGRESS_LINE_OPEN=false
203
    return
204
  fi
205

            
206
  local ts
207
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
208
  echo "$ts : Transcoding $input_file ... SKIPPED (unreadable/corrupted source)"
209
}
210

            
Bogdan Timofte authored a month ago
211
die() {
212
  log_msg "ERROR" "$*"
213
  exit 1
214
}
215

            
Bogdan Timofte authored a month ago
216
handle_interrupt() {
217
  INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
218
  STOP_AFTER_CURRENT=true
219

            
220
  if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
221
    if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
222
      log_msg "WARN" "Stop requested. Will stop after current file. Press Ctrl+C again to abort current encode immediately."
223
    else
224
      log_msg "WARN" "Stop requested. Exiting before next file."
225
    fi
226
    return
227
  fi
228

            
229
  if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
230
    log_msg "WARN" "Force-stopping current encode (pid=$CURRENT_FFMPEG_PID)."
231
    kill -INT "$CURRENT_FFMPEG_PID" 2>/dev/null || true
232
  fi
233
}
234

            
235
run_ffmpeg_with_signal_guard() {
236
  (
237
    trap '' INT TERM
238
    exec "$@"
239
  ) &
240

            
241
  CURRENT_FFMPEG_PID=$!
242
  if wait "$CURRENT_FFMPEG_PID"; then
243
    CURRENT_FFMPEG_PID=""
244
    return 0
245
  fi
246

            
247
  local rc=$?
248
  CURRENT_FFMPEG_PID=""
249
  return "$rc"
250
}
251

            
Bogdan Timofte authored a month ago
252
format_seconds() {
253
  local sec="$1"
254
  local h m s
255
  h=$((sec / 3600))
256
  m=$(((sec % 3600) / 60))
257
  s=$((sec % 60))
258
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
259
}
260

            
Bogdan Timofte authored a month ago
261
print_final_report() {
262
  local total_elapsed_sec="$1"
263
  local total_elapsed_fmt="$2"
264
  local file_post_total_sec="$3"
265
  local file_post_total_fmt="$4"
266
  local run_non_file_overhead_sec="$5"
267
  local run_non_file_overhead_fmt="$6"
268
  local avg_file_real_time_sec="$7"
269
  local avg_file_real_time_fmt="$8"
270
  local avg_video_time_sec="$9"
271
  local avg_video_time_fmt="${10}"
272

            
273
  printf '\n'
274
  printf 'Run Summary\n'
275
  printf '+---------------------------+-------+\n'
276
  printf '| %-25s | %-5s |\n' "Metric" "Value"
277
  printf '+---------------------------+-------+\n'
278
  printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED"
279
  printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED"
280
  printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED"
281
  printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES"
282
  printf '| %-25s | %5s |\n' "JSON copied" "$JSON_COPIED"
283
  printf '| %-25s | %5s |\n' "JSON skipped" "$JSON_SKIPPED"
284
  printf '| %-25s | %5s |\n' "Errors" "$ERRORS"
285
  printf '+---------------------------+-------+\n'
286

            
287
  printf '\nTimings\n'
288
  printf '+---------------------------+---------+----------+\n'
289
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
290
  printf '+---------------------------+---------+----------+\n'
291
  printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt"
292
  printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")"
293
  printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")"
294
  printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt"
295
  printf '| %-25s | %7s | %-8s |\n' "Non-file overhead" "${run_non_file_overhead_sec}s" "$run_non_file_overhead_fmt"
296
  printf '+---------------------------+---------+----------+\n'
297

            
298
  printf '\nPer-File Averages\n'
299
  printf '+---------------------------+---------+----------+\n'
300
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
301
  printf '+---------------------------+---------+----------+\n'
302
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
303
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
304
  printf '+---------------------------+---------+----------+\n'
305
}
306

            
Bogdan Timofte authored a month ago
307
make_temp_log_file() {
308
  local temp_path
309
  local base_tmp="${TMPDIR:-/tmp}"
310
  base_tmp="${base_tmp%/}"
311

            
312
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
313
  if [[ -z "$temp_path" ]]; then
314
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
315
  fi
316

            
317
  printf '%s\n' "$temp_path"
318
}
319

            
Bogdan Timofte authored a month ago
320
make_temp_output_file() {
321
  local target_file="$1"
322
  local target_dir target_base target_noext temp_path seed_path
323

            
324
  target_dir="$(dirname "$target_file")"
325
  target_base="$(basename "$target_file")"
326
  target_noext="${target_base%.*}"
327

            
328
  seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
329
  if [[ -n "$seed_path" ]]; then
330
    rm -f -- "$seed_path" 2>/dev/null || true
331
    temp_path="${seed_path}.mp4"
332
  else
333
    temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
334
    if ! touch "$temp_path" >/dev/null 2>&1; then
335
      temp_path=""
336
    else
337
      rm -f -- "$temp_path" 2>/dev/null || true
338
    fi
339
  fi
340

            
341
  printf '%s\n' "$temp_path"
342
}
343

            
344
cleanup_transcode_artifacts() {
345
  local temp_output="$1"
346
  local final_output="$2"
347

            
348
  rm -f -- "$temp_output" 2>/dev/null || true
349

            
350
  # Defensive cleanup for previously failed non-atomic runs.
351
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
352
    rm -f -- "$final_output" 2>/dev/null || true
353
  fi
354
}
355

            
356
is_apple_noise_file() {
357
  local file_path="$1"
358
  local base
359
  base="$(basename "$file_path")"
360

            
361
  case "$base" in
362
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
363
      return 0
364
      ;;
365
  esac
366

            
367
  return 1
368
}
369

            
Bogdan Timofte authored a month ago
370
require_value() {
371
  local flag="$1"
372
  local value="${2:-}"
373
  if [[ -z "$value" ]]; then
374
    die "Missing value for $flag"
375
  fi
376
}
377

            
378
to_abs_path() {
379
  local p="$1"
380
  if [[ "$p" = /* ]]; then
381
    printf '%s\n' "$p"
382
  else
383
    printf '%s\n' "$PWD/$p"
384
  fi
385
}
386

            
387
path_is_within() {
388
  local child="$1"
389
  local parent="$2"
390
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
391
}
392

            
393
join_cmd_for_log() {
394
  local out=""
395
  local arg
396
  for arg in "$@"; do
397
    out+="$(printf '%q' "$arg") "
398
  done
399
  printf '%s\n' "${out% }"
400
}
401

            
402
parse_args() {
403
  while [[ $# -gt 0 ]]; do
404
    case "$1" in
405
      -s|--source|--input)
406
        require_value "$1" "${2:-}"
407
        SOURCE_DIR="$2"
408
        SOURCE_PROVIDED=true
409
        shift 2
410
        ;;
411
      -d|--destination|--output)
412
        require_value "$1" "${2:-}"
413
        DEST_DIR="$2"
414
        DEST_PROVIDED=true
415
        shift 2
416
        ;;
417
      --mode)
418
        require_value "$1" "${2:-}"
419
        MODE="$2"
420
        shift 2
421
        ;;
422
      --crf)
423
        require_value "$1" "${2:-}"
424
        CRF_OVERRIDE="$2"
425
        shift 2
426
        ;;
427
      --overwrite)
428
        OVERWRITE=true
429
        shift
430
        ;;
431
      --no-overwrite)
432
        OVERWRITE=false
433
        shift
434
        ;;
435
      --dry-run)
436
        DRY_RUN=true
437
        shift
438
        ;;
439
      --recursive)
440
        RECURSIVE=true
441
        shift
442
        ;;
443
      --no-recursive)
444
        RECURSIVE=false
445
        shift
446
        ;;
447
      --extensions)
448
        require_value "$1" "${2:-}"
449
        EXTENSIONS_CSV="$2"
450
        shift 2
451
        ;;
452
      --single)
453
        require_value "$1" "${2:-}"
454
        SINGLE_FILE="$2"
455
        shift 2
456
        ;;
457
      --verbose)
458
        VERBOSE=true
459
        shift
460
        ;;
Bogdan Timofte authored a month ago
461
      --delete-source)
462
        MOVE_SOURCE=true
463
        shift
464
        ;;
465
      --keep-going)
466
        FAIL_FAST=false
467
        shift
468
        ;;
469
      --unattended)
470
        MOVE_SOURCE=true
471
        FAIL_FAST=false
472
        shift
473
        ;;
474
      --no-apple-repack-fallback)
475
        APPLE_REPACK_FALLBACK=false
476
        shift
477
        ;;
478
      --apple-repack-fallback)
479
        APPLE_REPACK_FALLBACK=true
480
        shift
481
        ;;
Bogdan Timofte authored a month ago
482
      --move-source)
483
        MOVE_SOURCE=true
484
        shift
485
        ;;
Bogdan Timofte authored a month ago
486
      --continue-on-error)
487
        FAIL_FAST=false
488
        shift
489
        ;;
Bogdan Timofte authored a month ago
490
      -h|--help)
491
        usage
492
        exit 0
493
        ;;
494
      *)
495
        die "Unknown argument: $1"
496
        ;;
497
    esac
498
  done
499

            
500
  case "$MODE" in
501
    auto|hardware|quality|compat) ;;
502
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
503
  esac
504

            
505
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
506
    die "At least one of --source or --destination must be provided"
507
  fi
508

            
509
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
510
    die "--crf must be an integer"
511
  fi
512
}
513

            
514
check_tools() {
515
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
516
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
517
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
518

            
519
  HAS_AVCONVERT=false
520
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
521
    HAS_AVCONVERT=true
522
  fi
523
}
524

            
525
make_temp_repack_file() {
526
  local base_tmp="${TMPDIR:-/tmp}"
527
  local seed_path temp_path
528

            
529
  base_tmp="${base_tmp%/}"
530
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
531
  if [[ -n "$seed_path" ]]; then
532
    rm -f -- "$seed_path" 2>/dev/null || true
533
    temp_path="${seed_path}.mp4"
534
    printf '%s\n' "$temp_path"
535
    return
536
  fi
537

            
538
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
539
  printf '%s\n' "$temp_path"
540
}
541

            
542
cleanup_repacked_input() {
543
  local repacked_file="$1"
544
  if [[ -n "$repacked_file" ]]; then
545
    rm -f -- "$repacked_file" 2>/dev/null || true
546
  fi
547
}
548

            
549
try_apple_repack_for_unreadable() {
550
  local source_file="$1"
551
  local repacked_file=""
552

            
553
  REPACKED_SOURCE_PATH=""
554

            
555
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
556
    return 1
557
  fi
558

            
559
  repacked_file="$(make_temp_repack_file)"
560
  if [[ -z "$repacked_file" ]]; then
561
    return 1
562
  fi
563

            
564
  if [[ "$VERBOSE" == true ]]; then
565
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
566
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
567
      cleanup_repacked_input "$repacked_file"
568
      return 1
569
    fi
570
  else
571
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
572
      cleanup_repacked_input "$repacked_file"
573
      return 1
574
    fi
575
  fi
576

            
577
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
578
    REPACKED_SOURCE_PATH="$repacked_file"
579
    return 0
580
  fi
581

            
582
  cleanup_repacked_input "$repacked_file"
583
  return 1
Bogdan Timofte authored a month ago
584
}
585

            
586
restore_metadata_with_exiftool() {
587
  local input_file="$1"
588
  local output_file="$2"
589

            
590
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
591

            
592
  if [[ "$VERBOSE" == true ]]; then
593
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
594
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
595
      return 0
596
    fi
597
  else
598
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
599
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
600
      return 0
601
    fi
602
  fi
603

            
Bogdan Timofte authored a month ago
604
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
605
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
606
    return 1
607
  fi
608

            
Bogdan Timofte authored a month ago
609
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
610
  return 1
611
}
612

            
613
map_garmin_model_to_standard_tags() {
614
  local input_file="$1"
615
  local output_file="$2"
616
  local garmin_model
617

            
618
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
619
  if [[ -z "$garmin_model" ]]; then
620
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
621
    return 0
622
  fi
623

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

            
628
  if [[ "$VERBOSE" == true ]]; then
629
    if exiftool -overwrite_original -m \
630
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
631
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
632
      -Keys:CompatibleBrands="isom, iso2, mp41" \
633
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
634
      -Make="Garmin" -Model="Varia RCT715" \
635
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
636
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
637
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
638
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
639
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
640
      "$output_file"; then
641
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
642
      return 0
643
    fi
644
  else
645
    if exiftool -overwrite_original -m -q -q \
646
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
647
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
648
      -Keys:CompatibleBrands="isom, iso2, mp41" \
649
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
650
      -Make="Garmin" -Model="Varia RCT715" \
651
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
652
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
653
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
654
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
655
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
656
      "$output_file" >/dev/null 2>&1; then
657
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
658
      return 0
659
    fi
660
  fi
661

            
662
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
663
  return 1
664
}
665

            
666
write_transcode_encoder_metadata() {
667
  local output_file="$1"
668
  local encoder_meta
669

            
670
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
671

            
672
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
673

            
674
  if [[ "$VERBOSE" == true ]]; then
675
    if exiftool -overwrite_original -m \
676
      -Software="$encoder_meta" \
677
      -UserData:Software="$encoder_meta" \
678
      "$output_file"; then
679
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
680
      return 0
681
    fi
682
  else
683
    if exiftool -overwrite_original -m -q -q \
684
      -Software="$encoder_meta" \
685
      -UserData:Software="$encoder_meta" \
686
      "$output_file" >/dev/null 2>&1; then
687
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
688
      return 0
689
    fi
690
  fi
691

            
692
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
693
  return 1
Bogdan Timofte authored a month ago
694
}
695

            
696
detect_encoders() {
697
  local encoders
698
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
699

            
700
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
701
    HAS_VIDEOTOOLBOX=true
702
  fi
703
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
704
    HAS_LIBX265=true
705
  fi
706
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
707
    HAS_LIBX264=true
708
  fi
709

            
710
  if [[ "$VERBOSE" == true ]]; then
711
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
712
  fi
713
}
714

            
715
resolve_encoder() {
716
  local os_name
717
  os_name="$(uname -s)"
718

            
719
  case "$MODE" in
720
    auto)
721
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
722
        ENCODER_KIND="hardware"
723
      elif [[ "$HAS_LIBX265" == true ]]; then
724
        ENCODER_KIND="quality"
725
      elif [[ "$HAS_LIBX264" == true ]]; then
726
        ENCODER_KIND="compat"
727
      else
728
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
729
      fi
730
      ;;
731
    hardware)
732
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
733
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
734
      ENCODER_KIND="hardware"
735
      ;;
736
    quality)
737
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
738
      ENCODER_KIND="quality"
739
      ;;
740
    compat)
741
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
742
      ENCODER_KIND="compat"
743
      ;;
744
  esac
745

            
746
  case "$ENCODER_KIND" in
747
    hardware)
748
      VIDEO_CODEC="hevc_videotoolbox"
749
      VIDEO_CRF=""
750
      ;;
751
    quality)
752
      VIDEO_CODEC="libx265"
753
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
754
      ;;
755
    compat)
756
      VIDEO_CODEC="libx264"
757
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
758
      ;;
759
  esac
760

            
761
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
762
}
763

            
764
probe_has_audio() {
765
  local input_file="$1"
766
  local out
767
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
768
  [[ -n "$out" ]]
769
}
770

            
771
print_verbose_probe() {
772
  local input_file="$1"
773
  ffprobe -v error \
774
    -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 \
775
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
776
}
777

            
778
ffprobe_duration_or_empty() {
779
  local file_path="$1"
780
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
781
}
782

            
783
ffprobe_video_codec_or_empty() {
784
  local file_path="$1"
785
  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
786
}
787

            
Bogdan Timofte authored a month ago
788
source_video_is_readable() {
789
  local file_path="$1"
790
  REPACKED_SOURCE_PATH=""
791

            
792
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
793
    SOURCE_READABLE_MODE="normal"
794
    return 0
795
  fi
796

            
797
  if [[ "$VERBOSE" == true ]]; then
798
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
799
  fi
800

            
801
  if ffprobe -v error -fflags +genpts -err_detect ignore_err -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
802
    SOURCE_READABLE_MODE="tolerant"
803
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
804
    return 0
805
  fi
806

            
807
  if try_apple_repack_for_unreadable "$file_path"; then
808
    SOURCE_READABLE_MODE="repacked"
809
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
810
    return 0
811
  fi
812

            
813
  SOURCE_READABLE_MODE="normal"
814
  return 1
815
}
816

            
817
source_error_from_ffmpeg_log() {
818
  local ffmpeg_log="$1"
819
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
820
  grep -Eiq 'invalid data found when processing input|error reading header|moov atom not found|corrupt|truncated|end of file|contradictionary STSC and STCO' "$ffmpeg_log"
821
}
822

            
823
destination_cannot_accept_file() {
824
  local input_file="$1"
825
  local out_dir="$2"
826
  local ffmpeg_log="$3"
827
  local probe_path=""
828
  local avail_kb=""
829
  local avail_bytes=0
830

            
831
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
832
    if grep -Eiq 'no space left on device|disk quota exceeded|read-only file system|permission denied|file too large|input/output error|stale file handle' "$ffmpeg_log"; then
833
      return 0
834
    fi
835
  fi
836

            
837
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
838
  if [[ -z "$probe_path" ]]; then
839
    return 0
840
  fi
841
  rm -f -- "$probe_path" 2>/dev/null || true
842

            
843
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
844
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
845
    avail_bytes=$((avail_kb * 1024))
846
    if [[ "$avail_bytes" -lt 67108864 ]]; then
847
      return 0
848
    fi
849
  fi
850

            
851
  return 1
852
}
853

            
Bogdan Timofte authored a month ago
854
validate_transcoded_output() {
855
  local input_file="$1"
856
  local output_file="$2"
857

            
858
  if [[ ! -f "$output_file" ]]; then
859
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
860
    return 1
861
  fi
862

            
863
  local expected_codec actual_codec
864
  case "$ENCODER_KIND" in
865
    hardware|quality) expected_codec="hevc" ;;
866
    compat) expected_codec="h264" ;;
867
    *)
868
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
869
      return 1
870
      ;;
871
  esac
872

            
873
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
874
  if [[ -z "$actual_codec" ]]; then
875
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
876
    return 1
877
  fi
878
  if [[ "$actual_codec" != "$expected_codec" ]]; then
879
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
880
    return 1
881
  fi
882

            
883
  local in_duration out_duration duration_delta
884
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
885
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
886

            
887
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
888
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
889
    return 1
890
  fi
891

            
892
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
893
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
894
    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)"
895
    return 1
896
  fi
897

            
898
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
899
  return 0
900
}
901

            
902
build_video_args() {
903
  VIDEO_ARGS=()
904

            
905
  case "$ENCODER_KIND" in
906
    hardware)
907
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
908
      ;;
909
    quality)
910
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
911
      ;;
912
    compat)
913
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
914
      ;;
915
  esac
916
}
917

            
918
normalize_source_dir() {
919
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
920
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
921
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
922
}
923

            
924
normalize_dest_dir() {
925
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
926
}
927

            
928
collect_extensions() {
929
  local raw="$EXTENSIONS_CSV"
930
  local token
931
  EXT_LIST=()
932

            
933
  IFS=',' read -r -a tokens <<< "$raw"
934
  for token in "${tokens[@]}"; do
935
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
936
    token="${token#.}"
937
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
938
    [[ -n "$token" ]] && EXT_LIST+=("$token")
939
  done
940

            
941
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
942
    die "No valid extensions after parsing --extensions"
943
  fi
944
}
945

            
946
build_find_expr_for_extensions() {
947
  FIND_EXT_EXPR=()
948
  local ext
949
  for ext in "${EXT_LIST[@]}"; do
950
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
951
  done
952
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
953
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
954
  fi
955
}
956

            
957
rel_path_from_source() {
958
  local abs_file="$1"
959
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
960
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
961
  else
962
    printf '%s\n' "$(basename "$abs_file")"
963
  fi
964
}
965

            
966
collect_video_files() {
967
  VIDEO_FILES=()
968

            
969
  if [[ -n "$SINGLE_FILE" ]]; then
970
    local single_abs
971
    single_abs="$(to_abs_path "$SINGLE_FILE")"
972
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
973
    VIDEO_FILES+=("$single_abs")
974
    return
975
  fi
976

            
977
  build_find_expr_for_extensions
978

            
979
  while IFS= read -r -d '' file; do
Bogdan Timofte authored a month ago
980
    if is_apple_noise_file "$file"; then
981
      vlog_msg "SKIP" "Ignoring Apple artifact: $file"
982
      continue
983
    fi
Bogdan Timofte authored a month ago
984
    VIDEO_FILES+=("$file")
985
  done < <(
986
    if [[ "$RECURSIVE" == true ]]; then
987
      find "$SOURCE_DIR" \
988
        -path "$DEST_DIR" -prune -o \
989
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
990
    else
991
      find "$SOURCE_DIR" \
992
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
993
    fi
994
  )
995
}
996

            
997
process_video_file() {
998
  local input_file="$1"
Bogdan Timofte authored a month ago
999
  local rel_path output_file temp_output_file out_dir
1000
  local display_path
1001
  local encode_input_file
1002
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1003
  local file_started_at file_ended_at file_real_elapsed_sec
1004
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1005
  local post_elapsed_sec
1006

            
1007
  file_started_at="$(date +%s)"
1008
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1009

            
1010
  rel_path="$(rel_path_from_source "$input_file")"
1011
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1012
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1013
  display_path="${input_file#$PWD/}"
1014
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1015

            
1016
  mkdir -p "$out_dir"
1017

            
Bogdan Timofte authored a month ago
1018
  if ! source_video_is_readable "$input_file"; then
1019
    ERRORS=$((ERRORS + 1))
1020
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1021
    if [[ "$VERBOSE" == true ]]; then
1022
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1023
    else
1024
      log_progress_start "$display_path"
1025
      log_progress_skipped_unreadable "$display_path"
1026
    fi
1027
    return 2
1028
  fi
1029

            
1030
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1031
    repacked_input_file="$REPACKED_SOURCE_PATH"
1032
    encode_input_file="$REPACKED_SOURCE_PATH"
1033
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1034
  fi
1035

            
Bogdan Timofte authored a month ago
1036
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1037
    vlog_msg "SKIP" "Video exists: $output_file"
1038
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1039
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1040
    return
1041
  fi
1042

            
1043
  local has_audio=false
Bogdan Timofte authored a month ago
1044
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1045
    has_audio=true
1046
  fi
1047

            
1048
  if [[ "$VERBOSE" == true ]]; then
1049
    log_msg "INFO" "ffprobe summary: $input_file"
1050
    print_verbose_probe "$input_file"
1051
    log_msg "INFO" "Audio detected: $has_audio"
1052
  fi
1053

            
1054
  build_video_args
1055

            
Bogdan Timofte authored a month ago
1056
  temp_output_file="$(make_temp_output_file "$output_file")"
1057
  if [[ -z "$temp_output_file" ]]; then
1058
    ERRORS=$((ERRORS + 1))
1059
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1060
    log_msg "ERROR" "Could not create temporary output file: $output_file"
1061
    return 3
1062
  fi
1063

            
Bogdan Timofte authored a month ago
1064
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1065
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1066
    cmd+=( -fflags +genpts -err_detect ignore_err )
1067
  fi
Bogdan Timofte authored a month ago
1068
  if [[ "$OVERWRITE" == true ]]; then
1069
    cmd+=( -y )
1070
  else
1071
    cmd+=( -n )
1072
  fi
1073

            
Bogdan Timofte authored a month ago
1074
  cmd+=( -i "$encode_input_file" -map 0:v:0 )
Bogdan Timofte authored a month ago
1075

            
1076
  if [[ "$has_audio" == true ]]; then
1077
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1078
  fi
1079

            
Bogdan Timofte authored a month ago
1080
  cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$temp_output_file" )
Bogdan Timofte authored a month ago
1081

            
1082
  if [[ "$VERBOSE" == true ]]; then
1083
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1084
  fi
1085

            
1086
  if [[ "$DRY_RUN" == true ]]; then
1087
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1088
    return 0
1089
  fi
1090

            
Bogdan Timofte authored a month ago
1091
  if [[ "$VERBOSE" != true ]]; then
1092
    log_progress_start "$display_path"
1093
  fi
1094

            
1095
  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
Bogdan Timofte authored a month ago
1096
  encode_started_at="$(date +%s)"
1097
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
1098

            
1099
  local ffmpeg_rc=0
Bogdan Timofte authored a month ago
1100
  local ffmpeg_log=""
Bogdan Timofte authored a month ago
1101
  if [[ "$VERBOSE" == true ]]; then
1102
    # Verbose: show ffmpeg output directly
Bogdan Timofte authored a month ago
1103
    if run_ffmpeg_with_signal_guard "${cmd[@]}"; then
Bogdan Timofte authored a month ago
1104
      :
1105
    else
1106
      ffmpeg_rc=$?
1107
    fi
1108
  else
1109
    # Quiet (default): redirect ffmpeg output; keep log on failure
1110
    ffmpeg_log="$(make_temp_log_file)"
1111
    if [[ -z "$ffmpeg_log" ]]; then
1112
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1113
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored a month ago
1114
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
Bogdan Timofte authored a month ago
1115
      return 3
Bogdan Timofte authored a month ago
1116
    fi
Bogdan Timofte authored a month ago
1117
    if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
Bogdan Timofte authored a month ago
1118
      rm -f "$ffmpeg_log"
1119
    else
1120
      ffmpeg_rc=$?
1121
    fi
1122
  fi
1123

            
Bogdan Timofte authored a month ago
1124
  encode_ended_at="$(date +%s)"
1125
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1126
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1127

            
1128
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1129
    local failure_rc=1
1130
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1131
      failure_rc=3
1132
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1133
      failure_rc=2
1134
    fi
1135

            
1136
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1137
    file_ended_at="$(date +%s)"
1138
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1139
    local encode_elapsed_fmt
1140
    local real_elapsed_fmt
1141
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1142
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1143

            
Bogdan Timofte authored a month ago
1144
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1145
    if [[ "$failure_rc" -eq 2 ]]; then
1146
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1147
    elif [[ "$failure_rc" -eq 3 ]]; then
1148
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1149
    fi
Bogdan Timofte authored a month ago
1150
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1151
      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
1152
    else
Bogdan Timofte authored a month ago
1153
      log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log"
Bogdan Timofte authored a month ago
1154
    fi
Bogdan Timofte authored a month ago
1155
    if [[ "$failure_rc" -eq 3 ]]; then
1156
      log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1157
    fi
1158
    cleanup_repacked_input "$repacked_input_file"
1159
    return "$failure_rc"
Bogdan Timofte authored a month ago
1160
  fi
1161

            
Bogdan Timofte authored a month ago
1162
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1163

            
Bogdan Timofte authored a month ago
1164
  if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then
1165
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1166
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1167
      cleanup_repacked_input "$repacked_input_file"
1168
      return 4
1169
    fi
Bogdan Timofte authored a month ago
1170
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1171
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1172
    cleanup_repacked_input "$repacked_input_file"
1173
    return 3
Bogdan Timofte authored a month ago
1174
  fi
1175

            
Bogdan Timofte authored a month ago
1176
  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1177
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1178
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1179
      cleanup_repacked_input "$repacked_input_file"
1180
      return 4
1181
    fi
Bogdan Timofte authored a month ago
1182
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1183
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1184
    cleanup_repacked_input "$repacked_input_file"
1185
    return 3
Bogdan Timofte authored a month ago
1186
  fi
1187

            
Bogdan Timofte authored a month ago
1188
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1189
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1190
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1191
      cleanup_repacked_input "$repacked_input_file"
1192
      return 4
1193
    fi
Bogdan Timofte authored a month ago
1194
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1195
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1196
    cleanup_repacked_input "$repacked_input_file"
1197
    return 3
Bogdan Timofte authored a month ago
1198
  fi
Bogdan Timofte authored a month ago
1199

            
1200
  if [[ "$MOVE_SOURCE" == true ]]; then
Bogdan Timofte authored a month ago
1201
    if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then
1202
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1203
      if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1204
        cleanup_repacked_input "$repacked_input_file"
1205
        return 4
1206
      fi
Bogdan Timofte authored a month ago
1207
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1208
      if destination_cannot_accept_file "$input_file" "$out_dir" ""; then
1209
        DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1210
        cleanup_repacked_input "$repacked_input_file"
1211
        return 3
1212
      fi
1213
      # Validation failed but destination is healthy: the source or encoder produced
1214
      # a corrupt/truncated output. Treat as source error so --keep-going can skip it.
1215
      log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file"
Bogdan Timofte authored a month ago
1216
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1217
      return 2
Bogdan Timofte authored a month ago
1218
    fi
1219
  fi
1220

            
Bogdan Timofte authored a month ago
1221
  touch -r "$input_file" "$temp_output_file" || true
1222

            
1223
  if [[ "$OVERWRITE" == true ]]; then
1224
    if ! mv -f "$temp_output_file" "$output_file"; then
1225
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1226
      ERRORS=$((ERRORS + 1))
1227
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1228
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1229
      cleanup_repacked_input "$repacked_input_file"
1230
      return 3
1231
    fi
1232
  else
1233
    if ! mv -n "$temp_output_file" "$output_file"; then
1234
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1235
      ERRORS=$((ERRORS + 1))
1236
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1237
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1238
      cleanup_repacked_input "$repacked_input_file"
1239
      return 3
1240
    fi
1241
  fi
Bogdan Timofte authored a month ago
1242

            
1243
  if [[ "$MOVE_SOURCE" == true ]]; then
1244
    if rm -f "$input_file"; then
1245
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
1246
    else
1247
      ERRORS=$((ERRORS + 1))
1248
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
Bogdan Timofte authored a month ago
1249
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1250
      return 1
1251
    fi
1252
  fi
1253

            
Bogdan Timofte authored a month ago
1254
  file_ended_at="$(date +%s)"
1255
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1256
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1257
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1258
    post_elapsed_sec=0
1259
  fi
1260
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1261
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1262

            
Bogdan Timofte authored a month ago
1263
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1264

            
1265
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1266
    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
1267
  else
Bogdan Timofte authored a month ago
1268
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1269
  fi
1270

            
Bogdan Timofte authored a month ago
1271
  cleanup_repacked_input "$repacked_input_file"
1272

            
Bogdan Timofte authored a month ago
1273
  return 0
1274
}
1275

            
1276
copy_one_json() {
1277
  local json_file="$1"
1278
  local rel_json dst_json dst_dir
1279

            
1280
  rel_json="$(rel_path_from_source "$json_file")"
1281
  dst_json="$DEST_DIR/$rel_json"
1282
  dst_dir="$(dirname "$dst_json")"
1283

            
1284
  mkdir -p "$dst_dir"
1285

            
1286
  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
1287
    JSON_SKIPPED=$((JSON_SKIPPED + 1))
1288
    vlog_msg "SKIP" "JSON exists: $dst_json"
1289
    return
1290
  fi
1291

            
1292
  if [[ "$DRY_RUN" == true ]]; then
1293
    JSON_COPIED=$((JSON_COPIED + 1))
1294
    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
1295
    return
1296
  fi
1297

            
1298
  if cp -f "$json_file" "$dst_json"; then
1299
    touch -r "$json_file" "$dst_json" || true
1300
    JSON_COPIED=$((JSON_COPIED + 1))
1301
    vlog_msg "INFO" "Copied JSON: $dst_json"
1302
  else
1303
    ERRORS=$((ERRORS + 1))
1304
    log_msg "ERROR" "Failed to copy JSON: $json_file"
1305
  fi
1306
}
1307

            
1308
copy_sidecars_json() {
1309
  local json_files=()
1310

            
1311
  if [[ -n "$SINGLE_FILE" ]]; then
1312
    local single_abs rel_path candidate
1313
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1314
    rel_path="$(rel_path_from_source "$single_abs")"
1315
    candidate="$SOURCE_DIR/${rel_path%.*}.json"
1316
    if [[ -f "$candidate" ]]; then
1317
      json_files+=("$candidate")
1318
    fi
1319
  else
1320
    while IFS= read -r -d '' jf; do
1321
      json_files+=("$jf")
1322
    done < <(
1323
      if [[ "$RECURSIVE" == true ]]; then
1324
        find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
1325
      else
1326
        find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
1327
      fi
1328
    )
1329
  fi
1330

            
1331
  if [[ ${#json_files[@]} -eq 0 ]]; then
1332
    vlog_msg "INFO" "No JSON sidecars found to copy"
1333
    return
1334
  fi
1335

            
1336
  local jf
1337
  for jf in "${json_files[@]}"; do
Bogdan Timofte authored a month ago
1338
    if is_apple_noise_file "$jf"; then
1339
      vlog_msg "SKIP" "Ignoring Apple artifact JSON: $jf"
1340
      continue
1341
    fi
Bogdan Timofte authored a month ago
1342
    copy_one_json "$jf"
1343
  done
1344
}
1345

            
1346
write_manifest() {
1347
  local manifest_path="$DEST_DIR/telemetry_manifest.json"
1348

            
1349
  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
1350
    vlog_msg "SKIP" "Manifest exists: $manifest_path"
1351
    return
1352
  fi
1353

            
1354
  if [[ "$DRY_RUN" == true ]]; then
1355
    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
1356
    return
1357
  fi
1358

            
1359
  mkdir -p "$DEST_DIR"
1360

            
1361
  cat > "$manifest_path" <<EOF
1362
{
1363
  "schema_version": "0.1-draft",
1364
  "purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
1365
  "fields_target": [
1366
    "power_w",
1367
    "speed_kmh",
1368
    "heart_rate_bpm",
1369
    "cadence_rpm",
1370
    "gps"
1371
  ],
1372
  "sync_methods": [
1373
    "auto_timestamp_plus_offset",
1374
    "manual_offset_ms"
1375
  ],
1376
  "notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
1377
}
1378
EOF
1379

            
1380
  vlog_msg "INFO" "Wrote manifest: $manifest_path"
1381
}
1382

            
1383
main() {
1384
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1385
  total_started_at="$(date +%s)"
1386

            
Bogdan Timofte authored a month ago
1387
  trap 'handle_interrupt' INT TERM
1388

            
Bogdan Timofte authored a month ago
1389
  parse_args "$@"
1390
  check_tools
1391

            
1392
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1393
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1394
    SINGLE_FILE="$SOURCE_DIR"
1395
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1396
  fi
1397

            
1398
  normalize_source_dir
1399
  normalize_dest_dir
1400
  collect_extensions
1401

            
1402
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1403
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1404
  fi
1405

            
1406
  detect_encoders
1407
  resolve_encoder
1408

            
1409
  collect_video_files
Bogdan Timofte authored a month ago
1410
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1411
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1412
  fi
1413

            
1414
  local f
Bogdan Timofte authored a month ago
1415
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1416
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1417
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1418
      break
1419
    fi
Bogdan Timofte authored a month ago
1420

            
1421
    local process_rc=0
1422
    if process_video_file "$f"; then
1423
      continue
1424
    else
1425
      process_rc=$?
1426
    fi
1427

            
1428
    case "$process_rc" in
1429
      2)
1430
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1431
        continue
1432
        ;;
1433
      3)
1434
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1435
        break
1436
        ;;
1437
      4)
1438
        log_msg "INFO" "Stopped by user after current file"
1439
        break
1440
        ;;
1441
      *)
1442
        if [[ "$FAIL_FAST" == true ]]; then
1443
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1444
          break
1445
        fi
1446
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1447
        continue
1448
        ;;
1449
    esac
Bogdan Timofte authored a month ago
1450
  done
1451

            
Bogdan Timofte authored a month ago
1452
  if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1453
    log_msg "INFO" "Skipping sidecar copy and manifest because run was stopped by user"
1454
  elif [[ "$ERRORS" -eq 0 ]]; then
Bogdan Timofte authored a month ago
1455
    copy_sidecars_json
1456
    write_manifest
1457
  else
1458
    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
1459
  fi
1460

            
1461
  total_ended_at="$(date +%s)"
1462
  total_elapsed_sec=$((total_ended_at - total_started_at))
1463
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
1464

            
1465
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
1466
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
1467
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
1468
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
1469

            
Bogdan Timofte authored a month ago
1470
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
1471
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
1472
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
1473
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
1474
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
1475
  fi
1476

            
1477
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
1478
  if [[ "$file_post_total_sec" -lt 0 ]]; then
1479
    file_post_total_sec=0
1480
  fi
1481
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
1482

            
1483
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
1484
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
1485
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
1486
  fi
Bogdan Timofte authored a month ago
1487
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
1488

            
Bogdan Timofte authored a month ago
1489
  print_final_report \
1490
    "$total_elapsed_sec" \
1491
    "$total_elapsed_fmt" \
1492
    "$file_post_total_sec" \
1493
    "$file_post_total_fmt" \
1494
    "$run_non_file_overhead_sec" \
1495
    "$run_non_file_overhead_fmt" \
1496
    "$avg_file_real_time_sec" \
1497
    "$avg_file_real_time_fmt" \
1498
    "$avg_video_time_sec" \
1499
    "$avg_video_time_fmt"
Bogdan Timofte authored a month ago
1500

            
1501
  if [[ "$ERRORS" -gt 0 ]]; then
1502
    exit 1
1503
  fi
1504
}
1505

            
1506
main "$@"