VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1772 lines | 56.489kb
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

            
Bogdan Timofte authored a month ago
237
  # If quiet-mode progress is mid-line, break it before interrupt logs.
238
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
239
    printf '\n'
240
    PROGRESS_LINE_OPEN=false
241
  fi
242

            
Bogdan Timofte authored a month ago
243
  if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
Bogdan Timofte authored a month ago
244
    # ffmpeg is in the same process group and already received SIGINT; it will
245
    # stop gracefully on its own.  A second Ctrl+C force-kills it if needed.
246
    log_msg "WARN" "Interrupted; stopping after current encode. Ctrl+C again to force-kill."
247
  elif [[ -n "$CURRENT_FFMPEG_PID" ]]; then
248
    log_msg "WARN" "Force-stopping encode (pid=$CURRENT_FFMPEG_PID)."
249
    kill -9 "$CURRENT_FFMPEG_PID" 2>/dev/null || true
Bogdan Timofte authored a month ago
250
  fi
Bogdan Timofte authored a month ago
251
}
Bogdan Timofte authored a month ago
252

            
Bogdan Timofte authored a month ago
253
# Drain all pending keystrokes from /dev/tty (non-blocking) and set
254
# STOP_AFTER_CURRENT if any of them is 'q'/'Q'.  Self-contained: saves/restores
255
# stty settings so it works regardless of the terminal's current mode.
256
# Uses min=0 time=0 so the terminal's read() returns immediately when the
257
# buffer is empty (bash treats a 0-byte read as EOF and exits the loop).
258
# -t 1 is a safety net for platforms where select() on a VMIN=0 fd does not
259
# report readable-when-empty; in that case we block at most 1s then exit.
260
check_for_quit_key() {
261
  [[ -e /dev/tty ]] || return 0
262
  local key old_stty found=false
263
  old_stty=$(stty -g </dev/tty 2>/dev/null) || return 0
264
  stty -echo -icanon min 0 time 0 </dev/tty 2>/dev/null \
265
    || { stty "$old_stty" </dev/tty 2>/dev/null || true; return 0; }
266
  while IFS= read -r -s -n 1 -t 1 key </dev/tty 2>/dev/null; do
267
    if [[ "$key" == "q" || "$key" == "Q" ]]; then
268
      found=true
269
      break
270
    fi
271
  done
272
  stty "$old_stty" </dev/tty 2>/dev/null || true
273
  if [[ "$found" == true ]]; then
274
    STOP_AFTER_CURRENT=true
275
    log_msg "INFO" "Quit key pressed; stopping before next file"
Bogdan Timofte authored a month ago
276
  fi
277
}
278

            
279
run_ffmpeg_with_signal_guard() {
Bogdan Timofte authored a month ago
280
  # ffmpeg runs in the same process group as the shell so Ctrl+C (SIGINT)
281
  # reaches it directly and triggers its own graceful shutdown.  stdin is
282
  # /dev/null so ffmpeg disables keyboard control and avoids SIGTTIN.
283
  # CURRENT_FFMPEG_PID is tracked for the force-kill second-Ctrl+C path.
284
  "$@" </dev/null &
Bogdan Timofte authored a month ago
285

            
286
  CURRENT_FFMPEG_PID=$!
287
  if wait "$CURRENT_FFMPEG_PID"; then
288
    CURRENT_FFMPEG_PID=""
289
    return 0
290
  fi
291

            
292
  local rc=$?
293
  CURRENT_FFMPEG_PID=""
294
  return "$rc"
295
}
296

            
Bogdan Timofte authored a month ago
297
format_seconds() {
298
  local sec="$1"
299
  local h m s
300
  h=$((sec / 3600))
301
  m=$(((sec % 3600) / 60))
302
  s=$((sec % 60))
303
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
304
}
305

            
Bogdan Timofte authored a month ago
306
format_bytes_human() {
307
  local bytes="$1"
308
  awk -v b="$bytes" 'BEGIN {
309
    split("B KiB MiB GiB TiB PiB", u, " ");
310
    i = 1;
311
    while (b >= 1024 && i < 6) {
312
      b = b / 1024;
313
      i++;
314
    }
315
    if (i == 1) {
316
      printf "%d %s", b, u[i];
317
    } else {
318
      printf "%.2f %s", b, u[i];
319
    }
320
  }'
321
}
322

            
Bogdan Timofte authored a month ago
323
print_final_report() {
324
  local total_elapsed_sec="$1"
325
  local total_elapsed_fmt="$2"
326
  local file_post_total_sec="$3"
327
  local file_post_total_fmt="$4"
328
  local run_non_file_overhead_sec="$5"
329
  local run_non_file_overhead_fmt="$6"
330
  local avg_file_real_time_sec="$7"
331
  local avg_file_real_time_fmt="$8"
332
  local avg_video_time_sec="$9"
333
  local avg_video_time_fmt="${10}"
Bogdan Timofte authored a month ago
334
  local startup_warmup_sec="${11}"
335
  local startup_warmup_fmt="${12}"
336
  local staging_warmup_sec="${13}"
337
  local staging_warmup_fmt="${14}"
338
  local input_bytes_processed="${15}"
339
  local output_bytes_processed="${16}"
Bogdan Timofte authored a month ago
340

            
341
  printf '\n'
342
  printf 'Run Summary\n'
343
  printf '+---------------------------+-------+\n'
344
  printf '| %-25s | %-5s |\n' "Metric" "Value"
345
  printf '+---------------------------+-------+\n'
346
  printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED"
347
  printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED"
348
  printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED"
349
  printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES"
350
  printf '| %-25s | %5s |\n' "Errors" "$ERRORS"
351
  printf '+---------------------------+-------+\n'
352

            
353
  printf '\nTimings\n'
354
  printf '+---------------------------+---------+----------+\n'
355
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
356
  printf '+---------------------------+---------+----------+\n'
357
  printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt"
358
  printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")"
359
  printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")"
360
  printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt"
361
  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
362
  if [[ "$startup_warmup_sec" -ge 0 ]]; then
363
    printf '| %-25s | %7s | %-8s |\n' "Startup warm-up" "${startup_warmup_sec}s" "$startup_warmup_fmt"
364
  fi
365
  if [[ "$staging_warmup_sec" -ge 0 ]]; then
366
    printf '| %-25s | %7s | %-8s |\n' "Staging warm-up" "${staging_warmup_sec}s" "$staging_warmup_fmt"
367
  fi
Bogdan Timofte authored a month ago
368
  printf '+---------------------------+---------+----------+\n'
369

            
370
  printf '\nPer-File Averages\n'
371
  printf '+---------------------------+---------+----------+\n'
372
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
373
  printf '+---------------------------+---------+----------+\n'
374
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
375
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
376
  printf '+---------------------------+---------+----------+\n'
377
}
378

            
Bogdan Timofte authored a month ago
379
make_temp_log_file() {
380
  local temp_path
381
  local base_tmp="${TMPDIR:-/tmp}"
382
  base_tmp="${base_tmp%/}"
383

            
384
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
385
  if [[ -z "$temp_path" ]]; then
386
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
387
  fi
388

            
389
  printf '%s\n' "$temp_path"
390
}
391

            
Bogdan Timofte authored a month ago
392
make_temp_output_file() {
393
  local target_file="$1"
Bogdan Timofte authored a month ago
394
  local preferred_dir="${2:-}"
395
  local target_dir target_base target_noext temp_path seed_path work_dir
Bogdan Timofte authored a month ago
396

            
397
  target_dir="$(dirname "$target_file")"
398
  target_base="$(basename "$target_file")"
399
  target_noext="${target_base%.*}"
Bogdan Timofte authored a month ago
400
  work_dir="$target_dir"
401

            
402
  if [[ -n "$preferred_dir" ]]; then
403
    work_dir="$preferred_dir"
404
  fi
405

            
406
  seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
407
  if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then
408
    seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
409
  fi
Bogdan Timofte authored a month ago
410

            
411
  if [[ -n "$seed_path" ]]; then
412
    rm -f -- "$seed_path" 2>/dev/null || true
413
    temp_path="${seed_path}.mp4"
414
  else
Bogdan Timofte authored a month ago
415
    temp_path="$work_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
Bogdan Timofte authored a month ago
416
    if ! touch "$temp_path" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
417
      if [[ "$work_dir" != "$target_dir" ]]; then
418
        temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
419
        if ! touch "$temp_path" >/dev/null 2>&1; then
420
          temp_path=""
421
        else
422
          rm -f -- "$temp_path" 2>/dev/null || true
423
        fi
424
      else
425
        temp_path=""
426
      fi
Bogdan Timofte authored a month ago
427
    else
428
      rm -f -- "$temp_path" 2>/dev/null || true
429
    fi
430
  fi
431

            
432
  printf '%s\n' "$temp_path"
433
}
434

            
435
cleanup_transcode_artifacts() {
436
  local temp_output="$1"
437
  local final_output="$2"
Bogdan Timofte authored a month ago
438
  local exif_backup_default="${temp_output}_original"
439
  local exif_backup_alt="${temp_output}.exif_original"
Bogdan Timofte authored a month ago
440

            
441
  rm -f -- "$temp_output" 2>/dev/null || true
Bogdan Timofte authored a month ago
442
  rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true
Bogdan Timofte authored a month ago
443

            
444
  # Defensive cleanup for previously failed non-atomic runs.
445
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
446
    rm -f -- "$final_output" 2>/dev/null || true
447
  fi
448
}
449

            
450
is_apple_noise_file() {
451
  local file_path="$1"
452
  local base
453
  base="$(basename "$file_path")"
454

            
455
  case "$base" in
456
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
457
      return 0
458
      ;;
459
  esac
460

            
461
  return 1
462
}
463

            
Bogdan Timofte authored a month ago
464
require_value() {
465
  local flag="$1"
466
  local value="${2:-}"
467
  if [[ -z "$value" ]]; then
468
    die "Missing value for $flag"
469
  fi
470
}
471

            
472
to_abs_path() {
473
  local p="$1"
474
  if [[ "$p" = /* ]]; then
475
    printf '%s\n' "$p"
476
  else
477
    printf '%s\n' "$PWD/$p"
478
  fi
479
}
480

            
481
path_is_within() {
482
  local child="$1"
483
  local parent="$2"
484
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
485
}
486

            
Bogdan Timofte authored a month ago
487
file_size_bytes_or_zero() {
488
  local file_path="$1"
489
  local file_size="0"
490

            
491
  file_size="$(stat -f%z "$file_path" 2>/dev/null || true)"
492
  if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
493
    file_size="$(stat -c%s "$file_path" 2>/dev/null || true)"
494
  fi
495

            
496
  if [[ "$file_size" =~ ^[0-9]+$ ]]; then
497
    printf '%s\n' "$file_size"
498
  else
499
    printf '0\n'
500
  fi
501
}
502

            
503
dir_available_bytes_or_zero() {
504
  local dir_path="$1"
505
  local avail_kb=""
506

            
507
  avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
508
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
509
    printf '%s\n' $((avail_kb * 1024))
510
  else
511
    printf '0\n'
512
  fi
513
}
514

            
515
staging_has_space_for_input() {
516
  local staging_dir="$1"
517
  local input_file="$2"
518
  local input_size_bytes=0
519
  local needed_bytes=0
520
  local available_bytes=0
521

            
522
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
523

            
524
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
525
  if [[ "$input_size_bytes" -le 0 ]]; then
526
    return 1
527
  fi
528

            
529
  # Keep one simple rule: need roughly 2x input size in staging.
530
  # This covers temp output plus metadata rewrite overhead.
531
  needed_bytes=$((input_size_bytes * 2))
532
  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
533
  [[ "$available_bytes" -ge "$needed_bytes" ]]
534
}
535

            
Bogdan Timofte authored a month ago
536
join_cmd_for_log() {
537
  local out=""
538
  local arg
539
  for arg in "$@"; do
540
    out+="$(printf '%q' "$arg") "
541
  done
542
  printf '%s\n' "${out% }"
543
}
544

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

            
659
  case "$MODE" in
660
    auto|hardware|quality|compat) ;;
661
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
662
  esac
663

            
664
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
665
    die "At least one of --source or --destination must be provided"
666
  fi
667

            
668
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
669
    die "--crf must be an integer"
670
  fi
Bogdan Timofte authored a month ago
671

            
672
  if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then
673
    if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then
674
      die "--debug-timing must be a positive integer"
675
    fi
676
    if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then
677
      die "--debug-timing must be greater than 0"
678
    fi
679
  fi
680

            
681
  if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then
682
    die "--staging-ramdisk-mb must be a positive integer"
683
  fi
684
  if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
685
    die "--staging-ramdisk-mb must be greater than 0"
686
  fi
Bogdan Timofte authored a month ago
687
}
688

            
689
check_tools() {
690
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
691
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
692
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
693

            
694
  HAS_AVCONVERT=false
695
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
696
    HAS_AVCONVERT=true
697
  fi
698
}
699

            
700
make_temp_repack_file() {
701
  local base_tmp="${TMPDIR:-/tmp}"
702
  local seed_path temp_path
703

            
704
  base_tmp="${base_tmp%/}"
705
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
706
  if [[ -n "$seed_path" ]]; then
707
    rm -f -- "$seed_path" 2>/dev/null || true
708
    temp_path="${seed_path}.mp4"
709
    printf '%s\n' "$temp_path"
710
    return
711
  fi
712

            
713
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
714
  printf '%s\n' "$temp_path"
715
}
716

            
717
cleanup_repacked_input() {
718
  local repacked_file="$1"
719
  if [[ -n "$repacked_file" ]]; then
720
    rm -f -- "$repacked_file" 2>/dev/null || true
721
  fi
722
}
723

            
724
try_apple_repack_for_unreadable() {
725
  local source_file="$1"
726
  local repacked_file=""
727

            
728
  REPACKED_SOURCE_PATH=""
729

            
730
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
731
    return 1
732
  fi
733

            
734
  repacked_file="$(make_temp_repack_file)"
735
  if [[ -z "$repacked_file" ]]; then
736
    return 1
737
  fi
738

            
739
  if [[ "$VERBOSE" == true ]]; then
740
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
741
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
742
      cleanup_repacked_input "$repacked_file"
743
      return 1
744
    fi
745
  else
746
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
747
      cleanup_repacked_input "$repacked_file"
748
      return 1
749
    fi
750
  fi
751

            
752
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
753
    REPACKED_SOURCE_PATH="$repacked_file"
754
    return 0
755
  fi
756

            
757
  cleanup_repacked_input "$repacked_file"
758
  return 1
Bogdan Timofte authored a month ago
759
}
760

            
761
restore_metadata_with_exiftool() {
762
  local input_file="$1"
763
  local output_file="$2"
764

            
765
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
766

            
767
  if [[ "$VERBOSE" == true ]]; then
768
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
769
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
770
      return 0
771
    fi
772
  else
773
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
774
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
775
      return 0
776
    fi
777
  fi
778

            
Bogdan Timofte authored a month ago
779
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
780
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
781
    return 1
782
  fi
783

            
Bogdan Timofte authored a month ago
784
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
785
  return 1
786
}
787

            
788
map_garmin_model_to_standard_tags() {
789
  local input_file="$1"
790
  local output_file="$2"
791
  local garmin_model
792

            
793
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
794
  if [[ -z "$garmin_model" ]]; then
795
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
796
    return 0
797
  fi
798

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

            
803
  if [[ "$VERBOSE" == true ]]; then
804
    if exiftool -overwrite_original -m \
805
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
806
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
807
      -Keys:CompatibleBrands="isom, iso2, mp41" \
808
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
809
      -Make="Garmin" -Model="Varia RCT715" \
810
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
811
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
812
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
813
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
814
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
815
      "$output_file"; then
816
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
817
      return 0
818
    fi
819
  else
820
    if exiftool -overwrite_original -m -q -q \
821
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
822
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
823
      -Keys:CompatibleBrands="isom, iso2, mp41" \
824
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
825
      -Make="Garmin" -Model="Varia RCT715" \
826
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
827
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
828
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
829
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
830
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
831
      "$output_file" >/dev/null 2>&1; then
832
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
833
      return 0
834
    fi
835
  fi
836

            
837
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
838
  return 1
839
}
840

            
841
write_transcode_encoder_metadata() {
842
  local output_file="$1"
843
  local encoder_meta
844

            
845
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
846

            
847
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
848

            
849
  if [[ "$VERBOSE" == true ]]; then
850
    if exiftool -overwrite_original -m \
851
      -Software="$encoder_meta" \
852
      -UserData:Software="$encoder_meta" \
853
      "$output_file"; then
854
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
855
      return 0
856
    fi
857
  else
858
    if exiftool -overwrite_original -m -q -q \
859
      -Software="$encoder_meta" \
860
      -UserData:Software="$encoder_meta" \
861
      "$output_file" >/dev/null 2>&1; then
862
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
863
      return 0
864
    fi
865
  fi
866

            
867
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
868
  return 1
Bogdan Timofte authored a month ago
869
}
870

            
871
detect_encoders() {
872
  local encoders
873
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
874

            
875
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
876
    HAS_VIDEOTOOLBOX=true
877
  fi
878
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
879
    HAS_LIBX265=true
880
  fi
881
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
882
    HAS_LIBX264=true
883
  fi
884

            
885
  if [[ "$VERBOSE" == true ]]; then
886
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
887
  fi
888
}
889

            
890
resolve_encoder() {
891
  local os_name
892
  os_name="$(uname -s)"
893

            
894
  case "$MODE" in
895
    auto)
896
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
897
        ENCODER_KIND="hardware"
898
      elif [[ "$HAS_LIBX265" == true ]]; then
899
        ENCODER_KIND="quality"
900
      elif [[ "$HAS_LIBX264" == true ]]; then
901
        ENCODER_KIND="compat"
902
      else
903
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
904
      fi
905
      ;;
906
    hardware)
907
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
908
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
909
      ENCODER_KIND="hardware"
910
      ;;
911
    quality)
912
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
913
      ENCODER_KIND="quality"
914
      ;;
915
    compat)
916
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
917
      ENCODER_KIND="compat"
918
      ;;
919
  esac
920

            
921
  case "$ENCODER_KIND" in
922
    hardware)
923
      VIDEO_CODEC="hevc_videotoolbox"
924
      VIDEO_CRF=""
925
      ;;
926
    quality)
927
      VIDEO_CODEC="libx265"
928
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
929
      ;;
930
    compat)
931
      VIDEO_CODEC="libx264"
932
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
933
      ;;
934
  esac
935

            
936
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
937
}
938

            
939
probe_has_audio() {
940
  local input_file="$1"
941
  local out
942
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
943
  [[ -n "$out" ]]
944
}
945

            
946
print_verbose_probe() {
947
  local input_file="$1"
948
  ffprobe -v error \
949
    -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 \
950
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
951
}
952

            
953
ffprobe_duration_or_empty() {
954
  local file_path="$1"
955
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
956
}
957

            
958
ffprobe_video_codec_or_empty() {
959
  local file_path="$1"
960
  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
961
}
962

            
Bogdan Timofte authored a month ago
963
source_video_is_readable() {
964
  local file_path="$1"
965
  REPACKED_SOURCE_PATH=""
966

            
967
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
968
    SOURCE_READABLE_MODE="normal"
969
    return 0
970
  fi
971

            
972
  if [[ "$VERBOSE" == true ]]; then
973
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
974
  fi
975

            
976
  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
977
    SOURCE_READABLE_MODE="tolerant"
978
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
979
    return 0
980
  fi
981

            
982
  if try_apple_repack_for_unreadable "$file_path"; then
983
    SOURCE_READABLE_MODE="repacked"
984
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
985
    return 0
986
  fi
987

            
988
  SOURCE_READABLE_MODE="normal"
989
  return 1
990
}
991

            
992
source_error_from_ffmpeg_log() {
993
  local ffmpeg_log="$1"
994
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
995
  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"
996
}
997

            
998
destination_cannot_accept_file() {
999
  local input_file="$1"
1000
  local out_dir="$2"
1001
  local ffmpeg_log="$3"
1002
  local probe_path=""
1003
  local avail_kb=""
1004
  local avail_bytes=0
1005

            
1006
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
1007
    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
1008
      return 0
1009
    fi
1010
  fi
1011

            
1012
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
1013
  if [[ -z "$probe_path" ]]; then
1014
    return 0
1015
  fi
1016
  rm -f -- "$probe_path" 2>/dev/null || true
1017

            
1018
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
1019
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
1020
    avail_bytes=$((avail_kb * 1024))
1021
    if [[ "$avail_bytes" -lt 67108864 ]]; then
1022
      return 0
1023
    fi
1024
  fi
1025

            
1026
  return 1
1027
}
1028

            
Bogdan Timofte authored a month ago
1029
validate_transcoded_output() {
1030
  local input_file="$1"
1031
  local output_file="$2"
1032

            
1033
  if [[ ! -f "$output_file" ]]; then
1034
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
1035
    return 1
1036
  fi
1037

            
1038
  local expected_codec actual_codec
1039
  case "$ENCODER_KIND" in
1040
    hardware|quality) expected_codec="hevc" ;;
1041
    compat) expected_codec="h264" ;;
1042
    *)
1043
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
1044
      return 1
1045
      ;;
1046
  esac
1047

            
1048
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
1049
  if [[ -z "$actual_codec" ]]; then
1050
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
1051
    return 1
1052
  fi
1053
  if [[ "$actual_codec" != "$expected_codec" ]]; then
1054
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
1055
    return 1
1056
  fi
1057

            
1058
  local in_duration out_duration duration_delta
1059
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1060
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1061

            
1062
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
1063
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
1064
    return 1
1065
  fi
1066

            
1067
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
1068
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
1069
    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)"
1070
    return 1
1071
  fi
1072

            
1073
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
1074
  return 0
1075
}
1076

            
1077
build_video_args() {
1078
  VIDEO_ARGS=()
1079

            
1080
  case "$ENCODER_KIND" in
1081
    hardware)
1082
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
1083
      ;;
1084
    quality)
1085
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
1086
      ;;
1087
    compat)
1088
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
1089
      ;;
1090
  esac
1091
}
1092

            
1093
normalize_source_dir() {
1094
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1095
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
1096
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1097
}
1098

            
1099
normalize_dest_dir() {
1100
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1101
}
1102

            
Bogdan Timofte authored a month ago
1103
# Sets globals: AUTO_CREATED_STAGING_RAMDISK, AUTO_CREATED_STAGING_PATH,
1104
# AUTO_CREATED_STAGING_DEV, STAGING_RAMDISK_CREATED_AT
1105
# Must be called directly (NOT inside $(...)) to preserve global assignments.
Bogdan Timofte authored a month ago
1106
create_missing_staging_ramdisk_if_needed() {
1107
  local staging_path="$1"
1108
  local ramdisk_name=""
1109
  local sectors=0
1110
  local dev=""
1111
  local mount_point=""
1112

            
1113
  staging_path="${staging_path%/}"
1114

            
1115
  if [[ -d "$staging_path" ]]; then
Bogdan Timofte authored a month ago
1116
    # Already exists; nothing to create.
Bogdan Timofte authored a month ago
1117
    return
1118
  fi
1119

            
1120
  if [[ "$(uname -s)" != "Darwin" ]]; then
1121
    return
1122
  fi
1123

            
1124
  case "$staging_path" in
1125
    /Volumes/*)
1126
      ;;
1127
    *)
1128
      return
1129
      ;;
1130
  esac
1131

            
1132
  ramdisk_name="$(basename "$staging_path")"
1133
  if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
1134
    return
1135
  fi
1136

            
1137
  if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1138
    # Only auto-create for top-level /Volumes/<Name>, not nested paths.
1139
    return
1140
  fi
1141

            
1142
  [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk"
1143
  [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk"
1144

            
1145
  sectors=$((STAGING_RAMDISK_MB * 2048))
1146
  vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)"
1147
  dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)"
1148
  if [[ -z "$dev" ]]; then
1149
    return
1150
  fi
1151

            
1152
  if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
1153
    /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1154
    return
1155
  fi
1156

            
1157
  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
1158
  if [[ -z "$mount_point" ]]; then
1159
    mount_point="$staging_path"
Bogdan Timofte authored a month ago
1160
  fi
1161

            
Bogdan Timofte authored a month ago
1162
  if [[ -d "$mount_point" ]]; then
Bogdan Timofte authored a month ago
1163
    AUTO_CREATED_STAGING_RAMDISK=true
Bogdan Timofte authored a month ago
1164
    AUTO_CREATED_STAGING_PATH="$mount_point"
1165
    AUTO_CREATED_STAGING_DEV="$dev"
Bogdan Timofte authored a month ago
1166
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1167
  fi
1168
}
1169

            
1170
attempt_unmount_auto_staging_ramdisk() {
1171
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then
1172
    return
1173
  fi
1174

            
Bogdan Timofte authored a month ago
1175
  if [[ "$AUTO_STAGING_CLEANED_UP" == true ]]; then
1176
    return
1177
  fi
1178
  AUTO_STAGING_CLEANED_UP=true
1179

            
Bogdan Timofte authored a month ago
1180
  if [[ "$(uname -s)" != "Darwin" ]]; then
1181
    return
1182
  fi
1183

            
Bogdan Timofte authored a month ago
1184
  local target="${AUTO_CREATED_STAGING_DEV:-$AUTO_CREATED_STAGING_PATH}"
1185
  if /usr/bin/hdiutil detach "$target" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
1186
    log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH"
1187
    return
1188
  fi
1189

            
1190
  if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1191
    log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH"
1192
  else
Bogdan Timofte authored a month ago
1193
    log_msg "INFO" "Auto-created staging RAM disk already gone: $AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1194
  fi
1195
}
1196

            
Bogdan Timofte authored a month ago
1197
cleanup_on_exit() {
1198
  local rc=$?
1199
  attempt_unmount_auto_staging_ramdisk
1200
  return "$rc"
1201
}
1202

            
Bogdan Timofte authored a month ago
1203
normalize_staging_dir() {
1204
  if [[ "$STAGING_PROVIDED" != true ]]; then
1205
    STAGING_DIR=""
1206
    return
1207
  fi
1208

            
1209
  STAGING_DIR="$(to_abs_path "$STAGING_DIR")"
Bogdan Timofte authored a month ago
1210
  if [[ ! -d "$STAGING_DIR" ]]; then
Bogdan Timofte authored a month ago
1211
    create_missing_staging_ramdisk_if_needed "$STAGING_DIR"
1212
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1213
      STAGING_DIR="$AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1214
      log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1215
    else
1216
      die "Staging directory not found: $STAGING_DIR"
1217
    fi
1218
  fi
Bogdan Timofte authored a month ago
1219
  [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR"
1220
  STAGING_DIR="$(cd "$STAGING_DIR" && pwd)"
1221
  [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR"
1222

            
1223
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1224
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1225
  fi
1226
}
1227

            
Bogdan Timofte authored a month ago
1228
collect_extensions() {
1229
  local raw="$EXTENSIONS_CSV"
1230
  local token
1231
  EXT_LIST=()
1232

            
1233
  IFS=',' read -r -a tokens <<< "$raw"
1234
  for token in "${tokens[@]}"; do
1235
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
1236
    token="${token#.}"
1237
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
1238
    [[ -n "$token" ]] && EXT_LIST+=("$token")
1239
  done
1240

            
1241
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1242
    die "No valid extensions after parsing --extensions"
1243
  fi
1244
}
1245

            
1246
build_find_expr_for_extensions() {
1247
  FIND_EXT_EXPR=()
1248
  local ext
1249
  for ext in "${EXT_LIST[@]}"; do
1250
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
1251
  done
1252
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
1253
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
1254
  fi
1255
}
1256

            
1257
rel_path_from_source() {
1258
  local abs_file="$1"
1259
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1260
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1261
  else
1262
    printf '%s\n' "$(basename "$abs_file")"
1263
  fi
1264
}
1265

            
1266
collect_video_files() {
1267
  VIDEO_FILES=()
1268

            
1269
  if [[ -n "$SINGLE_FILE" ]]; then
1270
    local single_abs
1271
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1272
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1273
    VIDEO_FILES+=("$single_abs")
1274
    return
1275
  fi
1276

            
1277
  build_find_expr_for_extensions
1278

            
1279
  while IFS= read -r -d '' file; do
Bogdan Timofte authored a month ago
1280
    if is_apple_noise_file "$file"; then
1281
      vlog_msg "SKIP" "Ignoring Apple artifact: $file"
1282
      continue
1283
    fi
Bogdan Timofte authored a month ago
1284
    VIDEO_FILES+=("$file")
1285
  done < <(
1286
    if [[ "$RECURSIVE" == true ]]; then
1287
      find "$SOURCE_DIR" \
1288
        -path "$DEST_DIR" -prune -o \
1289
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1290
    else
1291
      find "$SOURCE_DIR" \
1292
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1293
    fi
1294
  )
1295
}
1296

            
1297
process_video_file() {
1298
  local input_file="$1"
Bogdan Timofte authored a month ago
1299
  local rel_path output_file temp_output_file out_dir
1300
  local display_path
1301
  local encode_input_file
Bogdan Timofte authored a month ago
1302
  local preferred_temp_dir=""
Bogdan Timofte authored a month ago
1303
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1304
  local input_size_bytes=0 output_size_bytes=0
Bogdan Timofte authored a month ago
1305
  local file_started_at file_ended_at file_real_elapsed_sec
1306
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1307
  local post_elapsed_sec
1308

            
1309
  file_started_at="$(date +%s)"
1310
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1311

            
1312
  rel_path="$(rel_path_from_source "$input_file")"
1313
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1314
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1315
  display_path="${input_file#$PWD/}"
1316
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1317

            
1318
  mkdir -p "$out_dir"
1319

            
Bogdan Timofte authored a month ago
1320
  if ! source_video_is_readable "$input_file"; then
1321
    ERRORS=$((ERRORS + 1))
1322
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1323
    if [[ "$VERBOSE" == true ]]; then
1324
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1325
    else
1326
      log_progress_start "$display_path"
1327
      log_progress_skipped_unreadable "$display_path"
1328
    fi
1329
    return 2
1330
  fi
1331

            
1332
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1333
    repacked_input_file="$REPACKED_SOURCE_PATH"
1334
    encode_input_file="$REPACKED_SOURCE_PATH"
1335
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1336
  fi
1337

            
Bogdan Timofte authored a month ago
1338
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1339
    vlog_msg "SKIP" "Video exists: $output_file"
1340
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1341
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1342
    return
1343
  fi
1344

            
1345
  local has_audio=false
Bogdan Timofte authored a month ago
1346
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1347
    has_audio=true
1348
  fi
1349

            
1350
  if [[ "$VERBOSE" == true ]]; then
1351
    log_msg "INFO" "ffprobe summary: $input_file"
1352
    print_verbose_probe "$input_file"
1353
    log_msg "INFO" "Audio detected: $has_audio"
1354
  fi
1355

            
1356
  build_video_args
1357

            
Bogdan Timofte authored a month ago
1358
  preferred_temp_dir="$STAGING_DIR"
1359
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then
1360
    if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then
1361
      preferred_temp_dir=""
1362
      log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file"
1363
    fi
1364
  fi
1365

            
1366
  temp_output_file="$(make_temp_output_file "$output_file" "$preferred_temp_dir")"
Bogdan Timofte authored a month ago
1367
  if [[ -z "$temp_output_file" ]]; then
1368
    ERRORS=$((ERRORS + 1))
1369
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1370
    log_msg "ERROR" "Could not create temporary output file: $output_file"
1371
    return 3
1372
  fi
Bogdan Timofte authored a month ago
1373
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then
Bogdan Timofte authored a month ago
1374
    log_msg "WARN" "Staging unavailable for temp output; using destination directory: $temp_output_file"
1375
  fi
Bogdan Timofte authored a month ago
1376

            
Bogdan Timofte authored a month ago
1377
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1378
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1379
    cmd+=( -fflags +genpts -err_detect ignore_err )
1380
  fi
Bogdan Timofte authored a month ago
1381
  if [[ "$OVERWRITE" == true ]]; then
1382
    cmd+=( -y )
1383
  else
1384
    cmd+=( -n )
1385
  fi
1386

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

            
1389
  if [[ "$has_audio" == true ]]; then
1390
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1391
  fi
1392

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

            
1395
  if [[ "$VERBOSE" == true ]]; then
1396
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1397
  fi
1398

            
1399
  if [[ "$DRY_RUN" == true ]]; then
1400
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1401
    return 0
1402
  fi
1403

            
Bogdan Timofte authored a month ago
1404
  if [[ "$VERBOSE" != true ]]; then
1405
    log_progress_start "$display_path"
1406
  fi
1407

            
1408
  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
Bogdan Timofte authored a month ago
1409
  if [[ "$FIRST_ENCODE_STARTED_AT" -eq 0 ]]; then
1410
    local startup_warmup_sec=0
1411
    local warmup_elapsed_sec=0
1412
    FIRST_ENCODE_STARTED_AT="$(date +%s)"
1413
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 ]]; then
1414
      warmup_elapsed_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1415
      if [[ "$warmup_elapsed_sec" -lt 0 ]]; then
1416
        warmup_elapsed_sec=0
1417
      fi
1418
    fi
1419
  fi
Bogdan Timofte authored a month ago
1420
  encode_started_at="$(date +%s)"
1421
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
1422

            
1423
  local ffmpeg_rc=0
Bogdan Timofte authored a month ago
1424
  local ffmpeg_log=""
Bogdan Timofte authored a month ago
1425
  if [[ "$VERBOSE" == true ]]; then
1426
    # Verbose: show ffmpeg output directly
Bogdan Timofte authored a month ago
1427
    if run_ffmpeg_with_signal_guard "${cmd[@]}"; then
Bogdan Timofte authored a month ago
1428
      :
1429
    else
1430
      ffmpeg_rc=$?
1431
    fi
1432
  else
1433
    # Quiet (default): redirect ffmpeg output; keep log on failure
1434
    ffmpeg_log="$(make_temp_log_file)"
1435
    if [[ -z "$ffmpeg_log" ]]; then
1436
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1437
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored a month ago
1438
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
Bogdan Timofte authored a month ago
1439
      return 3
Bogdan Timofte authored a month ago
1440
    fi
Bogdan Timofte authored a month ago
1441
    if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
Bogdan Timofte authored a month ago
1442
      rm -f "$ffmpeg_log"
1443
    else
1444
      ffmpeg_rc=$?
1445
    fi
1446
  fi
1447

            
Bogdan Timofte authored a month ago
1448
  encode_ended_at="$(date +%s)"
1449
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1450
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1451

            
1452
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1453
    # Ctrl+C reached ffmpeg directly (same process group); it stopped mid-encode.
1454
    # Treat any user-triggered abort as a clean stop — no error counters, no noise.
1455
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1456
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1457
      cleanup_repacked_input "$repacked_input_file"
1458
      return 4
1459
    fi
1460

            
Bogdan Timofte authored a month ago
1461
    local failure_rc=1
1462
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1463
      failure_rc=3
1464
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1465
      failure_rc=2
1466
    fi
1467

            
1468
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1469
    file_ended_at="$(date +%s)"
1470
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1471
    local encode_elapsed_fmt
1472
    local real_elapsed_fmt
1473
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1474
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1475

            
Bogdan Timofte authored a month ago
1476
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1477
    if [[ "$failure_rc" -eq 2 ]]; then
1478
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1479
    elif [[ "$failure_rc" -eq 3 ]]; then
1480
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1481
    fi
Bogdan Timofte authored a month ago
1482
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1483
      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
1484
    else
Bogdan Timofte authored a month ago
1485
      log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log"
Bogdan Timofte authored a month ago
1486
    fi
Bogdan Timofte authored a month ago
1487
    if [[ "$failure_rc" -eq 3 ]]; then
1488
      log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1489
    fi
1490
    cleanup_repacked_input "$repacked_input_file"
1491
    return "$failure_rc"
Bogdan Timofte authored a month ago
1492
  fi
1493

            
Bogdan Timofte authored a month ago
1494
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1495

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

            
Bogdan Timofte authored a month ago
1508
  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1509
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1510
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1511
      cleanup_repacked_input "$repacked_input_file"
1512
      return 4
1513
    fi
Bogdan Timofte authored a month ago
1514
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1515
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1516
    cleanup_repacked_input "$repacked_input_file"
1517
    return 3
Bogdan Timofte authored a month ago
1518
  fi
1519

            
Bogdan Timofte authored a month ago
1520
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1521
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1522
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1523
      cleanup_repacked_input "$repacked_input_file"
1524
      return 4
1525
    fi
Bogdan Timofte authored a month ago
1526
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1527
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1528
    cleanup_repacked_input "$repacked_input_file"
1529
    return 3
Bogdan Timofte authored a month ago
1530
  fi
Bogdan Timofte authored a month ago
1531

            
1532
  if [[ "$MOVE_SOURCE" == true ]]; then
Bogdan Timofte authored a month ago
1533
    if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then
1534
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1535
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1536
      if destination_cannot_accept_file "$input_file" "$out_dir" ""; then
1537
        DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1538
        cleanup_repacked_input "$repacked_input_file"
1539
        return 3
1540
      fi
1541
      # Validation failed but destination is healthy: the source or encoder produced
1542
      # a corrupt/truncated output. Treat as source error so --keep-going can skip it.
Bogdan Timofte authored a month ago
1543
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
Bogdan Timofte authored a month ago
1544
      log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file"
Bogdan Timofte authored a month ago
1545
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1546
      return 2
Bogdan Timofte authored a month ago
1547
    fi
1548
  fi
1549

            
Bogdan Timofte authored a month ago
1550
  touch -r "$input_file" "$temp_output_file" || true
1551

            
1552
  if [[ "$OVERWRITE" == true ]]; then
1553
    if ! mv -f "$temp_output_file" "$output_file"; then
1554
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1555
      ERRORS=$((ERRORS + 1))
1556
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1557
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1558
      cleanup_repacked_input "$repacked_input_file"
1559
      return 3
1560
    fi
1561
  else
1562
    if ! mv -n "$temp_output_file" "$output_file"; then
1563
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1564
      ERRORS=$((ERRORS + 1))
1565
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1566
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1567
      cleanup_repacked_input "$repacked_input_file"
1568
      return 3
1569
    fi
1570
  fi
Bogdan Timofte authored a month ago
1571

            
Bogdan Timofte authored a month ago
1572
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1573
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1574
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1575
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1576

            
Bogdan Timofte authored a month ago
1577
  if [[ "$MOVE_SOURCE" == true ]]; then
1578
    if rm -f "$input_file"; then
1579
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
1580
    else
1581
      ERRORS=$((ERRORS + 1))
1582
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
Bogdan Timofte authored a month ago
1583
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1584
      return 1
1585
    fi
1586
  fi
1587

            
Bogdan Timofte authored a month ago
1588
  file_ended_at="$(date +%s)"
1589
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1590
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1591
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1592
    post_elapsed_sec=0
1593
  fi
1594
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1595
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1596

            
Bogdan Timofte authored a month ago
1597
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1598

            
1599
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1600
    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
1601
  else
Bogdan Timofte authored a month ago
1602
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1603
  fi
1604

            
Bogdan Timofte authored a month ago
1605
  cleanup_repacked_input "$repacked_input_file"
1606

            
Bogdan Timofte authored a month ago
1607
  return 0
1608
}
1609

            
1610
main() {
1611
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1612
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1613
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1614

            
Bogdan Timofte authored a month ago
1615
  trap 'handle_interrupt' INT TERM
Bogdan Timofte authored a month ago
1616
  trap 'cleanup_on_exit' EXIT
Bogdan Timofte authored a month ago
1617

            
Bogdan Timofte authored a month ago
1618
  parse_args "$@"
1619
  check_tools
1620

            
1621
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1622
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1623
    SINGLE_FILE="$SOURCE_DIR"
1624
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1625
  fi
1626

            
1627
  normalize_source_dir
1628
  normalize_dest_dir
Bogdan Timofte authored a month ago
1629
  normalize_staging_dir
Bogdan Timofte authored a month ago
1630
  collect_extensions
1631

            
1632
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1633
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1634
  fi
1635

            
1636
  detect_encoders
1637
  resolve_encoder
1638

            
1639
  collect_video_files
Bogdan Timofte authored a month ago
1640
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1641
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1642
  fi
1643

            
1644
  local f
Bogdan Timofte authored a month ago
1645
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
Bogdan Timofte authored a month ago
1646
    check_for_quit_key
Bogdan Timofte authored a month ago
1647
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1648
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1649
      break
1650
    fi
Bogdan Timofte authored a month ago
1651

            
1652
    local process_rc=0
1653
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1654
      process_rc=0
Bogdan Timofte authored a month ago
1655
    else
1656
      process_rc=$?
1657
    fi
1658

            
Bogdan Timofte authored a month ago
1659
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1660
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
1661
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
1662
        DEBUG_TIMING_STOPPED=true
1663
      fi
1664
    fi
1665

            
1666
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1667
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
1668
      break
1669
    fi
1670

            
1671
    if [[ "$process_rc" -eq 0 ]]; then
1672
      continue
1673
    fi
1674

            
Bogdan Timofte authored a month ago
1675
    case "$process_rc" in
1676
      2)
Bogdan Timofte authored a month ago
1677
        if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1678
          log_msg "INFO" "Stopped by user after current file"
1679
          break
1680
        fi
Bogdan Timofte authored a month ago
1681
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1682
        continue
1683
        ;;
1684
      3)
1685
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1686
        break
1687
        ;;
1688
      4)
1689
        log_msg "INFO" "Stopped by user after current file"
1690
        break
1691
        ;;
1692
      *)
1693
        if [[ "$FAIL_FAST" == true ]]; then
1694
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1695
          break
1696
        fi
1697
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1698
        continue
1699
        ;;
1700
    esac
Bogdan Timofte authored a month ago
1701
  done
1702

            
1703
  total_ended_at="$(date +%s)"
1704
  total_elapsed_sec=$((total_ended_at - total_started_at))
1705
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
1706

            
1707
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
1708
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
1709
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
1710
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
Bogdan Timofte authored a month ago
1711
  local startup_warmup_sec=-1 startup_warmup_fmt="n/a"
1712
  local staging_warmup_sec=-1 staging_warmup_fmt="n/a"
Bogdan Timofte authored a month ago
1713

            
Bogdan Timofte authored a month ago
1714
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
1715
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
1716
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
1717
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
1718
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
1719
  fi
1720

            
1721
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
1722
  if [[ "$file_post_total_sec" -lt 0 ]]; then
1723
    file_post_total_sec=0
1724
  fi
1725
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
1726

            
1727
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
1728
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
1729
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
1730
  fi
Bogdan Timofte authored a month ago
1731
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
1732

            
Bogdan Timofte authored a month ago
1733
  if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1734
    startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT))
1735
    if [[ "$startup_warmup_sec" -lt 0 ]]; then
1736
      startup_warmup_sec=0
1737
    fi
1738
    startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")"
1739
  fi
1740

            
1741
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1742
    staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1743
    if [[ "$staging_warmup_sec" -lt 0 ]]; then
1744
      staging_warmup_sec=0
1745
    fi
1746
    staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")"
1747
  fi
1748

            
Bogdan Timofte authored a month ago
1749
  print_final_report \
1750
    "$total_elapsed_sec" \
1751
    "$total_elapsed_fmt" \
1752
    "$file_post_total_sec" \
1753
    "$file_post_total_fmt" \
1754
    "$run_non_file_overhead_sec" \
1755
    "$run_non_file_overhead_fmt" \
1756
    "$avg_file_real_time_sec" \
1757
    "$avg_file_real_time_fmt" \
1758
    "$avg_video_time_sec" \
Bogdan Timofte authored a month ago
1759
    "$avg_video_time_fmt" \
1760
    "$startup_warmup_sec" \
1761
    "$startup_warmup_fmt" \
1762
    "$staging_warmup_sec" \
1763
    "$staging_warmup_fmt" \
1764
    "$INPUT_BYTES_PROCESSED" \
1765
    "$OUTPUT_BYTES_PROCESSED"
1766

            
Bogdan Timofte authored a month ago
1767
  if [[ "$ERRORS" -gt 0 ]]; then
1768
    exit 1
1769
  fi
1770
}
1771

            
1772
main "$@"