VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1744 lines | 55.459kb
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
ERRORS=0
Bogdan Timofte authored a month ago
75
INVALID_SOURCES_SKIPPED=0
76
DESTINATION_FAILURES=0
Bogdan Timofte authored a month ago
77
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
78
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
79
INPUT_BYTES_PROCESSED=0
80
OUTPUT_BYTES_PROCESSED=0
Bogdan Timofte authored a month ago
81

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

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

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

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

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

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

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

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

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

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

            
164
# Verbose-only log: suppressed in default quiet mode
165
vlog_msg() {
166
  [[ "$VERBOSE" == true ]] && log_msg "$@"
167
  return 0
168
}
169

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

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

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

            
Bogdan Timofte authored a month ago
194
log_progress_failed() {
195
  local input_file="$1"
196
  local real_elapsed_sec="$2"
197
  local encode_elapsed_sec="$3"
198
  local ffmpeg_rc="$4"
199
  local ffmpeg_log="$5"
200

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

            
207
  local ts
208
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
209
  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
210
}
211

            
212
log_progress_skipped_unreadable() {
213
  local input_file="$1"
214

            
215
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
216
    printf ' SKIPPED (unreadable/corrupted source)\n'
217
    PROGRESS_LINE_OPEN=false
218
    return
219
  fi
220

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

            
Bogdan Timofte authored a month ago
226
die() {
227
  log_msg "ERROR" "$*"
228
  exit 1
229
}
230

            
Bogdan Timofte authored a month ago
231
handle_interrupt() {
232
  INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
233
  STOP_AFTER_CURRENT=true
234

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

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

            
250
run_ffmpeg_with_signal_guard() {
251
  (
252
    trap '' INT TERM
253
    exec "$@"
254
  ) &
255

            
256
  CURRENT_FFMPEG_PID=$!
257
  if wait "$CURRENT_FFMPEG_PID"; then
258
    CURRENT_FFMPEG_PID=""
259
    return 0
260
  fi
261

            
262
  local rc=$?
263
  CURRENT_FFMPEG_PID=""
264
  return "$rc"
265
}
266

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

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

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

            
311
  printf '\n'
312
  printf 'Run Summary\n'
313
  printf '+---------------------------+-------+\n'
314
  printf '| %-25s | %-5s |\n' "Metric" "Value"
315
  printf '+---------------------------+-------+\n'
316
  printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED"
317
  printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED"
318
  printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED"
319
  printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES"
320
  printf '| %-25s | %5s |\n' "Errors" "$ERRORS"
321
  printf '+---------------------------+-------+\n'
322

            
323
  printf '\nTimings\n'
324
  printf '+---------------------------+---------+----------+\n'
325
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
326
  printf '+---------------------------+---------+----------+\n'
327
  printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt"
328
  printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")"
329
  printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")"
330
  printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt"
331
  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
332
  if [[ "$startup_warmup_sec" -ge 0 ]]; then
333
    printf '| %-25s | %7s | %-8s |\n' "Startup warm-up" "${startup_warmup_sec}s" "$startup_warmup_fmt"
334
  fi
335
  if [[ "$staging_warmup_sec" -ge 0 ]]; then
336
    printf '| %-25s | %7s | %-8s |\n' "Staging warm-up" "${staging_warmup_sec}s" "$staging_warmup_fmt"
337
  fi
Bogdan Timofte authored a month ago
338
  printf '+---------------------------+---------+----------+\n'
339

            
Bogdan Timofte authored a month ago
340
  printf '\nData Volume\n'
341
  printf '+---------------------------+----------------+-----------------+\n'
342
  printf '| %-25s | %-14s | %-15s |\n' "Metric" "Bytes" "Human"
343
  printf '+---------------------------+----------------+-----------------+\n'
344
  printf '| %-25s | %14s | %-15s |\n' "Input processed" "${input_bytes_processed} B" "$(format_bytes_human "$input_bytes_processed")"
345
  printf '| %-25s | %14s | %-15s |\n' "Output written" "${output_bytes_processed} B" "$(format_bytes_human "$output_bytes_processed")"
346
  printf '+---------------------------+----------------+-----------------+\n'
347

            
Bogdan Timofte authored a month ago
348
  printf '\nPer-File Averages\n'
349
  printf '+---------------------------+---------+----------+\n'
350
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
351
  printf '+---------------------------+---------+----------+\n'
352
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
353
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
354
  printf '+---------------------------+---------+----------+\n'
355
}
356

            
Bogdan Timofte authored a month ago
357
make_temp_log_file() {
358
  local temp_path
359
  local base_tmp="${TMPDIR:-/tmp}"
360
  base_tmp="${base_tmp%/}"
361

            
362
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
363
  if [[ -z "$temp_path" ]]; then
364
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
365
  fi
366

            
367
  printf '%s\n' "$temp_path"
368
}
369

            
Bogdan Timofte authored a month ago
370
make_temp_output_file() {
371
  local target_file="$1"
Bogdan Timofte authored a month ago
372
  local preferred_dir="${2:-}"
373
  local target_dir target_base target_noext temp_path seed_path work_dir
Bogdan Timofte authored a month ago
374

            
375
  target_dir="$(dirname "$target_file")"
376
  target_base="$(basename "$target_file")"
377
  target_noext="${target_base%.*}"
Bogdan Timofte authored a month ago
378
  work_dir="$target_dir"
379

            
380
  if [[ -n "$preferred_dir" ]]; then
381
    work_dir="$preferred_dir"
382
  fi
383

            
384
  seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
385
  if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then
386
    seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
387
  fi
Bogdan Timofte authored a month ago
388

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

            
410
  printf '%s\n' "$temp_path"
411
}
412

            
413
cleanup_transcode_artifacts() {
414
  local temp_output="$1"
415
  local final_output="$2"
Bogdan Timofte authored a month ago
416
  local exif_backup_default="${temp_output}_original"
417
  local exif_backup_alt="${temp_output}.exif_original"
Bogdan Timofte authored a month ago
418

            
419
  rm -f -- "$temp_output" 2>/dev/null || true
Bogdan Timofte authored a month ago
420
  rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true
Bogdan Timofte authored a month ago
421

            
422
  # Defensive cleanup for previously failed non-atomic runs.
423
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
424
    rm -f -- "$final_output" 2>/dev/null || true
425
  fi
426
}
427

            
428
is_apple_noise_file() {
429
  local file_path="$1"
430
  local base
431
  base="$(basename "$file_path")"
432

            
433
  case "$base" in
434
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
435
      return 0
436
      ;;
437
  esac
438

            
439
  return 1
440
}
441

            
Bogdan Timofte authored a month ago
442
require_value() {
443
  local flag="$1"
444
  local value="${2:-}"
445
  if [[ -z "$value" ]]; then
446
    die "Missing value for $flag"
447
  fi
448
}
449

            
450
to_abs_path() {
451
  local p="$1"
452
  if [[ "$p" = /* ]]; then
453
    printf '%s\n' "$p"
454
  else
455
    printf '%s\n' "$PWD/$p"
456
  fi
457
}
458

            
459
path_is_within() {
460
  local child="$1"
461
  local parent="$2"
462
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
463
}
464

            
Bogdan Timofte authored a month ago
465
file_size_bytes_or_zero() {
466
  local file_path="$1"
467
  local file_size="0"
468

            
469
  file_size="$(stat -f%z "$file_path" 2>/dev/null || true)"
470
  if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
471
    file_size="$(stat -c%s "$file_path" 2>/dev/null || true)"
472
  fi
473

            
474
  if [[ "$file_size" =~ ^[0-9]+$ ]]; then
475
    printf '%s\n' "$file_size"
476
  else
477
    printf '0\n'
478
  fi
479
}
480

            
481
dir_available_bytes_or_zero() {
482
  local dir_path="$1"
483
  local avail_kb=""
484

            
485
  avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
486
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
487
    printf '%s\n' $((avail_kb * 1024))
488
  else
489
    printf '0\n'
490
  fi
491
}
492

            
493
staging_has_space_for_input() {
494
  local staging_dir="$1"
495
  local input_file="$2"
496
  local input_size_bytes=0
497
  local needed_bytes=0
498
  local available_bytes=0
499

            
500
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
501

            
502
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
503
  if [[ "$input_size_bytes" -le 0 ]]; then
504
    return 1
505
  fi
506

            
507
  # Keep one simple rule: need roughly 2x input size in staging.
508
  # This covers temp output plus metadata rewrite overhead.
509
  needed_bytes=$((input_size_bytes * 2))
510
  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
511
  [[ "$available_bytes" -ge "$needed_bytes" ]]
512
}
513

            
Bogdan Timofte authored a month ago
514
join_cmd_for_log() {
515
  local out=""
516
  local arg
517
  for arg in "$@"; do
518
    out+="$(printf '%q' "$arg") "
519
  done
520
  printf '%s\n' "${out% }"
521
}
522

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

            
637
  case "$MODE" in
638
    auto|hardware|quality|compat) ;;
639
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
640
  esac
641

            
642
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
643
    die "At least one of --source or --destination must be provided"
644
  fi
645

            
646
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
647
    die "--crf must be an integer"
648
  fi
Bogdan Timofte authored a month ago
649

            
650
  if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then
651
    if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then
652
      die "--debug-timing must be a positive integer"
653
    fi
654
    if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then
655
      die "--debug-timing must be greater than 0"
656
    fi
657
  fi
658

            
659
  if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then
660
    die "--staging-ramdisk-mb must be a positive integer"
661
  fi
662
  if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
663
    die "--staging-ramdisk-mb must be greater than 0"
664
  fi
Bogdan Timofte authored a month ago
665
}
666

            
667
check_tools() {
668
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
669
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
670
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
671

            
672
  HAS_AVCONVERT=false
673
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
674
    HAS_AVCONVERT=true
675
  fi
676
}
677

            
678
make_temp_repack_file() {
679
  local base_tmp="${TMPDIR:-/tmp}"
680
  local seed_path temp_path
681

            
682
  base_tmp="${base_tmp%/}"
683
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
684
  if [[ -n "$seed_path" ]]; then
685
    rm -f -- "$seed_path" 2>/dev/null || true
686
    temp_path="${seed_path}.mp4"
687
    printf '%s\n' "$temp_path"
688
    return
689
  fi
690

            
691
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
692
  printf '%s\n' "$temp_path"
693
}
694

            
695
cleanup_repacked_input() {
696
  local repacked_file="$1"
697
  if [[ -n "$repacked_file" ]]; then
698
    rm -f -- "$repacked_file" 2>/dev/null || true
699
  fi
700
}
701

            
702
try_apple_repack_for_unreadable() {
703
  local source_file="$1"
704
  local repacked_file=""
705

            
706
  REPACKED_SOURCE_PATH=""
707

            
708
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
709
    return 1
710
  fi
711

            
712
  repacked_file="$(make_temp_repack_file)"
713
  if [[ -z "$repacked_file" ]]; then
714
    return 1
715
  fi
716

            
717
  if [[ "$VERBOSE" == true ]]; then
718
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
719
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
720
      cleanup_repacked_input "$repacked_file"
721
      return 1
722
    fi
723
  else
724
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
725
      cleanup_repacked_input "$repacked_file"
726
      return 1
727
    fi
728
  fi
729

            
730
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
731
    REPACKED_SOURCE_PATH="$repacked_file"
732
    return 0
733
  fi
734

            
735
  cleanup_repacked_input "$repacked_file"
736
  return 1
Bogdan Timofte authored a month ago
737
}
738

            
739
restore_metadata_with_exiftool() {
740
  local input_file="$1"
741
  local output_file="$2"
742

            
743
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
744

            
745
  if [[ "$VERBOSE" == true ]]; then
746
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
747
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
748
      return 0
749
    fi
750
  else
751
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
752
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
753
      return 0
754
    fi
755
  fi
756

            
Bogdan Timofte authored a month ago
757
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
758
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
759
    return 1
760
  fi
761

            
Bogdan Timofte authored a month ago
762
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
763
  return 1
764
}
765

            
766
map_garmin_model_to_standard_tags() {
767
  local input_file="$1"
768
  local output_file="$2"
769
  local garmin_model
770

            
771
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
772
  if [[ -z "$garmin_model" ]]; then
773
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
774
    return 0
775
  fi
776

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

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

            
815
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
816
  return 1
817
}
818

            
819
write_transcode_encoder_metadata() {
820
  local output_file="$1"
821
  local encoder_meta
822

            
823
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
824

            
825
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
826

            
827
  if [[ "$VERBOSE" == true ]]; then
828
    if exiftool -overwrite_original -m \
829
      -Software="$encoder_meta" \
830
      -UserData:Software="$encoder_meta" \
831
      "$output_file"; then
832
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
833
      return 0
834
    fi
835
  else
836
    if exiftool -overwrite_original -m -q -q \
837
      -Software="$encoder_meta" \
838
      -UserData:Software="$encoder_meta" \
839
      "$output_file" >/dev/null 2>&1; then
840
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
841
      return 0
842
    fi
843
  fi
844

            
845
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
846
  return 1
Bogdan Timofte authored a month ago
847
}
848

            
849
detect_encoders() {
850
  local encoders
851
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
852

            
853
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
854
    HAS_VIDEOTOOLBOX=true
855
  fi
856
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
857
    HAS_LIBX265=true
858
  fi
859
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
860
    HAS_LIBX264=true
861
  fi
862

            
863
  if [[ "$VERBOSE" == true ]]; then
864
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
865
  fi
866
}
867

            
868
resolve_encoder() {
869
  local os_name
870
  os_name="$(uname -s)"
871

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

            
899
  case "$ENCODER_KIND" in
900
    hardware)
901
      VIDEO_CODEC="hevc_videotoolbox"
902
      VIDEO_CRF=""
903
      ;;
904
    quality)
905
      VIDEO_CODEC="libx265"
906
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
907
      ;;
908
    compat)
909
      VIDEO_CODEC="libx264"
910
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
911
      ;;
912
  esac
913

            
914
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
915
}
916

            
917
probe_has_audio() {
918
  local input_file="$1"
919
  local out
920
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
921
  [[ -n "$out" ]]
922
}
923

            
924
print_verbose_probe() {
925
  local input_file="$1"
926
  ffprobe -v error \
927
    -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 \
928
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
929
}
930

            
931
ffprobe_duration_or_empty() {
932
  local file_path="$1"
933
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
934
}
935

            
936
ffprobe_video_codec_or_empty() {
937
  local file_path="$1"
938
  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
939
}
940

            
Bogdan Timofte authored a month ago
941
source_video_is_readable() {
942
  local file_path="$1"
943
  REPACKED_SOURCE_PATH=""
944

            
945
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
946
    SOURCE_READABLE_MODE="normal"
947
    return 0
948
  fi
949

            
950
  if [[ "$VERBOSE" == true ]]; then
951
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
952
  fi
953

            
954
  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
955
    SOURCE_READABLE_MODE="tolerant"
956
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
957
    return 0
958
  fi
959

            
960
  if try_apple_repack_for_unreadable "$file_path"; then
961
    SOURCE_READABLE_MODE="repacked"
962
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
963
    return 0
964
  fi
965

            
966
  SOURCE_READABLE_MODE="normal"
967
  return 1
968
}
969

            
970
source_error_from_ffmpeg_log() {
971
  local ffmpeg_log="$1"
972
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
973
  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"
974
}
975

            
976
destination_cannot_accept_file() {
977
  local input_file="$1"
978
  local out_dir="$2"
979
  local ffmpeg_log="$3"
980
  local probe_path=""
981
  local avail_kb=""
982
  local avail_bytes=0
983

            
984
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
985
    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
986
      return 0
987
    fi
988
  fi
989

            
990
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
991
  if [[ -z "$probe_path" ]]; then
992
    return 0
993
  fi
994
  rm -f -- "$probe_path" 2>/dev/null || true
995

            
996
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
997
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
998
    avail_bytes=$((avail_kb * 1024))
999
    if [[ "$avail_bytes" -lt 67108864 ]]; then
1000
      return 0
1001
    fi
1002
  fi
1003

            
1004
  return 1
1005
}
1006

            
Bogdan Timofte authored a month ago
1007
validate_transcoded_output() {
1008
  local input_file="$1"
1009
  local output_file="$2"
1010

            
1011
  if [[ ! -f "$output_file" ]]; then
1012
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
1013
    return 1
1014
  fi
1015

            
1016
  local expected_codec actual_codec
1017
  case "$ENCODER_KIND" in
1018
    hardware|quality) expected_codec="hevc" ;;
1019
    compat) expected_codec="h264" ;;
1020
    *)
1021
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
1022
      return 1
1023
      ;;
1024
  esac
1025

            
1026
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
1027
  if [[ -z "$actual_codec" ]]; then
1028
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
1029
    return 1
1030
  fi
1031
  if [[ "$actual_codec" != "$expected_codec" ]]; then
1032
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
1033
    return 1
1034
  fi
1035

            
1036
  local in_duration out_duration duration_delta
1037
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1038
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1039

            
1040
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
1041
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
1042
    return 1
1043
  fi
1044

            
1045
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
1046
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
1047
    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)"
1048
    return 1
1049
  fi
1050

            
1051
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
1052
  return 0
1053
}
1054

            
1055
build_video_args() {
1056
  VIDEO_ARGS=()
1057

            
1058
  case "$ENCODER_KIND" in
1059
    hardware)
1060
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
1061
      ;;
1062
    quality)
1063
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
1064
      ;;
1065
    compat)
1066
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
1067
      ;;
1068
  esac
1069
}
1070

            
1071
normalize_source_dir() {
1072
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1073
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
1074
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1075
}
1076

            
1077
normalize_dest_dir() {
1078
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1079
}
1080

            
Bogdan Timofte authored a month ago
1081
create_missing_staging_ramdisk_if_needed() {
1082
  local staging_path="$1"
1083
  local ramdisk_name=""
1084
  local sectors=0
1085
  local dev=""
1086
  local mount_point=""
1087

            
1088
  staging_path="${staging_path%/}"
1089

            
1090
  if [[ -d "$staging_path" ]]; then
1091
    printf '%s\n' "$staging_path"
1092
    return
1093
  fi
1094

            
1095
  if [[ "$(uname -s)" != "Darwin" ]]; then
1096
    return
1097
  fi
1098

            
1099
  case "$staging_path" in
1100
    /Volumes/*)
1101
      ;;
1102
    *)
1103
      return
1104
      ;;
1105
  esac
1106

            
1107
  ramdisk_name="$(basename "$staging_path")"
1108
  if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
1109
    return
1110
  fi
1111

            
1112
  if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1113
    # Only auto-create for top-level /Volumes/<Name>, not nested paths.
1114
    return
1115
  fi
1116

            
1117
  [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk"
1118
  [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk"
1119

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

            
1127
  if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
1128
    /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1129
    return
1130
  fi
1131

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

            
1141
  if [[ -d "$staging_path" ]]; then
1142
    AUTO_CREATED_STAGING_RAMDISK=true
1143
    AUTO_CREATED_STAGING_PATH="$staging_path"
1144
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1145
    printf '%s\n' "$staging_path"
1146
    return
1147
  fi
1148
}
1149

            
1150
attempt_unmount_auto_staging_ramdisk() {
1151
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then
1152
    return
1153
  fi
1154

            
1155
  if [[ "$(uname -s)" != "Darwin" ]]; then
1156
    return
1157
  fi
1158

            
1159
  if /usr/sbin/diskutil eject "$AUTO_CREATED_STAGING_PATH" >/dev/null 2>&1; then
1160
    log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH"
1161
    return
1162
  fi
1163

            
1164
  if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1165
    log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH"
1166
  else
1167
    log_msg "WARN" "Could not confirm unmount status for auto-created staging RAM disk: $AUTO_CREATED_STAGING_PATH"
1168
  fi
1169
}
1170

            
Bogdan Timofte authored a month ago
1171
normalize_staging_dir() {
1172
  if [[ "$STAGING_PROVIDED" != true ]]; then
1173
    STAGING_DIR=""
1174
    return
1175
  fi
1176

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

            
1192
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1193
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1194
  fi
1195
}
1196

            
Bogdan Timofte authored a month ago
1197
collect_extensions() {
1198
  local raw="$EXTENSIONS_CSV"
1199
  local token
1200
  EXT_LIST=()
1201

            
1202
  IFS=',' read -r -a tokens <<< "$raw"
1203
  for token in "${tokens[@]}"; do
1204
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
1205
    token="${token#.}"
1206
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
1207
    [[ -n "$token" ]] && EXT_LIST+=("$token")
1208
  done
1209

            
1210
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1211
    die "No valid extensions after parsing --extensions"
1212
  fi
1213
}
1214

            
1215
build_find_expr_for_extensions() {
1216
  FIND_EXT_EXPR=()
1217
  local ext
1218
  for ext in "${EXT_LIST[@]}"; do
1219
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
1220
  done
1221
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
1222
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
1223
  fi
1224
}
1225

            
1226
rel_path_from_source() {
1227
  local abs_file="$1"
1228
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1229
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1230
  else
1231
    printf '%s\n' "$(basename "$abs_file")"
1232
  fi
1233
}
1234

            
1235
collect_video_files() {
1236
  VIDEO_FILES=()
1237

            
1238
  if [[ -n "$SINGLE_FILE" ]]; then
1239
    local single_abs
1240
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1241
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1242
    VIDEO_FILES+=("$single_abs")
1243
    return
1244
  fi
1245

            
1246
  build_find_expr_for_extensions
1247

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

            
1266
process_video_file() {
1267
  local input_file="$1"
Bogdan Timofte authored a month ago
1268
  local rel_path output_file temp_output_file out_dir
1269
  local display_path
1270
  local encode_input_file
Bogdan Timofte authored a month ago
1271
  local preferred_temp_dir=""
Bogdan Timofte authored a month ago
1272
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1273
  local input_size_bytes=0 output_size_bytes=0
Bogdan Timofte authored a month ago
1274
  local file_started_at file_ended_at file_real_elapsed_sec
1275
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1276
  local post_elapsed_sec
1277

            
1278
  file_started_at="$(date +%s)"
1279
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1280

            
1281
  rel_path="$(rel_path_from_source "$input_file")"
1282
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1283
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1284
  display_path="${input_file#$PWD/}"
1285
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1286

            
1287
  mkdir -p "$out_dir"
1288

            
Bogdan Timofte authored a month ago
1289
  if ! source_video_is_readable "$input_file"; then
1290
    ERRORS=$((ERRORS + 1))
1291
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1292
    if [[ "$VERBOSE" == true ]]; then
1293
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1294
    else
1295
      log_progress_start "$display_path"
1296
      log_progress_skipped_unreadable "$display_path"
1297
    fi
1298
    return 2
1299
  fi
1300

            
1301
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1302
    repacked_input_file="$REPACKED_SOURCE_PATH"
1303
    encode_input_file="$REPACKED_SOURCE_PATH"
1304
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1305
  fi
1306

            
Bogdan Timofte authored a month ago
1307
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1308
    vlog_msg "SKIP" "Video exists: $output_file"
1309
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1310
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1311
    return
1312
  fi
1313

            
1314
  local has_audio=false
Bogdan Timofte authored a month ago
1315
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1316
    has_audio=true
1317
  fi
1318

            
1319
  if [[ "$VERBOSE" == true ]]; then
1320
    log_msg "INFO" "ffprobe summary: $input_file"
1321
    print_verbose_probe "$input_file"
1322
    log_msg "INFO" "Audio detected: $has_audio"
1323
  fi
1324

            
1325
  build_video_args
1326

            
Bogdan Timofte authored a month ago
1327
  preferred_temp_dir="$STAGING_DIR"
1328
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then
1329
    if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then
1330
      preferred_temp_dir=""
1331
      log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file"
1332
    fi
1333
  fi
1334

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

            
Bogdan Timofte authored a month ago
1346
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1347
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1348
    cmd+=( -fflags +genpts -err_detect ignore_err )
1349
  fi
Bogdan Timofte authored a month ago
1350
  if [[ "$OVERWRITE" == true ]]; then
1351
    cmd+=( -y )
1352
  else
1353
    cmd+=( -n )
1354
  fi
1355

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

            
1358
  if [[ "$has_audio" == true ]]; then
1359
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1360
  fi
1361

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

            
1364
  if [[ "$VERBOSE" == true ]]; then
1365
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1366
  fi
1367

            
1368
  if [[ "$DRY_RUN" == true ]]; then
1369
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1370
    return 0
1371
  fi
1372

            
Bogdan Timofte authored a month ago
1373
  if [[ "$VERBOSE" != true ]]; then
1374
    log_progress_start "$display_path"
1375
  fi
1376

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

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

            
Bogdan Timofte authored a month ago
1418
  encode_ended_at="$(date +%s)"
1419
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1420
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1421

            
1422
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1423
    local failure_rc=1
1424
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1425
      failure_rc=3
1426
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1427
      failure_rc=2
1428
    fi
1429

            
1430
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1431
    file_ended_at="$(date +%s)"
1432
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1433
    local encode_elapsed_fmt
1434
    local real_elapsed_fmt
1435
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1436
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1437

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

            
Bogdan Timofte authored a month ago
1456
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1457

            
Bogdan Timofte authored a month ago
1458
  if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then
1459
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1460
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1461
      cleanup_repacked_input "$repacked_input_file"
1462
      return 4
1463
    fi
Bogdan Timofte authored a month ago
1464
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1465
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1466
    cleanup_repacked_input "$repacked_input_file"
1467
    return 3
Bogdan Timofte authored a month ago
1468
  fi
1469

            
Bogdan Timofte authored a month ago
1470
  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1471
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1472
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1473
      cleanup_repacked_input "$repacked_input_file"
1474
      return 4
1475
    fi
Bogdan Timofte authored a month ago
1476
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1477
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1478
    cleanup_repacked_input "$repacked_input_file"
1479
    return 3
Bogdan Timofte authored a month ago
1480
  fi
1481

            
Bogdan Timofte authored a month ago
1482
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1483
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1484
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1485
      cleanup_repacked_input "$repacked_input_file"
1486
      return 4
1487
    fi
Bogdan Timofte authored a month ago
1488
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1489
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1490
    cleanup_repacked_input "$repacked_input_file"
1491
    return 3
Bogdan Timofte authored a month ago
1492
  fi
Bogdan Timofte authored a month ago
1493

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

            
Bogdan Timofte authored a month ago
1512
  touch -r "$input_file" "$temp_output_file" || true
1513

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

            
Bogdan Timofte authored a month ago
1534
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1535
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1536
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1537
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1538

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

            
Bogdan Timofte authored a month ago
1550
  file_ended_at="$(date +%s)"
1551
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1552
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1553
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1554
    post_elapsed_sec=0
1555
  fi
1556
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1557
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1558

            
Bogdan Timofte authored a month ago
1559
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1560

            
1561
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1562
    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
1563
  else
Bogdan Timofte authored a month ago
1564
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1565
  fi
1566

            
Bogdan Timofte authored a month ago
1567
  cleanup_repacked_input "$repacked_input_file"
1568

            
Bogdan Timofte authored a month ago
1569
  return 0
1570
}
1571

            
1572
main() {
1573
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1574
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1575
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1576

            
Bogdan Timofte authored a month ago
1577
  trap 'handle_interrupt' INT TERM
1578

            
Bogdan Timofte authored a month ago
1579
  parse_args "$@"
1580
  check_tools
1581

            
1582
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1583
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1584
    SINGLE_FILE="$SOURCE_DIR"
1585
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1586
  fi
1587

            
1588
  normalize_source_dir
1589
  normalize_dest_dir
Bogdan Timofte authored a month ago
1590
  normalize_staging_dir
Bogdan Timofte authored a month ago
1591
  collect_extensions
1592

            
1593
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1594
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1595
  fi
1596

            
1597
  detect_encoders
1598
  resolve_encoder
1599

            
1600
  collect_video_files
Bogdan Timofte authored a month ago
1601
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1602
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1603
  fi
1604

            
1605
  local f
Bogdan Timofte authored a month ago
1606
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1607
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1608
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1609
      break
1610
    fi
Bogdan Timofte authored a month ago
1611

            
1612
    local process_rc=0
1613
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1614
      process_rc=0
Bogdan Timofte authored a month ago
1615
    else
1616
      process_rc=$?
1617
    fi
1618

            
Bogdan Timofte authored a month ago
1619
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1620
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
1621
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
1622
        DEBUG_TIMING_STOPPED=true
1623
      fi
1624
    fi
1625

            
1626
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1627
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
1628
      break
1629
    fi
1630

            
1631
    if [[ "$process_rc" -eq 0 ]]; then
1632
      continue
1633
    fi
1634

            
Bogdan Timofte authored a month ago
1635
    case "$process_rc" in
1636
      2)
Bogdan Timofte authored a month ago
1637
        if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1638
          log_msg "INFO" "Stopped by user after current file"
1639
          break
1640
        fi
Bogdan Timofte authored a month ago
1641
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1642
        continue
1643
        ;;
1644
      3)
1645
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1646
        break
1647
        ;;
1648
      4)
1649
        log_msg "INFO" "Stopped by user after current file"
1650
        break
1651
        ;;
1652
      *)
1653
        if [[ "$FAIL_FAST" == true ]]; then
1654
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1655
          break
1656
        fi
1657
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1658
        continue
1659
        ;;
1660
    esac
Bogdan Timofte authored a month ago
1661
  done
1662

            
Bogdan Timofte authored a month ago
1663
  if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
Bogdan Timofte authored a month ago
1664
    log_msg "INFO" "Skipping sidecar handling because debug timing mode requested early stop"
Bogdan Timofte authored a month ago
1665
  elif [[ "$STOP_AFTER_CURRENT" == true ]]; then
Bogdan Timofte authored a month ago
1666
    log_msg "INFO" "Skipping sidecar handling because run was stopped by user"
Bogdan Timofte authored a month ago
1667
  elif [[ "$ERRORS" -eq 0 ]]; then
Bogdan Timofte authored a month ago
1668
    log_msg "INFO" "Sidecar handling is disabled for this release"
Bogdan Timofte authored a month ago
1669
  else
Bogdan Timofte authored a month ago
1670
    log_msg "INFO" "Skipping sidecar handling because encoding ended with errors"
Bogdan Timofte authored a month ago
1671
  fi
1672

            
1673
  total_ended_at="$(date +%s)"
1674
  total_elapsed_sec=$((total_ended_at - total_started_at))
1675
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
1676

            
1677
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
1678
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
1679
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
1680
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
Bogdan Timofte authored a month ago
1681
  local startup_warmup_sec=-1 startup_warmup_fmt="n/a"
1682
  local staging_warmup_sec=-1 staging_warmup_fmt="n/a"
Bogdan Timofte authored a month ago
1683

            
Bogdan Timofte authored a month ago
1684
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
1685
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
1686
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
1687
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
1688
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
1689
  fi
1690

            
1691
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
1692
  if [[ "$file_post_total_sec" -lt 0 ]]; then
1693
    file_post_total_sec=0
1694
  fi
1695
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
1696

            
1697
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
1698
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
1699
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
1700
  fi
Bogdan Timofte authored a month ago
1701
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
1702

            
Bogdan Timofte authored a month ago
1703
  if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1704
    startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT))
1705
    if [[ "$startup_warmup_sec" -lt 0 ]]; then
1706
      startup_warmup_sec=0
1707
    fi
1708
    startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")"
1709
  fi
1710

            
1711
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1712
    staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1713
    if [[ "$staging_warmup_sec" -lt 0 ]]; then
1714
      staging_warmup_sec=0
1715
    fi
1716
    staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")"
1717
  fi
1718

            
Bogdan Timofte authored a month ago
1719
  print_final_report \
1720
    "$total_elapsed_sec" \
1721
    "$total_elapsed_fmt" \
1722
    "$file_post_total_sec" \
1723
    "$file_post_total_fmt" \
1724
    "$run_non_file_overhead_sec" \
1725
    "$run_non_file_overhead_fmt" \
1726
    "$avg_file_real_time_sec" \
1727
    "$avg_file_real_time_fmt" \
1728
    "$avg_video_time_sec" \
Bogdan Timofte authored a month ago
1729
    "$avg_video_time_fmt" \
1730
    "$startup_warmup_sec" \
1731
    "$startup_warmup_fmt" \
1732
    "$staging_warmup_sec" \
1733
    "$staging_warmup_fmt" \
1734
    "$INPUT_BYTES_PROCESSED" \
1735
    "$OUTPUT_BYTES_PROCESSED"
1736

            
1737
  attempt_unmount_auto_staging_ramdisk
Bogdan Timofte authored a month ago
1738

            
1739
  if [[ "$ERRORS" -gt 0 ]]; then
1740
    exit 1
1741
  fi
1742
}
1743

            
1744
main "$@"