VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1858 lines | 58.366kb
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
Bogdan Timofte authored a month ago
21
DEFAULT_STAGING_RAMDISK_MB=8192
Bogdan Timofte authored a month ago
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"
Bogdan Timofte authored a month ago
32
STAGING_DIR=""
Bogdan Timofte authored a month ago
33
MODE="$DEFAULT_MODE"
34
EXTENSIONS_CSV="$DEFAULT_EXTENSIONS"
35
CRF_OVERRIDE=""
36
OVERWRITE=true
37
DRY_RUN=false
38
RECURSIVE=true
39
SINGLE_FILE=""
40
VERBOSE=false
41
MOVE_SOURCE=false
42
SOURCE_PROVIDED=false
43
DEST_PROVIDED=false
Bogdan Timofte authored a month ago
44
STAGING_PROVIDED=false
Bogdan Timofte authored a month ago
45
STAGING_RAMDISK_MB="$DEFAULT_STAGING_RAMDISK_MB"
46
AUTO_CREATED_STAGING_RAMDISK=false
47
AUTO_CREATED_STAGING_PATH=""
48
STAGING_RAMDISK_CREATED_AT=0
49
RUN_STARTED_AT=0
50
FIRST_ENCODE_STARTED_AT=0
51
DEBUG_TIMING_LIMIT=0
52
DEBUG_TIMING_FILES=0
53
DEBUG_TIMING_STOPPED=false
Bogdan Timofte authored a month ago
54
FAIL_FAST=true
55
SOURCE_READABLE_MODE="normal"
56
APPLE_REPACK_FALLBACK=true
57
REPACKED_SOURCE_PATH=""
Bogdan Timofte authored a month ago
58

            
59
HAS_VIDEOTOOLBOX=false
60
HAS_LIBX265=false
61
HAS_LIBX264=false
Bogdan Timofte authored a month ago
62
HAS_AVCONVERT=false
Bogdan Timofte authored a month ago
63

            
64
ENCODER_KIND=""
65
VIDEO_CODEC=""
66
VIDEO_CRF=""
67
VIDEO_ARGS=()
68
FIND_EXT_EXPR=()
Bogdan Timofte authored a month ago
69
EXT_LIST=()
70
VIDEO_FILES=()
Bogdan Timofte authored a month ago
71

            
72
VIDEOS_PROCESSED=0
73
VIDEOS_SKIPPED=0
74
JSON_COPIED=0
75
JSON_SKIPPED=0
76
ERRORS=0
Bogdan Timofte authored a month ago
77
INVALID_SOURCES_SKIPPED=0
78
DESTINATION_FAILURES=0
Bogdan Timofte authored a month ago
79
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
80
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
81
INPUT_BYTES_PROCESSED=0
82
OUTPUT_BYTES_PROCESSED=0
Bogdan Timofte authored a month ago
83

            
84
DURATION_TOLERANCE_SEC=1.0
Bogdan Timofte authored a month ago
85
STOP_AFTER_CURRENT=false
86
INTERRUPT_COUNT=0
87
CURRENT_FFMPEG_PID=""
88
PROGRESS_LINE_OPEN=false
Bogdan Timofte authored a month ago
89

            
90
usage() {
91
  cat <<'EOF'
92
Usage:
93
  garmin_varia_transcode.sh [options]
94

            
95
Options:
96
  -s, --source, --input DIR       Source directory or single file (default: current directory)
97
  -d, --destination, --output DIR Destination directory (default: current directory)
Bogdan Timofte authored a month ago
98
  --staging-dir DIR               Temporary staging directory for intermediate output files
99
                                  (falls back to destination if staging cannot be used)
Bogdan Timofte authored a month ago
100
  --staging-ramdisk-mb N          RAM disk size in MB when auto-creating missing /Volumes staging
101
                                  directory on macOS (default: 8192)
102
  --debug-timing N                Process at most N video files, then stop and print timing stats
Bogdan Timofte authored a month ago
103
  --mode MODE                     Encoding mode (default: hardware); see Encoding Modes below
104
  --crf N                         CRF value for quality/compat modes (lower = better; default: 20/19)
105
  --no-overwrite                  Skip files that already exist at destination (default: overwrite)
106
  --dry-run                       Print actions without writing files
107
  --no-recursive                  Process only the top-level source directory (default: recursive)
108
  --extensions LIST               Comma-separated video extensions (default: mp4,mov,avi,m4v)
109
  --verbose                       Log each operation with timestamp; show ffmpeg/ffprobe output
Bogdan Timofte authored a month ago
110
  --delete-source                 Delete source file only after strict post-encode validation
111
  --keep-going                    Continue after source-file failures (default: stop)
112
  --unattended                    Preset for long runs: --delete-source + --keep-going
113
  --no-apple-repack-fallback      Disable macOS avconvert fallback for unreadable MP4/MOV sources
114
  --apple-repack-fallback         Enable macOS avconvert fallback (default)
Bogdan Timofte authored a month ago
115
  -h, --help                      Show this help
116

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

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

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

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

            
137
Behavior:
138
  - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible)
139
  - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility
Bogdan Timofte authored a month ago
140
  - Metadata is restored from source to output with exiftool
141
  - Garmin camera model is also mapped to standard Make/Model tags for compatibility
142
  - Inferred lens metadata is written from Garmin's declared 140-degree FOV
143
  - Transcoding encoder/mode is written in metadata (Software)
Bogdan Timofte authored a month ago
144
  - Original directory structure is preserved under destination
145
  - When --source points to a file, only that file is processed
146
  - JSON sidecar files found in source are copied 1:1 to destination
147
  - telemetry_manifest.json is created in destination as a placeholder contract
148

            
149
Examples:
150
  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
151
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
152
  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
153
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
154
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
Bogdan Timofte authored a month ago
155
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --delete-source
156
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --unattended
Bogdan Timofte authored a month ago
157
EOF
158
}
159

            
160
log_msg() {
161
  local level="$1"
162
  shift
163
  local ts
164
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
165
  echo "[$ts] [$level] $*"
166
}
167

            
168
# Verbose-only log: suppressed in default quiet mode
169
vlog_msg() {
170
  [[ "$VERBOSE" == true ]] && log_msg "$@"
171
  return 0
172
}
173

            
Bogdan Timofte authored a month ago
174
# Quiet-mode per-file progress lines
175
log_progress_start() {
Bogdan Timofte authored a month ago
176
  local input_file="$1"
Bogdan Timofte authored a month ago
177
  local ts
178
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
179
  printf '[%s] [INFO] Transcoding %s ...' "$ts" "$input_file"
Bogdan Timofte authored a month ago
180
  PROGRESS_LINE_OPEN=true
181
}
182

            
183
log_progress_done() {
Bogdan Timofte authored a month ago
184
  local real_elapsed_sec="$2"
185
  local encode_elapsed_sec="$3"
Bogdan Timofte authored a month ago
186
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
187
    printf ' done in %ss (encode %ss)\n' "$real_elapsed_sec" "$encode_elapsed_sec"
188
    PROGRESS_LINE_OPEN=false
189
    return
190
  fi
191

            
192
  local input_file="$1"
Bogdan Timofte authored a month ago
193
  local ts
194
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
195
  echo "[$ts] [INFO] Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
196
}
197

            
Bogdan Timofte authored a month ago
198
log_progress_failed() {
199
  local input_file="$1"
200
  local real_elapsed_sec="$2"
201
  local encode_elapsed_sec="$3"
202
  local ffmpeg_rc="$4"
203
  local ffmpeg_log="$5"
204

            
205
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
206
    printf ' FAILED after %ss (encode %ss, rc=%s, log=%s)\n' "$real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "${ffmpeg_log:-n/a}"
207
    PROGRESS_LINE_OPEN=false
208
    return
209
  fi
210

            
211
  local ts
212
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
213
  echo "[$ts] [ERROR] Transcoding $input_file ... FAILED after ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
Bogdan Timofte authored a month ago
214
}
215

            
216
log_progress_skipped_unreadable() {
217
  local input_file="$1"
218

            
219
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
220
    printf ' SKIPPED (unreadable/corrupted source)\n'
221
    PROGRESS_LINE_OPEN=false
222
    return
223
  fi
224

            
225
  local ts
226
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
227
  echo "[$ts] [INFO] Transcoding $input_file ... SKIPPED (unreadable/corrupted source)"
Bogdan Timofte authored a month ago
228
}
229

            
Bogdan Timofte authored a month ago
230
die() {
231
  log_msg "ERROR" "$*"
232
  exit 1
233
}
234

            
Bogdan Timofte authored a month ago
235
handle_interrupt() {
236
  INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
237
  STOP_AFTER_CURRENT=true
238

            
239
  if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
240
    if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
241
      log_msg "WARN" "Stop requested. Will stop after current file. Press Ctrl+C again to abort current encode immediately."
242
    else
243
      log_msg "WARN" "Stop requested. Exiting before next file."
244
    fi
245
    return
246
  fi
247

            
248
  if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
249
    log_msg "WARN" "Force-stopping current encode (pid=$CURRENT_FFMPEG_PID)."
250
    kill -INT "$CURRENT_FFMPEG_PID" 2>/dev/null || true
251
  fi
252
}
253

            
254
run_ffmpeg_with_signal_guard() {
255
  (
256
    trap '' INT TERM
257
    exec "$@"
258
  ) &
259

            
260
  CURRENT_FFMPEG_PID=$!
261
  if wait "$CURRENT_FFMPEG_PID"; then
262
    CURRENT_FFMPEG_PID=""
263
    return 0
264
  fi
265

            
266
  local rc=$?
267
  CURRENT_FFMPEG_PID=""
268
  return "$rc"
269
}
270

            
Bogdan Timofte authored a month ago
271
format_seconds() {
272
  local sec="$1"
273
  local h m s
274
  h=$((sec / 3600))
275
  m=$(((sec % 3600) / 60))
276
  s=$((sec % 60))
277
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
278
}
279

            
Bogdan Timofte authored a month ago
280
format_bytes_human() {
281
  local bytes="$1"
282
  awk -v b="$bytes" 'BEGIN {
283
    split("B KiB MiB GiB TiB PiB", u, " ");
284
    i = 1;
285
    while (b >= 1024 && i < 6) {
286
      b = b / 1024;
287
      i++;
288
    }
289
    if (i == 1) {
290
      printf "%d %s", b, u[i];
291
    } else {
292
      printf "%.2f %s", b, u[i];
293
    }
294
  }'
295
}
296

            
Bogdan Timofte authored a month ago
297
print_final_report() {
298
  local total_elapsed_sec="$1"
299
  local total_elapsed_fmt="$2"
300
  local file_post_total_sec="$3"
301
  local file_post_total_fmt="$4"
302
  local run_non_file_overhead_sec="$5"
303
  local run_non_file_overhead_fmt="$6"
304
  local avg_file_real_time_sec="$7"
305
  local avg_file_real_time_fmt="$8"
306
  local avg_video_time_sec="$9"
307
  local avg_video_time_fmt="${10}"
Bogdan Timofte authored a month ago
308
  local startup_warmup_sec="${11}"
309
  local startup_warmup_fmt="${12}"
310
  local staging_warmup_sec="${13}"
311
  local staging_warmup_fmt="${14}"
312
  local input_bytes_processed="${15}"
313
  local output_bytes_processed="${16}"
Bogdan Timofte authored a month ago
314

            
315
  printf '\n'
316
  printf 'Run Summary\n'
317
  printf '+---------------------------+-------+\n'
318
  printf '| %-25s | %-5s |\n' "Metric" "Value"
319
  printf '+---------------------------+-------+\n'
320
  printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED"
321
  printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED"
322
  printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED"
323
  printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES"
324
  printf '| %-25s | %5s |\n' "JSON copied" "$JSON_COPIED"
325
  printf '| %-25s | %5s |\n' "JSON skipped" "$JSON_SKIPPED"
326
  printf '| %-25s | %5s |\n' "Errors" "$ERRORS"
327
  printf '+---------------------------+-------+\n'
328

            
329
  printf '\nTimings\n'
330
  printf '+---------------------------+---------+----------+\n'
331
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
332
  printf '+---------------------------+---------+----------+\n'
333
  printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt"
334
  printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")"
335
  printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")"
336
  printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt"
337
  printf '| %-25s | %7s | %-8s |\n' "Non-file overhead" "${run_non_file_overhead_sec}s" "$run_non_file_overhead_fmt"
Bogdan Timofte authored a month ago
338
  if [[ "$startup_warmup_sec" -ge 0 ]]; then
339
    printf '| %-25s | %7s | %-8s |\n' "Startup warm-up" "${startup_warmup_sec}s" "$startup_warmup_fmt"
340
  fi
341
  if [[ "$staging_warmup_sec" -ge 0 ]]; then
342
    printf '| %-25s | %7s | %-8s |\n' "Staging warm-up" "${staging_warmup_sec}s" "$staging_warmup_fmt"
343
  fi
Bogdan Timofte authored a month ago
344
  printf '+---------------------------+---------+----------+\n'
345

            
Bogdan Timofte authored a month ago
346
  printf '\nData Volume\n'
347
  printf '+---------------------------+----------------+-----------------+\n'
348
  printf '| %-25s | %-14s | %-15s |\n' "Metric" "Bytes" "Human"
349
  printf '+---------------------------+----------------+-----------------+\n'
350
  printf '| %-25s | %14s | %-15s |\n' "Input processed" "${input_bytes_processed} B" "$(format_bytes_human "$input_bytes_processed")"
351
  printf '| %-25s | %14s | %-15s |\n' "Output written" "${output_bytes_processed} B" "$(format_bytes_human "$output_bytes_processed")"
352
  printf '+---------------------------+----------------+-----------------+\n'
353

            
Bogdan Timofte authored a month ago
354
  printf '\nPer-File Averages\n'
355
  printf '+---------------------------+---------+----------+\n'
356
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
357
  printf '+---------------------------+---------+----------+\n'
358
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
359
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
360
  printf '+---------------------------+---------+----------+\n'
361
}
362

            
Bogdan Timofte authored a month ago
363
make_temp_log_file() {
364
  local temp_path
365
  local base_tmp="${TMPDIR:-/tmp}"
366
  base_tmp="${base_tmp%/}"
367

            
368
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
369
  if [[ -z "$temp_path" ]]; then
370
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
371
  fi
372

            
373
  printf '%s\n' "$temp_path"
374
}
375

            
Bogdan Timofte authored a month ago
376
make_temp_output_file() {
377
  local target_file="$1"
Bogdan Timofte authored a month ago
378
  local preferred_dir="${2:-}"
379
  local target_dir target_base target_noext temp_path seed_path work_dir
Bogdan Timofte authored a month ago
380

            
381
  target_dir="$(dirname "$target_file")"
382
  target_base="$(basename "$target_file")"
383
  target_noext="${target_base%.*}"
Bogdan Timofte authored a month ago
384
  work_dir="$target_dir"
385

            
386
  if [[ -n "$preferred_dir" ]]; then
387
    work_dir="$preferred_dir"
388
  fi
389

            
390
  seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
391
  if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then
392
    seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
393
  fi
Bogdan Timofte authored a month ago
394

            
395
  if [[ -n "$seed_path" ]]; then
396
    rm -f -- "$seed_path" 2>/dev/null || true
397
    temp_path="${seed_path}.mp4"
398
  else
Bogdan Timofte authored a month ago
399
    temp_path="$work_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
Bogdan Timofte authored a month ago
400
    if ! touch "$temp_path" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
401
      if [[ "$work_dir" != "$target_dir" ]]; then
402
        temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
403
        if ! touch "$temp_path" >/dev/null 2>&1; then
404
          temp_path=""
405
        else
406
          rm -f -- "$temp_path" 2>/dev/null || true
407
        fi
408
      else
409
        temp_path=""
410
      fi
Bogdan Timofte authored a month ago
411
    else
412
      rm -f -- "$temp_path" 2>/dev/null || true
413
    fi
414
  fi
415

            
416
  printf '%s\n' "$temp_path"
417
}
418

            
419
cleanup_transcode_artifacts() {
420
  local temp_output="$1"
421
  local final_output="$2"
Bogdan Timofte authored a month ago
422
  local exif_backup_default="${temp_output}_original"
423
  local exif_backup_alt="${temp_output}.exif_original"
Bogdan Timofte authored a month ago
424

            
425
  rm -f -- "$temp_output" 2>/dev/null || true
Bogdan Timofte authored a month ago
426
  rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true
Bogdan Timofte authored a month ago
427

            
428
  # Defensive cleanup for previously failed non-atomic runs.
429
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
430
    rm -f -- "$final_output" 2>/dev/null || true
431
  fi
432
}
433

            
434
is_apple_noise_file() {
435
  local file_path="$1"
436
  local base
437
  base="$(basename "$file_path")"
438

            
439
  case "$base" in
440
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
441
      return 0
442
      ;;
443
  esac
444

            
445
  return 1
446
}
447

            
Bogdan Timofte authored a month ago
448
require_value() {
449
  local flag="$1"
450
  local value="${2:-}"
451
  if [[ -z "$value" ]]; then
452
    die "Missing value for $flag"
453
  fi
454
}
455

            
456
to_abs_path() {
457
  local p="$1"
458
  if [[ "$p" = /* ]]; then
459
    printf '%s\n' "$p"
460
  else
461
    printf '%s\n' "$PWD/$p"
462
  fi
463
}
464

            
465
path_is_within() {
466
  local child="$1"
467
  local parent="$2"
468
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
469
}
470

            
Bogdan Timofte authored a month ago
471
file_size_bytes_or_zero() {
472
  local file_path="$1"
473
  local file_size="0"
474

            
475
  file_size="$(stat -f%z "$file_path" 2>/dev/null || true)"
476
  if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
477
    file_size="$(stat -c%s "$file_path" 2>/dev/null || true)"
478
  fi
479

            
480
  if [[ "$file_size" =~ ^[0-9]+$ ]]; then
481
    printf '%s\n' "$file_size"
482
  else
483
    printf '0\n'
484
  fi
485
}
486

            
487
dir_available_bytes_or_zero() {
488
  local dir_path="$1"
489
  local avail_kb=""
490

            
491
  avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
492
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
493
    printf '%s\n' $((avail_kb * 1024))
494
  else
495
    printf '0\n'
496
  fi
497
}
498

            
499
staging_has_space_for_input() {
500
  local staging_dir="$1"
501
  local input_file="$2"
502
  local input_size_bytes=0
503
  local needed_bytes=0
504
  local available_bytes=0
505

            
506
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
507

            
508
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
509
  if [[ "$input_size_bytes" -le 0 ]]; then
510
    return 1
511
  fi
512

            
513
  # Keep one simple rule: need roughly 2x input size in staging.
514
  # This covers temp output plus metadata rewrite overhead.
515
  needed_bytes=$((input_size_bytes * 2))
516
  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
517
  [[ "$available_bytes" -ge "$needed_bytes" ]]
518
}
519

            
Bogdan Timofte authored a month ago
520
join_cmd_for_log() {
521
  local out=""
522
  local arg
523
  for arg in "$@"; do
524
    out+="$(printf '%q' "$arg") "
525
  done
526
  printf '%s\n' "${out% }"
527
}
528

            
529
parse_args() {
530
  while [[ $# -gt 0 ]]; do
531
    case "$1" in
532
      -s|--source|--input)
533
        require_value "$1" "${2:-}"
534
        SOURCE_DIR="$2"
535
        SOURCE_PROVIDED=true
536
        shift 2
537
        ;;
538
      -d|--destination|--output)
539
        require_value "$1" "${2:-}"
540
        DEST_DIR="$2"
541
        DEST_PROVIDED=true
542
        shift 2
543
        ;;
Bogdan Timofte authored a month ago
544
      --staging-dir)
545
        require_value "$1" "${2:-}"
546
        STAGING_DIR="$2"
547
        STAGING_PROVIDED=true
548
        shift 2
549
        ;;
Bogdan Timofte authored a month ago
550
      --staging-ramdisk-mb)
551
        require_value "$1" "${2:-}"
552
        STAGING_RAMDISK_MB="$2"
553
        shift 2
554
        ;;
555
      --debug-timing)
556
        require_value "$1" "${2:-}"
557
        DEBUG_TIMING_LIMIT="$2"
558
        shift 2
559
        ;;
Bogdan Timofte authored a month ago
560
      --mode)
561
        require_value "$1" "${2:-}"
562
        MODE="$2"
563
        shift 2
564
        ;;
565
      --crf)
566
        require_value "$1" "${2:-}"
567
        CRF_OVERRIDE="$2"
568
        shift 2
569
        ;;
570
      --overwrite)
571
        OVERWRITE=true
572
        shift
573
        ;;
574
      --no-overwrite)
575
        OVERWRITE=false
576
        shift
577
        ;;
578
      --dry-run)
579
        DRY_RUN=true
580
        shift
581
        ;;
582
      --recursive)
583
        RECURSIVE=true
584
        shift
585
        ;;
586
      --no-recursive)
587
        RECURSIVE=false
588
        shift
589
        ;;
590
      --extensions)
591
        require_value "$1" "${2:-}"
592
        EXTENSIONS_CSV="$2"
593
        shift 2
594
        ;;
595
      --single)
596
        require_value "$1" "${2:-}"
597
        SINGLE_FILE="$2"
598
        shift 2
599
        ;;
600
      --verbose)
601
        VERBOSE=true
602
        shift
603
        ;;
Bogdan Timofte authored a month ago
604
      --delete-source)
605
        MOVE_SOURCE=true
606
        shift
607
        ;;
608
      --keep-going)
609
        FAIL_FAST=false
610
        shift
611
        ;;
612
      --unattended)
613
        MOVE_SOURCE=true
614
        FAIL_FAST=false
615
        shift
616
        ;;
617
      --no-apple-repack-fallback)
618
        APPLE_REPACK_FALLBACK=false
619
        shift
620
        ;;
621
      --apple-repack-fallback)
622
        APPLE_REPACK_FALLBACK=true
623
        shift
624
        ;;
Bogdan Timofte authored a month ago
625
      --move-source)
626
        MOVE_SOURCE=true
627
        shift
628
        ;;
Bogdan Timofte authored a month ago
629
      --continue-on-error)
630
        FAIL_FAST=false
631
        shift
632
        ;;
Bogdan Timofte authored a month ago
633
      -h|--help)
634
        usage
635
        exit 0
636
        ;;
637
      *)
638
        die "Unknown argument: $1"
639
        ;;
640
    esac
641
  done
642

            
643
  case "$MODE" in
644
    auto|hardware|quality|compat) ;;
645
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
646
  esac
647

            
648
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
649
    die "At least one of --source or --destination must be provided"
650
  fi
651

            
652
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
653
    die "--crf must be an integer"
654
  fi
Bogdan Timofte authored a month ago
655

            
656
  if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then
657
    if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then
658
      die "--debug-timing must be a positive integer"
659
    fi
660
    if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then
661
      die "--debug-timing must be greater than 0"
662
    fi
663
  fi
664

            
665
  if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then
666
    die "--staging-ramdisk-mb must be a positive integer"
667
  fi
668
  if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
669
    die "--staging-ramdisk-mb must be greater than 0"
670
  fi
Bogdan Timofte authored a month ago
671
}
672

            
673
check_tools() {
674
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
675
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
676
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
677

            
678
  HAS_AVCONVERT=false
679
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
680
    HAS_AVCONVERT=true
681
  fi
682
}
683

            
684
make_temp_repack_file() {
685
  local base_tmp="${TMPDIR:-/tmp}"
686
  local seed_path temp_path
687

            
688
  base_tmp="${base_tmp%/}"
689
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
690
  if [[ -n "$seed_path" ]]; then
691
    rm -f -- "$seed_path" 2>/dev/null || true
692
    temp_path="${seed_path}.mp4"
693
    printf '%s\n' "$temp_path"
694
    return
695
  fi
696

            
697
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
698
  printf '%s\n' "$temp_path"
699
}
700

            
701
cleanup_repacked_input() {
702
  local repacked_file="$1"
703
  if [[ -n "$repacked_file" ]]; then
704
    rm -f -- "$repacked_file" 2>/dev/null || true
705
  fi
706
}
707

            
708
try_apple_repack_for_unreadable() {
709
  local source_file="$1"
710
  local repacked_file=""
711

            
712
  REPACKED_SOURCE_PATH=""
713

            
714
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
715
    return 1
716
  fi
717

            
718
  repacked_file="$(make_temp_repack_file)"
719
  if [[ -z "$repacked_file" ]]; then
720
    return 1
721
  fi
722

            
723
  if [[ "$VERBOSE" == true ]]; then
724
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
725
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
726
      cleanup_repacked_input "$repacked_file"
727
      return 1
728
    fi
729
  else
730
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
731
      cleanup_repacked_input "$repacked_file"
732
      return 1
733
    fi
734
  fi
735

            
736
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
737
    REPACKED_SOURCE_PATH="$repacked_file"
738
    return 0
739
  fi
740

            
741
  cleanup_repacked_input "$repacked_file"
742
  return 1
Bogdan Timofte authored a month ago
743
}
744

            
745
restore_metadata_with_exiftool() {
746
  local input_file="$1"
747
  local output_file="$2"
748

            
749
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
750

            
751
  if [[ "$VERBOSE" == true ]]; then
752
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
753
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
754
      return 0
755
    fi
756
  else
757
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
758
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
759
      return 0
760
    fi
761
  fi
762

            
Bogdan Timofte authored a month ago
763
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
764
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
765
    return 1
766
  fi
767

            
Bogdan Timofte authored a month ago
768
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
769
  return 1
770
}
771

            
772
map_garmin_model_to_standard_tags() {
773
  local input_file="$1"
774
  local output_file="$2"
775
  local garmin_model
776

            
777
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
778
  if [[ -z "$garmin_model" ]]; then
779
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
780
    return 0
781
  fi
782

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

            
787
  if [[ "$VERBOSE" == true ]]; then
788
    if exiftool -overwrite_original -m \
789
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
790
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
791
      -Keys:CompatibleBrands="isom, iso2, mp41" \
792
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
793
      -Make="Garmin" -Model="Varia RCT715" \
794
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
795
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
796
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
797
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
798
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
799
      "$output_file"; then
800
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
801
      return 0
802
    fi
803
  else
804
    if exiftool -overwrite_original -m -q -q \
805
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
806
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
807
      -Keys:CompatibleBrands="isom, iso2, mp41" \
808
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
809
      -Make="Garmin" -Model="Varia RCT715" \
810
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
811
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
812
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
813
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
814
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
815
      "$output_file" >/dev/null 2>&1; then
816
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
817
      return 0
818
    fi
819
  fi
820

            
821
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
822
  return 1
823
}
824

            
825
write_transcode_encoder_metadata() {
826
  local output_file="$1"
827
  local encoder_meta
828

            
829
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
830

            
831
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
832

            
833
  if [[ "$VERBOSE" == true ]]; then
834
    if exiftool -overwrite_original -m \
835
      -Software="$encoder_meta" \
836
      -UserData:Software="$encoder_meta" \
837
      "$output_file"; then
838
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
839
      return 0
840
    fi
841
  else
842
    if exiftool -overwrite_original -m -q -q \
843
      -Software="$encoder_meta" \
844
      -UserData:Software="$encoder_meta" \
845
      "$output_file" >/dev/null 2>&1; then
846
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
847
      return 0
848
    fi
849
  fi
850

            
851
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
852
  return 1
Bogdan Timofte authored a month ago
853
}
854

            
855
detect_encoders() {
856
  local encoders
857
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
858

            
859
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
860
    HAS_VIDEOTOOLBOX=true
861
  fi
862
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
863
    HAS_LIBX265=true
864
  fi
865
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
866
    HAS_LIBX264=true
867
  fi
868

            
869
  if [[ "$VERBOSE" == true ]]; then
870
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
871
  fi
872
}
873

            
874
resolve_encoder() {
875
  local os_name
876
  os_name="$(uname -s)"
877

            
878
  case "$MODE" in
879
    auto)
880
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
881
        ENCODER_KIND="hardware"
882
      elif [[ "$HAS_LIBX265" == true ]]; then
883
        ENCODER_KIND="quality"
884
      elif [[ "$HAS_LIBX264" == true ]]; then
885
        ENCODER_KIND="compat"
886
      else
887
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
888
      fi
889
      ;;
890
    hardware)
891
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
892
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
893
      ENCODER_KIND="hardware"
894
      ;;
895
    quality)
896
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
897
      ENCODER_KIND="quality"
898
      ;;
899
    compat)
900
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
901
      ENCODER_KIND="compat"
902
      ;;
903
  esac
904

            
905
  case "$ENCODER_KIND" in
906
    hardware)
907
      VIDEO_CODEC="hevc_videotoolbox"
908
      VIDEO_CRF=""
909
      ;;
910
    quality)
911
      VIDEO_CODEC="libx265"
912
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
913
      ;;
914
    compat)
915
      VIDEO_CODEC="libx264"
916
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
917
      ;;
918
  esac
919

            
920
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
921
}
922

            
923
probe_has_audio() {
924
  local input_file="$1"
925
  local out
926
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
927
  [[ -n "$out" ]]
928
}
929

            
930
print_verbose_probe() {
931
  local input_file="$1"
932
  ffprobe -v error \
933
    -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 \
934
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
935
}
936

            
937
ffprobe_duration_or_empty() {
938
  local file_path="$1"
939
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
940
}
941

            
942
ffprobe_video_codec_or_empty() {
943
  local file_path="$1"
944
  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
945
}
946

            
Bogdan Timofte authored a month ago
947
source_video_is_readable() {
948
  local file_path="$1"
949
  REPACKED_SOURCE_PATH=""
950

            
951
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
952
    SOURCE_READABLE_MODE="normal"
953
    return 0
954
  fi
955

            
956
  if [[ "$VERBOSE" == true ]]; then
957
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
958
  fi
959

            
960
  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
961
    SOURCE_READABLE_MODE="tolerant"
962
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
963
    return 0
964
  fi
965

            
966
  if try_apple_repack_for_unreadable "$file_path"; then
967
    SOURCE_READABLE_MODE="repacked"
968
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
969
    return 0
970
  fi
971

            
972
  SOURCE_READABLE_MODE="normal"
973
  return 1
974
}
975

            
976
source_error_from_ffmpeg_log() {
977
  local ffmpeg_log="$1"
978
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
979
  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"
980
}
981

            
982
destination_cannot_accept_file() {
983
  local input_file="$1"
984
  local out_dir="$2"
985
  local ffmpeg_log="$3"
986
  local probe_path=""
987
  local avail_kb=""
988
  local avail_bytes=0
989

            
990
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
991
    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
992
      return 0
993
    fi
994
  fi
995

            
996
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
997
  if [[ -z "$probe_path" ]]; then
998
    return 0
999
  fi
1000
  rm -f -- "$probe_path" 2>/dev/null || true
1001

            
1002
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
1003
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
1004
    avail_bytes=$((avail_kb * 1024))
1005
    if [[ "$avail_bytes" -lt 67108864 ]]; then
1006
      return 0
1007
    fi
1008
  fi
1009

            
1010
  return 1
1011
}
1012

            
Bogdan Timofte authored a month ago
1013
validate_transcoded_output() {
1014
  local input_file="$1"
1015
  local output_file="$2"
1016

            
1017
  if [[ ! -f "$output_file" ]]; then
1018
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
1019
    return 1
1020
  fi
1021

            
1022
  local expected_codec actual_codec
1023
  case "$ENCODER_KIND" in
1024
    hardware|quality) expected_codec="hevc" ;;
1025
    compat) expected_codec="h264" ;;
1026
    *)
1027
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
1028
      return 1
1029
      ;;
1030
  esac
1031

            
1032
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
1033
  if [[ -z "$actual_codec" ]]; then
1034
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
1035
    return 1
1036
  fi
1037
  if [[ "$actual_codec" != "$expected_codec" ]]; then
1038
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
1039
    return 1
1040
  fi
1041

            
1042
  local in_duration out_duration duration_delta
1043
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1044
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1045

            
1046
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
1047
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
1048
    return 1
1049
  fi
1050

            
1051
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
1052
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
1053
    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)"
1054
    return 1
1055
  fi
1056

            
1057
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
1058
  return 0
1059
}
1060

            
1061
build_video_args() {
1062
  VIDEO_ARGS=()
1063

            
1064
  case "$ENCODER_KIND" in
1065
    hardware)
1066
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
1067
      ;;
1068
    quality)
1069
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
1070
      ;;
1071
    compat)
1072
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
1073
      ;;
1074
  esac
1075
}
1076

            
1077
normalize_source_dir() {
1078
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1079
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
1080
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1081
}
1082

            
1083
normalize_dest_dir() {
1084
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1085
}
1086

            
Bogdan Timofte authored a month ago
1087
create_missing_staging_ramdisk_if_needed() {
1088
  local staging_path="$1"
1089
  local ramdisk_name=""
1090
  local sectors=0
1091
  local dev=""
1092
  local mount_point=""
1093

            
1094
  staging_path="${staging_path%/}"
1095

            
1096
  if [[ -d "$staging_path" ]]; then
1097
    printf '%s\n' "$staging_path"
1098
    return
1099
  fi
1100

            
1101
  if [[ "$(uname -s)" != "Darwin" ]]; then
1102
    return
1103
  fi
1104

            
1105
  case "$staging_path" in
1106
    /Volumes/*)
1107
      ;;
1108
    *)
1109
      return
1110
      ;;
1111
  esac
1112

            
1113
  ramdisk_name="$(basename "$staging_path")"
1114
  if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
1115
    return
1116
  fi
1117

            
1118
  if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1119
    # Only auto-create for top-level /Volumes/<Name>, not nested paths.
1120
    return
1121
  fi
1122

            
1123
  [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk"
1124
  [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk"
1125

            
1126
  sectors=$((STAGING_RAMDISK_MB * 2048))
1127
  vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)"
1128
  dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)"
1129
  if [[ -z "$dev" ]]; then
1130
    return
1131
  fi
1132

            
1133
  if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
1134
    /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1135
    return
1136
  fi
1137

            
1138
  mount_point="$(/usr/sbin/diskutil info "$dev" 2>/dev/null | /usr/bin/awk -F': *' '/Mount Point/ {print $2; exit}')"
1139
  if [[ -n "$mount_point" && -d "$mount_point" ]]; then
1140
    AUTO_CREATED_STAGING_RAMDISK=true
1141
    AUTO_CREATED_STAGING_PATH="$mount_point"
1142
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1143
    printf '%s\n' "$mount_point"
1144
    return
1145
  fi
1146

            
1147
  if [[ -d "$staging_path" ]]; then
1148
    AUTO_CREATED_STAGING_RAMDISK=true
1149
    AUTO_CREATED_STAGING_PATH="$staging_path"
1150
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1151
    printf '%s\n' "$staging_path"
1152
    return
1153
  fi
1154
}
1155

            
1156
attempt_unmount_auto_staging_ramdisk() {
1157
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then
1158
    return
1159
  fi
1160

            
1161
  if [[ "$(uname -s)" != "Darwin" ]]; then
1162
    return
1163
  fi
1164

            
1165
  if /usr/sbin/diskutil eject "$AUTO_CREATED_STAGING_PATH" >/dev/null 2>&1; then
1166
    log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH"
1167
    return
1168
  fi
1169

            
1170
  if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1171
    log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH"
1172
  else
1173
    log_msg "WARN" "Could not confirm unmount status for auto-created staging RAM disk: $AUTO_CREATED_STAGING_PATH"
1174
  fi
1175
}
1176

            
Bogdan Timofte authored a month ago
1177
normalize_staging_dir() {
1178
  if [[ "$STAGING_PROVIDED" != true ]]; then
1179
    STAGING_DIR=""
1180
    return
1181
  fi
1182

            
1183
  STAGING_DIR="$(to_abs_path "$STAGING_DIR")"
Bogdan Timofte authored a month ago
1184
  if [[ ! -d "$STAGING_DIR" ]]; then
1185
    local created_staging_dir=""
1186
    created_staging_dir="$(create_missing_staging_ramdisk_if_needed "$STAGING_DIR")"
1187
    if [[ -n "$created_staging_dir" && -d "$created_staging_dir" ]]; then
1188
      STAGING_DIR="$created_staging_dir"
1189
      log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1190
    else
1191
      die "Staging directory not found: $STAGING_DIR"
1192
    fi
1193
  fi
Bogdan Timofte authored a month ago
1194
  [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR"
1195
  STAGING_DIR="$(cd "$STAGING_DIR" && pwd)"
1196
  [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR"
1197

            
1198
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1199
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1200
  fi
1201
}
1202

            
Bogdan Timofte authored a month ago
1203
collect_extensions() {
1204
  local raw="$EXTENSIONS_CSV"
1205
  local token
1206
  EXT_LIST=()
1207

            
1208
  IFS=',' read -r -a tokens <<< "$raw"
1209
  for token in "${tokens[@]}"; do
1210
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
1211
    token="${token#.}"
1212
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
1213
    [[ -n "$token" ]] && EXT_LIST+=("$token")
1214
  done
1215

            
1216
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1217
    die "No valid extensions after parsing --extensions"
1218
  fi
1219
}
1220

            
1221
build_find_expr_for_extensions() {
1222
  FIND_EXT_EXPR=()
1223
  local ext
1224
  for ext in "${EXT_LIST[@]}"; do
1225
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
1226
  done
1227
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
1228
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
1229
  fi
1230
}
1231

            
1232
rel_path_from_source() {
1233
  local abs_file="$1"
1234
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1235
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1236
  else
1237
    printf '%s\n' "$(basename "$abs_file")"
1238
  fi
1239
}
1240

            
1241
collect_video_files() {
1242
  VIDEO_FILES=()
1243

            
1244
  if [[ -n "$SINGLE_FILE" ]]; then
1245
    local single_abs
1246
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1247
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1248
    VIDEO_FILES+=("$single_abs")
1249
    return
1250
  fi
1251

            
1252
  build_find_expr_for_extensions
1253

            
1254
  while IFS= read -r -d '' file; do
Bogdan Timofte authored a month ago
1255
    if is_apple_noise_file "$file"; then
1256
      vlog_msg "SKIP" "Ignoring Apple artifact: $file"
1257
      continue
1258
    fi
Bogdan Timofte authored a month ago
1259
    VIDEO_FILES+=("$file")
1260
  done < <(
1261
    if [[ "$RECURSIVE" == true ]]; then
1262
      find "$SOURCE_DIR" \
1263
        -path "$DEST_DIR" -prune -o \
1264
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1265
    else
1266
      find "$SOURCE_DIR" \
1267
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1268
    fi
1269
  )
1270
}
1271

            
1272
process_video_file() {
1273
  local input_file="$1"
Bogdan Timofte authored a month ago
1274
  local rel_path output_file temp_output_file out_dir
1275
  local display_path
1276
  local encode_input_file
Bogdan Timofte authored a month ago
1277
  local preferred_temp_dir=""
Bogdan Timofte authored a month ago
1278
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1279
  local input_size_bytes=0 output_size_bytes=0
Bogdan Timofte authored a month ago
1280
  local file_started_at file_ended_at file_real_elapsed_sec
1281
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1282
  local post_elapsed_sec
1283

            
1284
  file_started_at="$(date +%s)"
1285
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1286

            
1287
  rel_path="$(rel_path_from_source "$input_file")"
1288
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1289
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1290
  display_path="${input_file#$PWD/}"
1291
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1292

            
1293
  mkdir -p "$out_dir"
1294

            
Bogdan Timofte authored a month ago
1295
  if ! source_video_is_readable "$input_file"; then
1296
    ERRORS=$((ERRORS + 1))
1297
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1298
    if [[ "$VERBOSE" == true ]]; then
1299
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1300
    else
1301
      log_progress_start "$display_path"
1302
      log_progress_skipped_unreadable "$display_path"
1303
    fi
1304
    return 2
1305
  fi
1306

            
1307
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1308
    repacked_input_file="$REPACKED_SOURCE_PATH"
1309
    encode_input_file="$REPACKED_SOURCE_PATH"
1310
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1311
  fi
1312

            
Bogdan Timofte authored a month ago
1313
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1314
    vlog_msg "SKIP" "Video exists: $output_file"
1315
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1316
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1317
    return
1318
  fi
1319

            
1320
  local has_audio=false
Bogdan Timofte authored a month ago
1321
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1322
    has_audio=true
1323
  fi
1324

            
1325
  if [[ "$VERBOSE" == true ]]; then
1326
    log_msg "INFO" "ffprobe summary: $input_file"
1327
    print_verbose_probe "$input_file"
1328
    log_msg "INFO" "Audio detected: $has_audio"
1329
  fi
1330

            
1331
  build_video_args
1332

            
Bogdan Timofte authored a month ago
1333
  preferred_temp_dir="$STAGING_DIR"
1334
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then
1335
    if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then
1336
      preferred_temp_dir=""
1337
      log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file"
1338
    fi
1339
  fi
1340

            
1341
  temp_output_file="$(make_temp_output_file "$output_file" "$preferred_temp_dir")"
Bogdan Timofte authored a month ago
1342
  if [[ -z "$temp_output_file" ]]; then
1343
    ERRORS=$((ERRORS + 1))
1344
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1345
    log_msg "ERROR" "Could not create temporary output file: $output_file"
1346
    return 3
1347
  fi
Bogdan Timofte authored a month ago
1348
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then
Bogdan Timofte authored a month ago
1349
    log_msg "WARN" "Staging unavailable for temp output; using destination directory: $temp_output_file"
1350
  fi
Bogdan Timofte authored a month ago
1351

            
Bogdan Timofte authored a month ago
1352
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1353
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1354
    cmd+=( -fflags +genpts -err_detect ignore_err )
1355
  fi
Bogdan Timofte authored a month ago
1356
  if [[ "$OVERWRITE" == true ]]; then
1357
    cmd+=( -y )
1358
  else
1359
    cmd+=( -n )
1360
  fi
1361

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

            
1364
  if [[ "$has_audio" == true ]]; then
1365
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1366
  fi
1367

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

            
1370
  if [[ "$VERBOSE" == true ]]; then
1371
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1372
  fi
1373

            
1374
  if [[ "$DRY_RUN" == true ]]; then
1375
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1376
    return 0
1377
  fi
1378

            
Bogdan Timofte authored a month ago
1379
  if [[ "$VERBOSE" != true ]]; then
1380
    log_progress_start "$display_path"
1381
  fi
1382

            
1383
  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
Bogdan Timofte authored a month ago
1384
  if [[ "$FIRST_ENCODE_STARTED_AT" -eq 0 ]]; then
1385
    local startup_warmup_sec=0
1386
    local warmup_elapsed_sec=0
1387
    FIRST_ENCODE_STARTED_AT="$(date +%s)"
1388
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 ]]; then
1389
      warmup_elapsed_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1390
      if [[ "$warmup_elapsed_sec" -lt 0 ]]; then
1391
        warmup_elapsed_sec=0
1392
      fi
1393
      log_msg "INFO" "Staging RAM disk warm-up before first encode: ${warmup_elapsed_sec}s / $(format_seconds "$warmup_elapsed_sec")"
1394
    fi
1395
  fi
Bogdan Timofte authored a month ago
1396
  encode_started_at="$(date +%s)"
1397
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
1398

            
1399
  local ffmpeg_rc=0
Bogdan Timofte authored a month ago
1400
  local ffmpeg_log=""
Bogdan Timofte authored a month ago
1401
  if [[ "$VERBOSE" == true ]]; then
1402
    # Verbose: show ffmpeg output directly
Bogdan Timofte authored a month ago
1403
    if run_ffmpeg_with_signal_guard "${cmd[@]}"; then
Bogdan Timofte authored a month ago
1404
      :
1405
    else
1406
      ffmpeg_rc=$?
1407
    fi
1408
  else
1409
    # Quiet (default): redirect ffmpeg output; keep log on failure
1410
    ffmpeg_log="$(make_temp_log_file)"
1411
    if [[ -z "$ffmpeg_log" ]]; then
1412
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1413
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored a month ago
1414
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
Bogdan Timofte authored a month ago
1415
      return 3
Bogdan Timofte authored a month ago
1416
    fi
Bogdan Timofte authored a month ago
1417
    if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
Bogdan Timofte authored a month ago
1418
      rm -f "$ffmpeg_log"
1419
    else
1420
      ffmpeg_rc=$?
1421
    fi
1422
  fi
1423

            
Bogdan Timofte authored a month ago
1424
  encode_ended_at="$(date +%s)"
1425
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1426
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1427

            
1428
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1429
    local failure_rc=1
1430
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1431
      failure_rc=3
1432
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1433
      failure_rc=2
1434
    fi
1435

            
1436
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1437
    file_ended_at="$(date +%s)"
1438
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1439
    local encode_elapsed_fmt
1440
    local real_elapsed_fmt
1441
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1442
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1443

            
Bogdan Timofte authored a month ago
1444
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1445
    if [[ "$failure_rc" -eq 2 ]]; then
1446
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1447
    elif [[ "$failure_rc" -eq 3 ]]; then
1448
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1449
    fi
Bogdan Timofte authored a month ago
1450
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1451
      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
1452
    else
Bogdan Timofte authored a month ago
1453
      log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log"
Bogdan Timofte authored a month ago
1454
    fi
Bogdan Timofte authored a month ago
1455
    if [[ "$failure_rc" -eq 3 ]]; then
1456
      log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1457
    fi
1458
    cleanup_repacked_input "$repacked_input_file"
1459
    return "$failure_rc"
Bogdan Timofte authored a month ago
1460
  fi
1461

            
Bogdan Timofte authored a month ago
1462
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1463

            
Bogdan Timofte authored a month ago
1464
  if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then
1465
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1466
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1467
      cleanup_repacked_input "$repacked_input_file"
1468
      return 4
1469
    fi
Bogdan Timofte authored a month ago
1470
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1471
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1472
    cleanup_repacked_input "$repacked_input_file"
1473
    return 3
Bogdan Timofte authored a month ago
1474
  fi
1475

            
Bogdan Timofte authored a month ago
1476
  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1477
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1478
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1479
      cleanup_repacked_input "$repacked_input_file"
1480
      return 4
1481
    fi
Bogdan Timofte authored a month ago
1482
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1483
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1484
    cleanup_repacked_input "$repacked_input_file"
1485
    return 3
Bogdan Timofte authored a month ago
1486
  fi
1487

            
Bogdan Timofte authored a month ago
1488
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1489
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1490
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1491
      cleanup_repacked_input "$repacked_input_file"
1492
      return 4
1493
    fi
Bogdan Timofte authored a month ago
1494
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1495
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1496
    cleanup_repacked_input "$repacked_input_file"
1497
    return 3
Bogdan Timofte authored a month ago
1498
  fi
Bogdan Timofte authored a month ago
1499

            
1500
  if [[ "$MOVE_SOURCE" == true ]]; then
Bogdan Timofte authored a month ago
1501
    if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then
1502
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1503
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1504
      if destination_cannot_accept_file "$input_file" "$out_dir" ""; then
1505
        DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1506
        cleanup_repacked_input "$repacked_input_file"
1507
        return 3
1508
      fi
1509
      # Validation failed but destination is healthy: the source or encoder produced
1510
      # a corrupt/truncated output. Treat as source error so --keep-going can skip it.
Bogdan Timofte authored a month ago
1511
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
Bogdan Timofte authored a month ago
1512
      log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file"
Bogdan Timofte authored a month ago
1513
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1514
      return 2
Bogdan Timofte authored a month ago
1515
    fi
1516
  fi
1517

            
Bogdan Timofte authored a month ago
1518
  touch -r "$input_file" "$temp_output_file" || true
1519

            
1520
  if [[ "$OVERWRITE" == true ]]; then
1521
    if ! mv -f "$temp_output_file" "$output_file"; then
1522
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1523
      ERRORS=$((ERRORS + 1))
1524
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1525
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1526
      cleanup_repacked_input "$repacked_input_file"
1527
      return 3
1528
    fi
1529
  else
1530
    if ! mv -n "$temp_output_file" "$output_file"; then
1531
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1532
      ERRORS=$((ERRORS + 1))
1533
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1534
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1535
      cleanup_repacked_input "$repacked_input_file"
1536
      return 3
1537
    fi
1538
  fi
Bogdan Timofte authored a month ago
1539

            
Bogdan Timofte authored a month ago
1540
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1541
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1542
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1543
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1544

            
Bogdan Timofte authored a month ago
1545
  if [[ "$MOVE_SOURCE" == true ]]; then
1546
    if rm -f "$input_file"; then
1547
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
1548
    else
1549
      ERRORS=$((ERRORS + 1))
1550
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
Bogdan Timofte authored a month ago
1551
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1552
      return 1
1553
    fi
1554
  fi
1555

            
Bogdan Timofte authored a month ago
1556
  file_ended_at="$(date +%s)"
1557
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1558
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1559
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1560
    post_elapsed_sec=0
1561
  fi
1562
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1563
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1564

            
Bogdan Timofte authored a month ago
1565
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1566

            
1567
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1568
    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
1569
  else
Bogdan Timofte authored a month ago
1570
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1571
  fi
1572

            
Bogdan Timofte authored a month ago
1573
  cleanup_repacked_input "$repacked_input_file"
1574

            
Bogdan Timofte authored a month ago
1575
  return 0
1576
}
1577

            
1578
copy_one_json() {
1579
  local json_file="$1"
1580
  local rel_json dst_json dst_dir
1581

            
1582
  rel_json="$(rel_path_from_source "$json_file")"
1583
  dst_json="$DEST_DIR/$rel_json"
1584
  dst_dir="$(dirname "$dst_json")"
1585

            
1586
  mkdir -p "$dst_dir"
1587

            
1588
  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
1589
    JSON_SKIPPED=$((JSON_SKIPPED + 1))
1590
    vlog_msg "SKIP" "JSON exists: $dst_json"
1591
    return
1592
  fi
1593

            
1594
  if [[ "$DRY_RUN" == true ]]; then
1595
    JSON_COPIED=$((JSON_COPIED + 1))
1596
    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
1597
    return
1598
  fi
1599

            
1600
  if cp -f "$json_file" "$dst_json"; then
1601
    touch -r "$json_file" "$dst_json" || true
1602
    JSON_COPIED=$((JSON_COPIED + 1))
1603
    vlog_msg "INFO" "Copied JSON: $dst_json"
1604
  else
1605
    ERRORS=$((ERRORS + 1))
1606
    log_msg "ERROR" "Failed to copy JSON: $json_file"
1607
  fi
1608
}
1609

            
1610
copy_sidecars_json() {
1611
  local json_files=()
1612

            
1613
  if [[ -n "$SINGLE_FILE" ]]; then
1614
    local single_abs rel_path candidate
1615
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1616
    rel_path="$(rel_path_from_source "$single_abs")"
1617
    candidate="$SOURCE_DIR/${rel_path%.*}.json"
1618
    if [[ -f "$candidate" ]]; then
1619
      json_files+=("$candidate")
1620
    fi
1621
  else
1622
    while IFS= read -r -d '' jf; do
1623
      json_files+=("$jf")
1624
    done < <(
1625
      if [[ "$RECURSIVE" == true ]]; then
1626
        find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
1627
      else
1628
        find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
1629
      fi
1630
    )
1631
  fi
1632

            
1633
  if [[ ${#json_files[@]} -eq 0 ]]; then
1634
    vlog_msg "INFO" "No JSON sidecars found to copy"
1635
    return
1636
  fi
1637

            
1638
  local jf
1639
  for jf in "${json_files[@]}"; do
Bogdan Timofte authored a month ago
1640
    if is_apple_noise_file "$jf"; then
1641
      vlog_msg "SKIP" "Ignoring Apple artifact JSON: $jf"
1642
      continue
1643
    fi
Bogdan Timofte authored a month ago
1644
    copy_one_json "$jf"
1645
  done
1646
}
1647

            
1648
write_manifest() {
1649
  local manifest_path="$DEST_DIR/telemetry_manifest.json"
1650

            
1651
  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
1652
    vlog_msg "SKIP" "Manifest exists: $manifest_path"
1653
    return
1654
  fi
1655

            
1656
  if [[ "$DRY_RUN" == true ]]; then
1657
    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
1658
    return
1659
  fi
1660

            
1661
  mkdir -p "$DEST_DIR"
1662

            
1663
  cat > "$manifest_path" <<EOF
1664
{
1665
  "schema_version": "0.1-draft",
1666
  "purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
1667
  "fields_target": [
1668
    "power_w",
1669
    "speed_kmh",
1670
    "heart_rate_bpm",
1671
    "cadence_rpm",
1672
    "gps"
1673
  ],
1674
  "sync_methods": [
1675
    "auto_timestamp_plus_offset",
1676
    "manual_offset_ms"
1677
  ],
1678
  "notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
1679
}
1680
EOF
1681

            
1682
  vlog_msg "INFO" "Wrote manifest: $manifest_path"
1683
}
1684

            
1685
main() {
1686
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1687
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1688
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1689

            
Bogdan Timofte authored a month ago
1690
  trap 'handle_interrupt' INT TERM
1691

            
Bogdan Timofte authored a month ago
1692
  parse_args "$@"
1693
  check_tools
1694

            
1695
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1696
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1697
    SINGLE_FILE="$SOURCE_DIR"
1698
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1699
  fi
1700

            
1701
  normalize_source_dir
1702
  normalize_dest_dir
Bogdan Timofte authored a month ago
1703
  normalize_staging_dir
Bogdan Timofte authored a month ago
1704
  collect_extensions
1705

            
1706
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1707
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1708
  fi
1709

            
1710
  detect_encoders
1711
  resolve_encoder
1712

            
1713
  collect_video_files
Bogdan Timofte authored a month ago
1714
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1715
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1716
  fi
1717

            
1718
  local f
Bogdan Timofte authored a month ago
1719
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1720
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1721
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1722
      break
1723
    fi
Bogdan Timofte authored a month ago
1724

            
1725
    local process_rc=0
1726
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1727
      process_rc=0
Bogdan Timofte authored a month ago
1728
    else
1729
      process_rc=$?
1730
    fi
1731

            
Bogdan Timofte authored a month ago
1732
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1733
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
1734
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
1735
        DEBUG_TIMING_STOPPED=true
1736
      fi
1737
    fi
1738

            
1739
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1740
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
1741
      break
1742
    fi
1743

            
1744
    if [[ "$process_rc" -eq 0 ]]; then
1745
      continue
1746
    fi
1747

            
Bogdan Timofte authored a month ago
1748
    case "$process_rc" in
1749
      2)
Bogdan Timofte authored a month ago
1750
        if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1751
          log_msg "INFO" "Stopped by user after current file"
1752
          break
1753
        fi
Bogdan Timofte authored a month ago
1754
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1755
        continue
1756
        ;;
1757
      3)
1758
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1759
        break
1760
        ;;
1761
      4)
1762
        log_msg "INFO" "Stopped by user after current file"
1763
        break
1764
        ;;
1765
      *)
1766
        if [[ "$FAIL_FAST" == true ]]; then
1767
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1768
          break
1769
        fi
1770
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1771
        continue
1772
        ;;
1773
    esac
Bogdan Timofte authored a month ago
1774
  done
1775

            
Bogdan Timofte authored a month ago
1776
  if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1777
    log_msg "INFO" "Skipping sidecar copy and manifest because debug timing mode requested early stop"
1778
  elif [[ "$STOP_AFTER_CURRENT" == true ]]; then
Bogdan Timofte authored a month ago
1779
    log_msg "INFO" "Skipping sidecar copy and manifest because run was stopped by user"
1780
  elif [[ "$ERRORS" -eq 0 ]]; then
Bogdan Timofte authored a month ago
1781
    copy_sidecars_json
1782
    write_manifest
1783
  else
1784
    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
1785
  fi
1786

            
1787
  total_ended_at="$(date +%s)"
1788
  total_elapsed_sec=$((total_ended_at - total_started_at))
1789
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
1790

            
1791
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
1792
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
1793
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
1794
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
Bogdan Timofte authored a month ago
1795
  local startup_warmup_sec=-1 startup_warmup_fmt="n/a"
1796
  local staging_warmup_sec=-1 staging_warmup_fmt="n/a"
Bogdan Timofte authored a month ago
1797

            
Bogdan Timofte authored a month ago
1798
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
1799
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
1800
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
1801
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
1802
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
1803
  fi
1804

            
1805
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
1806
  if [[ "$file_post_total_sec" -lt 0 ]]; then
1807
    file_post_total_sec=0
1808
  fi
1809
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
1810

            
1811
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
1812
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
1813
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
1814
  fi
Bogdan Timofte authored a month ago
1815
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
1816

            
Bogdan Timofte authored a month ago
1817
  if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1818
    startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT))
1819
    if [[ "$startup_warmup_sec" -lt 0 ]]; then
1820
      startup_warmup_sec=0
1821
    fi
1822
    startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")"
1823
  fi
1824

            
1825
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1826
    staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1827
    if [[ "$staging_warmup_sec" -lt 0 ]]; then
1828
      staging_warmup_sec=0
1829
    fi
1830
    staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")"
1831
  fi
1832

            
Bogdan Timofte authored a month ago
1833
  print_final_report \
1834
    "$total_elapsed_sec" \
1835
    "$total_elapsed_fmt" \
1836
    "$file_post_total_sec" \
1837
    "$file_post_total_fmt" \
1838
    "$run_non_file_overhead_sec" \
1839
    "$run_non_file_overhead_fmt" \
1840
    "$avg_file_real_time_sec" \
1841
    "$avg_file_real_time_fmt" \
1842
    "$avg_video_time_sec" \
Bogdan Timofte authored a month ago
1843
    "$avg_video_time_fmt" \
1844
    "$startup_warmup_sec" \
1845
    "$startup_warmup_fmt" \
1846
    "$staging_warmup_sec" \
1847
    "$staging_warmup_fmt" \
1848
    "$INPUT_BYTES_PROCESSED" \
1849
    "$OUTPUT_BYTES_PROCESSED"
1850

            
1851
  attempt_unmount_auto_staging_ramdisk
Bogdan Timofte authored a month ago
1852

            
1853
  if [[ "$ERRORS" -gt 0 ]]; then
1854
    exit 1
1855
  fi
1856
}
1857

            
1858
main "$@"