VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1744 lines | 55.268kb
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=""
Bogdan Timofte authored a month ago
48
AUTO_CREATED_STAGING_DEV=""
Bogdan Timofte authored a month ago
49
STAGING_RAMDISK_CREATED_AT=0
Bogdan Timofte authored a month ago
50
AUTO_STAGING_CLEANED_UP=false
Bogdan Timofte authored a month ago
51
RUN_STARTED_AT=0
52
FIRST_ENCODE_STARTED_AT=0
53
DEBUG_TIMING_LIMIT=0
54
DEBUG_TIMING_FILES=0
55
DEBUG_TIMING_STOPPED=false
Bogdan Timofte authored a month ago
56
FAIL_FAST=true
57
SOURCE_READABLE_MODE="normal"
58
APPLE_REPACK_FALLBACK=true
59
REPACKED_SOURCE_PATH=""
Bogdan Timofte authored a month ago
60

            
61
HAS_VIDEOTOOLBOX=false
62
HAS_LIBX265=false
63
HAS_LIBX264=false
Bogdan Timofte authored a month ago
64
HAS_AVCONVERT=false
Bogdan Timofte authored a month ago
65

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

            
74
VIDEOS_PROCESSED=0
75
VIDEOS_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

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

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

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

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

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

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

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

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

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

            
214
log_progress_skipped_unreadable() {
215
  local input_file="$1"
216

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

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

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

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

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

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

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

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

            
264
  local rc=$?
265
  CURRENT_FFMPEG_PID=""
266
  return "$rc"
267
}
268

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

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

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

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

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

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

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

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

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

            
369
  printf '%s\n' "$temp_path"
370
}
371

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

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

            
382
  if [[ -n "$preferred_dir" ]]; then
383
    work_dir="$preferred_dir"
384
  fi
385

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

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

            
412
  printf '%s\n' "$temp_path"
413
}
414

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

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

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

            
430
is_apple_noise_file() {
431
  local file_path="$1"
432
  local base
433
  base="$(basename "$file_path")"
434

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

            
441
  return 1
442
}
443

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

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

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

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

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

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

            
483
dir_available_bytes_or_zero() {
484
  local dir_path="$1"
485
  local avail_kb=""
486

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

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

            
502
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
503

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
704
try_apple_repack_for_unreadable() {
705
  local source_file="$1"
706
  local repacked_file=""
707

            
708
  REPACKED_SOURCE_PATH=""
709

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

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

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

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

            
737
  cleanup_repacked_input "$repacked_file"
738
  return 1
Bogdan Timofte authored a month ago
739
}
740

            
741
restore_metadata_with_exiftool() {
742
  local input_file="$1"
743
  local output_file="$2"
744

            
745
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
746

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

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

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

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

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

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

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

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

            
821
write_transcode_encoder_metadata() {
822
  local output_file="$1"
823
  local encoder_meta
824

            
825
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
826

            
827
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
828

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

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

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

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

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

            
870
resolve_encoder() {
871
  local os_name
872
  os_name="$(uname -s)"
873

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

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

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

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

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

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

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

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

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

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

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

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

            
968
  SOURCE_READABLE_MODE="normal"
969
  return 1
970
}
971

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

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

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

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

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

            
1006
  return 1
1007
}
1008

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

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

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

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

            
1038
  local in_duration out_duration duration_delta
1039
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1040
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1041

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

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

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

            
1057
build_video_args() {
1058
  VIDEO_ARGS=()
1059

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

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

            
1079
normalize_dest_dir() {
1080
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1081
}
1082

            
Bogdan Timofte authored a month ago
1083
# Sets globals: AUTO_CREATED_STAGING_RAMDISK, AUTO_CREATED_STAGING_PATH,
1084
# AUTO_CREATED_STAGING_DEV, STAGING_RAMDISK_CREATED_AT
1085
# Must be called directly (NOT inside $(...)) to preserve global assignments.
Bogdan Timofte authored a month ago
1086
create_missing_staging_ramdisk_if_needed() {
1087
  local staging_path="$1"
1088
  local ramdisk_name=""
1089
  local sectors=0
1090
  local dev=""
1091
  local mount_point=""
1092

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

            
1095
  if [[ -d "$staging_path" ]]; then
Bogdan Timofte authored a month ago
1096
    # Already exists; nothing to create.
Bogdan Timofte authored a month ago
1097
    return
1098
  fi
1099

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

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

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

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

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

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

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

            
1137
  mount_point="$(/usr/sbin/diskutil info "$dev" 2>/dev/null | /usr/bin/awk -F': *' '/Mount Point/ {print $2; exit}')"
Bogdan Timofte authored a month ago
1138
  if [[ -z "$mount_point" ]]; then
1139
    mount_point="$staging_path"
Bogdan Timofte authored a month ago
1140
  fi
1141

            
Bogdan Timofte authored a month ago
1142
  if [[ -d "$mount_point" ]]; then
Bogdan Timofte authored a month ago
1143
    AUTO_CREATED_STAGING_RAMDISK=true
Bogdan Timofte authored a month ago
1144
    AUTO_CREATED_STAGING_PATH="$mount_point"
1145
    AUTO_CREATED_STAGING_DEV="$dev"
Bogdan Timofte authored a month ago
1146
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
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

            
Bogdan Timofte authored a month ago
1155
  if [[ "$AUTO_STAGING_CLEANED_UP" == true ]]; then
1156
    return
1157
  fi
1158
  AUTO_STAGING_CLEANED_UP=true
1159

            
Bogdan Timofte authored a month ago
1160
  if [[ "$(uname -s)" != "Darwin" ]]; then
1161
    return
1162
  fi
1163

            
Bogdan Timofte authored a month ago
1164
  local target="${AUTO_CREATED_STAGING_DEV:-$AUTO_CREATED_STAGING_PATH}"
1165
  if /usr/bin/hdiutil detach "$target" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
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
Bogdan Timofte authored a month ago
1173
    log_msg "INFO" "Auto-created staging RAM disk already gone: $AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1174
  fi
1175
}
1176

            
Bogdan Timofte authored a month ago
1177
cleanup_on_exit() {
1178
  local rc=$?
1179
  attempt_unmount_auto_staging_ramdisk
1180
  return "$rc"
1181
}
1182

            
Bogdan Timofte authored a month ago
1183
normalize_staging_dir() {
1184
  if [[ "$STAGING_PROVIDED" != true ]]; then
1185
    STAGING_DIR=""
1186
    return
1187
  fi
1188

            
1189
  STAGING_DIR="$(to_abs_path "$STAGING_DIR")"
Bogdan Timofte authored a month ago
1190
  if [[ ! -d "$STAGING_DIR" ]]; then
Bogdan Timofte authored a month ago
1191
    create_missing_staging_ramdisk_if_needed "$STAGING_DIR"
1192
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1193
      STAGING_DIR="$AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1194
      log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1195
    else
1196
      die "Staging directory not found: $STAGING_DIR"
1197
    fi
1198
  fi
Bogdan Timofte authored a month ago
1199
  [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR"
1200
  STAGING_DIR="$(cd "$STAGING_DIR" && pwd)"
1201
  [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR"
1202

            
1203
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1204
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1205
  fi
1206
}
1207

            
Bogdan Timofte authored a month ago
1208
collect_extensions() {
1209
  local raw="$EXTENSIONS_CSV"
1210
  local token
1211
  EXT_LIST=()
1212

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

            
1221
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1222
    die "No valid extensions after parsing --extensions"
1223
  fi
1224
}
1225

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

            
1237
rel_path_from_source() {
1238
  local abs_file="$1"
1239
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1240
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1241
  else
1242
    printf '%s\n' "$(basename "$abs_file")"
1243
  fi
1244
}
1245

            
1246
collect_video_files() {
1247
  VIDEO_FILES=()
1248

            
1249
  if [[ -n "$SINGLE_FILE" ]]; then
1250
    local single_abs
1251
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1252
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1253
    VIDEO_FILES+=("$single_abs")
1254
    return
1255
  fi
1256

            
1257
  build_find_expr_for_extensions
1258

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

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

            
1289
  file_started_at="$(date +%s)"
1290
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1291

            
1292
  rel_path="$(rel_path_from_source "$input_file")"
1293
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1294
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1295
  display_path="${input_file#$PWD/}"
1296
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1297

            
1298
  mkdir -p "$out_dir"
1299

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

            
1312
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1313
    repacked_input_file="$REPACKED_SOURCE_PATH"
1314
    encode_input_file="$REPACKED_SOURCE_PATH"
1315
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1316
  fi
1317

            
Bogdan Timofte authored a month ago
1318
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1319
    vlog_msg "SKIP" "Video exists: $output_file"
1320
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1321
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1322
    return
1323
  fi
1324

            
1325
  local has_audio=false
Bogdan Timofte authored a month ago
1326
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1327
    has_audio=true
1328
  fi
1329

            
1330
  if [[ "$VERBOSE" == true ]]; then
1331
    log_msg "INFO" "ffprobe summary: $input_file"
1332
    print_verbose_probe "$input_file"
1333
    log_msg "INFO" "Audio detected: $has_audio"
1334
  fi
1335

            
1336
  build_video_args
1337

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

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

            
Bogdan Timofte authored a month ago
1357
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1358
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1359
    cmd+=( -fflags +genpts -err_detect ignore_err )
1360
  fi
Bogdan Timofte authored a month ago
1361
  if [[ "$OVERWRITE" == true ]]; then
1362
    cmd+=( -y )
1363
  else
1364
    cmd+=( -n )
1365
  fi
1366

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

            
1369
  if [[ "$has_audio" == true ]]; then
1370
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1371
  fi
1372

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

            
1375
  if [[ "$VERBOSE" == true ]]; then
1376
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1377
  fi
1378

            
1379
  if [[ "$DRY_RUN" == true ]]; then
1380
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1381
    return 0
1382
  fi
1383

            
Bogdan Timofte authored a month ago
1384
  if [[ "$VERBOSE" != true ]]; then
1385
    log_progress_start "$display_path"
1386
  fi
1387

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

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

            
Bogdan Timofte authored a month ago
1429
  encode_ended_at="$(date +%s)"
1430
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1431
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1432

            
1433
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1434
    local failure_rc=1
1435
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1436
      failure_rc=3
1437
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1438
      failure_rc=2
1439
    fi
1440

            
1441
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1442
    file_ended_at="$(date +%s)"
1443
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1444
    local encode_elapsed_fmt
1445
    local real_elapsed_fmt
1446
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1447
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1448

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

            
Bogdan Timofte authored a month ago
1467
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1468

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

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

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

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

            
Bogdan Timofte authored a month ago
1523
  touch -r "$input_file" "$temp_output_file" || true
1524

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

            
Bogdan Timofte authored a month ago
1545
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1546
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1547
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1548
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1549

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

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

            
Bogdan Timofte authored a month ago
1570
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1571

            
1572
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1573
    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
1574
  else
Bogdan Timofte authored a month ago
1575
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1576
  fi
1577

            
Bogdan Timofte authored a month ago
1578
  cleanup_repacked_input "$repacked_input_file"
1579

            
Bogdan Timofte authored a month ago
1580
  return 0
1581
}
1582

            
1583
main() {
1584
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1585
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1586
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1587

            
Bogdan Timofte authored a month ago
1588
  trap 'handle_interrupt' INT TERM
Bogdan Timofte authored a month ago
1589
  trap 'cleanup_on_exit' EXIT
Bogdan Timofte authored a month ago
1590

            
Bogdan Timofte authored a month ago
1591
  parse_args "$@"
1592
  check_tools
1593

            
1594
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1595
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1596
    SINGLE_FILE="$SOURCE_DIR"
1597
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1598
  fi
1599

            
1600
  normalize_source_dir
1601
  normalize_dest_dir
Bogdan Timofte authored a month ago
1602
  normalize_staging_dir
Bogdan Timofte authored a month ago
1603
  collect_extensions
1604

            
1605
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1606
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1607
  fi
1608

            
1609
  detect_encoders
1610
  resolve_encoder
1611

            
1612
  collect_video_files
Bogdan Timofte authored a month ago
1613
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1614
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1615
  fi
1616

            
1617
  local f
Bogdan Timofte authored a month ago
1618
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1619
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1620
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1621
      break
1622
    fi
Bogdan Timofte authored a month ago
1623

            
1624
    local process_rc=0
1625
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1626
      process_rc=0
Bogdan Timofte authored a month ago
1627
    else
1628
      process_rc=$?
1629
    fi
1630

            
Bogdan Timofte authored a month ago
1631
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1632
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
1633
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
1634
        DEBUG_TIMING_STOPPED=true
1635
      fi
1636
    fi
1637

            
1638
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1639
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
1640
      break
1641
    fi
1642

            
1643
    if [[ "$process_rc" -eq 0 ]]; then
1644
      continue
1645
    fi
1646

            
Bogdan Timofte authored a month ago
1647
    case "$process_rc" in
1648
      2)
Bogdan Timofte authored a month ago
1649
        if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1650
          log_msg "INFO" "Stopped by user after current file"
1651
          break
1652
        fi
Bogdan Timofte authored a month ago
1653
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1654
        continue
1655
        ;;
1656
      3)
1657
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1658
        break
1659
        ;;
1660
      4)
1661
        log_msg "INFO" "Stopped by user after current file"
1662
        break
1663
        ;;
1664
      *)
1665
        if [[ "$FAIL_FAST" == true ]]; then
1666
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1667
          break
1668
        fi
1669
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1670
        continue
1671
        ;;
1672
    esac
Bogdan Timofte authored a month ago
1673
  done
1674

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
1739
  if [[ "$ERRORS" -gt 0 ]]; then
1740
    exit 1
1741
  fi
1742
}
1743

            
1744
main "$@"