Showing 3 changed files with 403 additions and 46 deletions
+5 -0
CHANGELOG.md
@@ -10,11 +10,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10 10
 ### Changed
11 11
 - `--move-source` renamed to `--delete-source` (old name kept as alias)
12 12
 - `--continue-on-error` renamed to `--keep-going` (old name kept as alias)
13
+- `--unattended` automatically enables input staging when `--staging-dir` is set
14
+- Source and destination preflight checks now fail before staging/scanning when
15
+	source or destination NFS/autofs mounts are unreadable or unwritable
13 16
 ### Added
14 17
 - `--unattended` preset (`--delete-source` + `--keep-going`) for long unattended runs
15 18
 - `--staging-dir DIR` to place intermediate transcoding temp files on a fast local path,
16 19
 	with automatic fallback to destination temp files when staging is unavailable
17 20
 	or lacks sufficient free space for the current file
21
+- `--stage-input` to copy each source video to staging before encoding, which avoids
22
+	long ffmpeg reads directly from flaky NAS/autofs mounts
18 23
 - automatic RAM disk creation on macOS when `--staging-dir` points to a missing
19 24
 	`/Volumes/<Name>` path, configurable with `--staging-ramdisk-mb N`
20 25
 - `--debug-timing N` to stop after N video files and print timing statistics for quick profiling
+12 -6
README.md
@@ -24,12 +24,15 @@ optimized for Apple Photos / QuickTime compatibility.
24 24
 # Transcode + delete originals after validation
25 25
 ./garmin_varia_transcode.sh -s SampleFootage -d Output --delete-source
26 26
 
27
-# Long unattended run preset (delete-source + keep-going)
27
+# Long unattended run preset (delete-source + keep-going; also stages input when --staging-dir is set)
28 28
 ./garmin_varia_transcode.sh -s SampleFootage -d Output --unattended
29 29
 
30 30
 # NFS destination with local staging for temp outputs
31 31
 ./garmin_varia_transcode.sh -s SampleFootage -d ~/Autofs/xdev/is-baobab/nvme0n1/@backup/Garmin --staging-dir /tmp/varia_staging
32 32
 
33
+# Flaky NAS/autofs source: copy each source clip to local staging before encode
34
+./garmin_varia_transcode.sh -s ~/Autofs/xdev/autonas/ext01/@Camera/import -d ~/Autofs/xdev/autonas/ext00/@Garmin --unattended --staging-dir /Volumes/VARIA_RAM --staging-ramdisk-mb 512 --stage-input
35
+
33 36
 # Timing debug: process first 20 files then stop and print final statistics
34 37
 ./garmin_varia_transcode.sh -s SampleFootage -d Output --debug-timing 20
35 38
 
@@ -57,6 +60,8 @@ Options:
57 60
   --staging-dir DIR               Temporary staging directory for intermediate output files
58 61
                                   Falls back to destination temp files if staging is unavailable
59 62
                                   or does not have enough free space for current file
63
+  --stage-input                   Copy each source video to staging before encoding; useful
64
+                                  for flaky NAS/autofs source reads
60 65
   --staging-ramdisk-mb N          RAM disk size in MB when auto-creating missing /Volumes staging
61 66
                                   directory on macOS (default: 8192)
62 67
   --debug-timing N                Process at most N video files, then stop and print timing stats
@@ -94,15 +99,16 @@ Options:
94 99
 ## Safety
95 100
 
96 101
 - Destination inside source is rejected with an error
102
+- Source readability and destination writability are checked before staging or
103
+  scanning, so stale/inaccessible NFS/autofs mounts fail with an explicit error
97 104
 - `--delete-source` only deletes source after codec + duration validation passes
98 105
 - `--dry-run` never writes files
99 106
 - `Ctrl+C` behavior during long runs:
100 107
   first press requests stop after current file; second press force-stops current encode
101
- - Graceful quit between files:
102
-   Press `q` (or `Q`) between files to request a graceful stop before the next
103
-   file begins. The program consumes the keypress (it is not echoed) so you can
104
-   press `q` while an encode is running and it will be honored once that encode
105
-   finishes.
108
+- Graceful quit between files:
109
+  press `q` (or `Q`) during interactive runs to request a graceful stop before
110
+  the next file begins. `--unattended` disables `q`/`Q` terminal polling so
111
+  buffered terminal input cannot stop long runs accidentally; use `Ctrl+C`.
106 112
 - On macOS, unreadable MP4/MOV sources automatically try `avconvert --preset PresetPassthrough`
107 113
   as fallback before being marked as unreadable
108 114
 
+386 -40
garmin_varia_transcode.sh
@@ -1,5 +1,5 @@
1 1
 #!/usr/bin/env bash
2
-set -euo pipefail
2
+set -Eeuo pipefail
3 3
 
4 4
 # Garmin Varia batch transcoder (macOS/Linux)
5 5
 #
@@ -19,6 +19,7 @@ DEFAULT_EXTENSIONS="mp4,mov,avi,m4v"
19 19
 DEFAULT_CRF_HEVC=20
20 20
 DEFAULT_CRF_H264=19
21 21
 DEFAULT_STAGING_RAMDISK_MB=8192
22
+DEFAULT_PREFLIGHT_TIMEOUT_SEC=10
22 23
 
23 24
 # Garmin publicly declares a 140-degree field of view.
24 25
 # We infer practical focal metadata from this single spec.
@@ -39,14 +40,17 @@ RECURSIVE=true
39 40
 SINGLE_FILE=""
40 41
 VERBOSE=false
41 42
 MOVE_SOURCE=false
43
+UNATTENDED=false
42 44
 SOURCE_PROVIDED=false
43 45
 DEST_PROVIDED=false
44 46
 STAGING_PROVIDED=false
47
+STAGE_INPUT=false
45 48
 STAGING_RAMDISK_MB="$DEFAULT_STAGING_RAMDISK_MB"
46 49
 AUTO_CREATED_STAGING_RAMDISK=false
47 50
 AUTO_CREATED_STAGING_PATH=""
48 51
 AUTO_CREATED_STAGING_DEV=""
49 52
 STAGING_RAMDISK_CREATED_AT=0
53
+STAGING_RAMDISK_CREATE_ERROR=""
50 54
 AUTO_STAGING_CLEANED_UP=false
51 55
 RUN_STARTED_AT=0
52 56
 FIRST_ENCODE_STARTED_AT=0
@@ -57,6 +61,8 @@ FAIL_FAST=true
57 61
 SOURCE_READABLE_MODE="normal"
58 62
 APPLE_REPACK_FALLBACK=true
59 63
 REPACKED_SOURCE_PATH=""
64
+STAGED_INPUT_PATH=""
65
+STAGED_INPUT_FAILURE_RC=0
60 66
 
61 67
 HAS_VIDEOTOOLBOX=false
62 68
 HAS_LIBX265=false
@@ -82,10 +88,13 @@ INPUT_BYTES_PROCESSED=0
82 88
 OUTPUT_BYTES_PROCESSED=0
83 89
 
84 90
 DURATION_TOLERANCE_SEC=1.0
91
+PREFLIGHT_TIMEOUT_SEC="$DEFAULT_PREFLIGHT_TIMEOUT_SEC"
85 92
 STOP_AFTER_CURRENT=false
86 93
 INTERRUPT_COUNT=0
87 94
 CURRENT_FFMPEG_PID=""
88 95
 PROGRESS_LINE_OPEN=false
96
+FINAL_REPORT_PRINTED=false
97
+MAIN_FLOW_STARTED=false
89 98
 
90 99
 usage() {
91 100
   cat <<'EOF'
@@ -97,6 +106,8 @@ Options:
97 106
   -d, --destination, --output DIR Destination directory (default: current directory)
98 107
   --staging-dir DIR               Temporary staging directory for intermediate output files
99 108
                                   (falls back to destination if staging cannot be used)
109
+  --stage-input                   Copy each source video to the staging directory before
110
+                                  encoding; useful for flaky NAS/autofs source reads
100 111
   --staging-ramdisk-mb N          RAM disk size in MB when auto-creating missing /Volumes staging
101 112
                                   directory on macOS (default: 8192)
102 113
   --debug-timing N                Process at most N video files, then stop and print timing stats
@@ -114,6 +125,14 @@ Options:
114 125
   --apple-repack-fallback         Enable macOS avconvert fallback (default)
115 126
   -h, --help                      Show this help
116 127
 
128
+Run Controls:
129
+  q / Q      Request graceful stop before the next file (interactive runs only)
130
+  Ctrl+C     Request stop after the current encode finishes
131
+  Ctrl+C x2  Force-stop the current encode
132
+
133
+  Note: --unattended disables q/Q terminal polling so long runs cannot be
134
+  stopped accidentally by buffered terminal input. Use Ctrl+C for unattended.
135
+
117 136
 Encoding Modes:
118 137
   hardware   Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only.
119 138
              ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro).
@@ -163,6 +182,24 @@ log_msg() {
163 182
   echo "[$ts] [$level] $*"
164 183
 }
165 184
 
185
+handle_unexpected_error() {
186
+  local rc=$?
187
+  local line_no="${1:-unknown}"
188
+  local command_text="${2:-unknown}"
189
+
190
+  if [[ "$rc" -eq 0 ]]; then
191
+    return 0
192
+  fi
193
+
194
+  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
195
+    printf '\n'
196
+    PROGRESS_LINE_OPEN=false
197
+  fi
198
+
199
+  log_msg "ERROR" "Unexpected script failure at line $line_no (rc=$rc): $command_text"
200
+  return "$rc"
201
+}
202
+
166 203
 # Verbose-only log: suppressed in default quiet mode
167 204
 vlog_msg() {
168 205
   [[ "$VERBOSE" == true ]] && log_msg "$@"
@@ -258,22 +295,29 @@ handle_interrupt() {
258 295
 # -t 1 is a safety net for platforms where select() on a VMIN=0 fd does not
259 296
 # report readable-when-empty; in that case we block at most 1s then exit.
260 297
 check_for_quit_key() {
298
+  [[ "$UNATTENDED" == true ]] && return 0
299
+  [[ -t 0 ]] || return 0
261 300
   [[ -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
301
+  local key old_stty found=false tty_fd
302
+  if ! exec {tty_fd}</dev/tty 2>/dev/null; then
303
+    return 0
304
+  fi
305
+  old_stty=$(stty -g <&"$tty_fd" 2>/dev/null) || { exec {tty_fd}<&-; return 0; }
306
+  stty -echo -icanon min 0 time 0 <&"$tty_fd" 2>/dev/null \
307
+    || { stty "$old_stty" <&"$tty_fd" 2>/dev/null || true; exec {tty_fd}<&-; return 0; }
308
+  while IFS= read -r -s -n 1 -t 1 key <&"$tty_fd" 2>/dev/null; do
267 309
     if [[ "$key" == "q" || "$key" == "Q" ]]; then
268 310
       found=true
269 311
       break
270 312
     fi
271 313
   done
272
-  stty "$old_stty" </dev/tty 2>/dev/null || true
314
+  stty "$old_stty" <&"$tty_fd" 2>/dev/null || true
315
+  exec {tty_fd}<&-
273 316
   if [[ "$found" == true ]]; then
274 317
     STOP_AFTER_CURRENT=true
275 318
     log_msg "INFO" "Quit key pressed; stopping before next file"
276 319
   fi
320
+  return 0
277 321
 }
278 322
 
279 323
 run_ffmpeg_with_signal_guard() {
@@ -432,6 +476,31 @@ make_temp_output_file() {
432 476
   printf '%s\n' "$temp_path"
433 477
 }
434 478
 
479
+make_temp_staged_input_file() {
480
+  local source_file="$1"
481
+  local staging_dir="$2"
482
+  local source_base source_noext source_ext seed_path temp_path
483
+
484
+  source_base="$(basename "$source_file")"
485
+  source_noext="${source_base%.*}"
486
+  source_ext="$(file_extension_or_tmp "$source_file")"
487
+
488
+  seed_path="$(mktemp "$staging_dir/.varia_input.${source_noext}.XXXXXX" 2>/dev/null || true)"
489
+  if [[ -n "$seed_path" ]]; then
490
+    rm -f -- "$seed_path" 2>/dev/null || true
491
+    temp_path="${seed_path}.${source_ext}"
492
+  else
493
+    temp_path="$staging_dir/.varia_input.${source_noext}.$$.$RANDOM.${source_ext}"
494
+    if ! touch "$temp_path" >/dev/null 2>&1; then
495
+      temp_path=""
496
+    else
497
+      rm -f -- "$temp_path" 2>/dev/null || true
498
+    fi
499
+  fi
500
+
501
+  printf '%s\n' "$temp_path"
502
+}
503
+
435 504
 cleanup_transcode_artifacts() {
436 505
   local temp_output="$1"
437 506
   local final_output="$2"
@@ -447,6 +516,19 @@ cleanup_transcode_artifacts() {
447 516
   fi
448 517
 }
449 518
 
519
+cleanup_staged_input() {
520
+  local staged_input="$1"
521
+  [[ -n "$staged_input" ]] || return 0
522
+  rm -f -- "$staged_input" 2>/dev/null || true
523
+}
524
+
525
+cleanup_process_inputs() {
526
+  local staged_input="$1"
527
+  local repacked_input="$2"
528
+  cleanup_staged_input "$staged_input"
529
+  cleanup_repacked_input "$repacked_input"
530
+}
531
+
450 532
 is_apple_noise_file() {
451 533
   local file_path="$1"
452 534
   local base
@@ -484,6 +566,77 @@ path_is_within() {
484 566
   [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
485 567
 }
486 568
 
569
+run_with_timeout() {
570
+  local timeout_sec="$1"
571
+  shift
572
+  local pid elapsed=0 rc=0
573
+
574
+  "$@" &
575
+  pid=$!
576
+
577
+  while kill -0 "$pid" 2>/dev/null; do
578
+    if [[ "$elapsed" -ge "$timeout_sec" ]]; then
579
+      kill -TERM "$pid" 2>/dev/null || true
580
+      sleep 1
581
+      kill -KILL "$pid" 2>/dev/null || true
582
+      return 124
583
+    fi
584
+    sleep 1
585
+    elapsed=$((elapsed + 1))
586
+  done
587
+
588
+  wait "$pid" 2>/dev/null
589
+  rc=$?
590
+  return "$rc"
591
+}
592
+
593
+preflight_source_readable() {
594
+  local source_dir="$1"
595
+  local rc=0
596
+
597
+  log_msg "INFO" "Preflight: checking source is readable: $source_dir"
598
+  trap - ERR
599
+  set +e
600
+  run_with_timeout "$PREFLIGHT_TIMEOUT_SEC" bash -c '
601
+    source_dir="$1"
602
+    [[ -d "$source_dir" && -r "$source_dir" && -x "$source_dir" ]] || exit 10
603
+    find "$source_dir" -maxdepth 1 -print -quit >/dev/null 2>&1
604
+  ' _ "$source_dir"
605
+  rc=$?
606
+  set -e
607
+  trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR
608
+  if [[ "$rc" -ne 0 ]]; then
609
+    if [[ "$rc" -eq 124 ]]; then
610
+      die "Source is not readable within ${PREFLIGHT_TIMEOUT_SEC}s, likely stale/inaccessible NFS/autofs mount: $source_dir"
611
+    fi
612
+    die "Source is not readable or searchable: $source_dir"
613
+  fi
614
+}
615
+
616
+preflight_destination_writable() {
617
+  local dest_dir="$1"
618
+  local rc=0
619
+
620
+  log_msg "INFO" "Preflight: checking destination is writable: $dest_dir"
621
+  trap - ERR
622
+  set +e
623
+  run_with_timeout "$PREFLIGHT_TIMEOUT_SEC" bash -c '
624
+    dest_dir="$1"
625
+    mkdir -p "$dest_dir" >/dev/null 2>&1 || exit 10
626
+    probe="$(mktemp "$dest_dir/.varia_write_probe.XXXXXX" 2>/dev/null)" || exit 11
627
+    rm -f -- "$probe" >/dev/null 2>&1 || exit 12
628
+  ' _ "$dest_dir"
629
+  rc=$?
630
+  set -e
631
+  trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR
632
+  if [[ "$rc" -ne 0 ]]; then
633
+    if [[ "$rc" -eq 124 ]]; then
634
+      die "Destination is not writable within ${PREFLIGHT_TIMEOUT_SEC}s, likely stale/inaccessible NFS/autofs mount: $dest_dir"
635
+    fi
636
+    die "Destination is not writable: $dest_dir"
637
+  fi
638
+}
639
+
487 640
 file_size_bytes_or_zero() {
488 641
   local file_path="$1"
489 642
   local file_size="0"
@@ -500,6 +653,18 @@ file_size_bytes_or_zero() {
500 653
   fi
501 654
 }
502 655
 
656
+file_extension_or_tmp() {
657
+  local file_path="$1"
658
+  local base ext
659
+  base="$(basename "$file_path")"
660
+  ext="${base##*.}"
661
+  if [[ "$base" == "$ext" || -z "$ext" ]]; then
662
+    printf 'tmp\n'
663
+  else
664
+    printf '%s\n' "$ext"
665
+  fi
666
+}
667
+
503 668
 dir_available_bytes_or_zero() {
504 669
   local dir_path="$1"
505 670
   local avail_kb=""
@@ -533,6 +698,28 @@ staging_has_space_for_input() {
533 698
   [[ "$available_bytes" -ge "$needed_bytes" ]]
534 699
 }
535 700
 
701
+staging_has_space_for_input_copy() {
702
+  local staging_dir="$1"
703
+  local input_file="$2"
704
+  local input_size_bytes=0
705
+  local needed_bytes=0
706
+  local available_bytes=0
707
+
708
+  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
709
+
710
+  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
711
+  if [[ "$input_size_bytes" -le 0 ]]; then
712
+    return 1
713
+  fi
714
+
715
+  # Input staging needs room for the source copy plus the output and metadata
716
+  # rewrite temp files. Garmin 30s clips are usually fixed-size, so 3x is a
717
+  # conservative and easy-to-explain guard.
718
+  needed_bytes=$((input_size_bytes * 3))
719
+  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
720
+  [[ "$available_bytes" -ge "$needed_bytes" ]]
721
+}
722
+
536 723
 join_cmd_for_log() {
537 724
   local out=""
538 725
   local arg
@@ -563,6 +750,10 @@ parse_args() {
563 750
         STAGING_PROVIDED=true
564 751
         shift 2
565 752
         ;;
753
+      --stage-input)
754
+        STAGE_INPUT=true
755
+        shift
756
+        ;;
566 757
       --staging-ramdisk-mb)
567 758
         require_value "$1" "${2:-}"
568 759
         STAGING_RAMDISK_MB="$2"
@@ -626,6 +817,7 @@ parse_args() {
626 817
         shift
627 818
         ;;
628 819
       --unattended)
820
+        UNATTENDED=true
629 821
         MOVE_SOURCE=true
630 822
         FAIL_FAST=false
631 823
         shift
@@ -684,6 +876,10 @@ parse_args() {
684 876
   if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
685 877
     die "--staging-ramdisk-mb must be greater than 0"
686 878
   fi
879
+
880
+  if [[ "$STAGE_INPUT" != true && "$STAGING_PROVIDED" == true && "$MOVE_SOURCE" == true && "$FAIL_FAST" == false ]]; then
881
+    STAGE_INPUT=true
882
+  fi
687 883
 }
688 884
 
689 885
 check_tools() {
@@ -989,6 +1185,83 @@ source_video_is_readable() {
989 1185
   return 1
990 1186
 }
991 1187
 
1188
+stage_input_for_encode() {
1189
+  local source_file="$1"
1190
+  local source_size staged_size
1191
+  local staged_duration
1192
+  local available_bytes=0 needed_bytes=0
1193
+
1194
+  STAGED_INPUT_PATH=""
1195
+  STAGED_INPUT_FAILURE_RC=0
1196
+
1197
+  if [[ "$STAGE_INPUT" != true ]]; then
1198
+    return 0
1199
+  fi
1200
+
1201
+  if [[ "$DRY_RUN" == true ]]; then
1202
+    vlog_msg "DRY-RUN" "Would copy source to staging before encode: $source_file"
1203
+    return 0
1204
+  fi
1205
+
1206
+  if [[ "$STAGING_PROVIDED" != true || -z "$STAGING_DIR" ]]; then
1207
+    STAGED_INPUT_FAILURE_RC=3
1208
+    log_msg "ERROR" "--stage-input requires --staging-dir"
1209
+    return 1
1210
+  fi
1211
+
1212
+  if [[ ! -d "$STAGING_DIR" || ! -w "$STAGING_DIR" ]]; then
1213
+    STAGED_INPUT_FAILURE_RC=3
1214
+    log_msg "ERROR" "Input staging directory is not writable: $STAGING_DIR"
1215
+    return 1
1216
+  fi
1217
+
1218
+  source_size="$(file_size_bytes_or_zero "$source_file")"
1219
+  available_bytes="$(dir_available_bytes_or_zero "$STAGING_DIR")"
1220
+  needed_bytes=$((source_size * 3))
1221
+  if [[ "$source_size" -le 0 || "$available_bytes" -lt "$needed_bytes" ]]; then
1222
+    STAGED_INPUT_FAILURE_RC=3
1223
+    log_msg "ERROR" "Insufficient staging space for input copy and output temp files: $source_file (needed=${needed_bytes}B available=${available_bytes}B source=${source_size}B staging=$STAGING_DIR)"
1224
+    return 1
1225
+  fi
1226
+
1227
+  STAGED_INPUT_PATH="$(make_temp_staged_input_file "$source_file" "$STAGING_DIR")"
1228
+  if [[ -z "$STAGED_INPUT_PATH" ]]; then
1229
+    STAGED_INPUT_FAILURE_RC=3
1230
+    log_msg "ERROR" "Could not create staged input temp file: $source_file"
1231
+    return 1
1232
+  fi
1233
+
1234
+  vlog_msg "INFO" "Copying source to staging before encode: $source_file -> $STAGED_INPUT_PATH"
1235
+  if ! dd if="$source_file" of="$STAGED_INPUT_PATH" bs=4m status=none; then
1236
+    cleanup_staged_input "$STAGED_INPUT_PATH"
1237
+    STAGED_INPUT_PATH=""
1238
+    STAGED_INPUT_FAILURE_RC=2
1239
+    log_msg "ERROR" "Failed to copy source to staging: $source_file"
1240
+    return 1
1241
+  fi
1242
+
1243
+  staged_size="$(file_size_bytes_or_zero "$STAGED_INPUT_PATH")"
1244
+  if [[ "$source_size" -le 0 || "$source_size" != "$staged_size" ]]; then
1245
+    cleanup_staged_input "$STAGED_INPUT_PATH"
1246
+    STAGED_INPUT_PATH=""
1247
+    STAGED_INPUT_FAILURE_RC=2
1248
+    log_msg "ERROR" "Staged source copy failed size validation: $source_file (source=${source_size}B staged=${staged_size}B)"
1249
+    return 1
1250
+  fi
1251
+
1252
+  staged_duration="$(ffprobe_duration_or_empty "$STAGED_INPUT_PATH")"
1253
+  if [[ -z "$staged_duration" ]]; then
1254
+    cleanup_staged_input "$STAGED_INPUT_PATH"
1255
+    STAGED_INPUT_PATH=""
1256
+    STAGED_INPUT_FAILURE_RC=2
1257
+    log_msg "ERROR" "Staged source copy failed duration probe: $STAGED_INPUT_PATH"
1258
+    return 1
1259
+  fi
1260
+
1261
+  vlog_msg "INFO" "Staged input OK: $STAGED_INPUT_PATH"
1262
+  return 0
1263
+}
1264
+
992 1265
 source_error_from_ffmpeg_log() {
993 1266
   local ffmpeg_log="$1"
994 1267
   [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
@@ -1093,6 +1366,7 @@ build_video_args() {
1093 1366
 normalize_source_dir() {
1094 1367
   SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1095 1368
   [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
1369
+  preflight_source_readable "$SOURCE_DIR"
1096 1370
   SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1097 1371
 }
1098 1372
 
@@ -1110,6 +1384,7 @@ create_missing_staging_ramdisk_if_needed() {
1110 1384
   local dev=""
1111 1385
   local mount_point=""
1112 1386
 
1387
+  STAGING_RAMDISK_CREATE_ERROR=""
1113 1388
   staging_path="${staging_path%/}"
1114 1389
 
1115 1390
   if [[ -d "$staging_path" ]]; then
@@ -1118,6 +1393,7 @@ create_missing_staging_ramdisk_if_needed() {
1118 1393
   fi
1119 1394
 
1120 1395
   if [[ "$(uname -s)" != "Darwin" ]]; then
1396
+    STAGING_RAMDISK_CREATE_ERROR="auto-create is only supported on macOS"
1121 1397
     return
1122 1398
   fi
1123 1399
 
@@ -1125,17 +1401,20 @@ create_missing_staging_ramdisk_if_needed() {
1125 1401
     /Volumes/*)
1126 1402
       ;;
1127 1403
     *)
1404
+      STAGING_RAMDISK_CREATE_ERROR="auto-create only supports top-level /Volumes/<Name> paths"
1128 1405
       return
1129 1406
       ;;
1130 1407
   esac
1131 1408
 
1132 1409
   ramdisk_name="$(basename "$staging_path")"
1133 1410
   if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
1411
+    STAGING_RAMDISK_CREATE_ERROR="invalid RAM disk volume name derived from staging path"
1134 1412
     return
1135 1413
   fi
1136 1414
 
1137 1415
   if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1138 1416
     # Only auto-create for top-level /Volumes/<Name>, not nested paths.
1417
+    STAGING_RAMDISK_CREATE_ERROR="auto-create only supports top-level /Volumes/<Name> paths"
1139 1418
     return
1140 1419
   fi
1141 1420
 
@@ -1146,10 +1425,12 @@ create_missing_staging_ramdisk_if_needed() {
1146 1425
   vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)"
1147 1426
   dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)"
1148 1427
   if [[ -z "$dev" ]]; then
1428
+    STAGING_RAMDISK_CREATE_ERROR="hdiutil attach failed for ${STAGING_RAMDISK_MB}MB RAM disk"
1149 1429
     return
1150 1430
   fi
1151 1431
 
1152 1432
   if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
1433
+    STAGING_RAMDISK_CREATE_ERROR="diskutil eraseVolume failed for RAM disk device $dev"
1153 1434
     /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1154 1435
     return
1155 1436
   fi
@@ -1164,6 +1445,8 @@ create_missing_staging_ramdisk_if_needed() {
1164 1445
     AUTO_CREATED_STAGING_PATH="$mount_point"
1165 1446
     AUTO_CREATED_STAGING_DEV="$dev"
1166 1447
     STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1448
+  else
1449
+    STAGING_RAMDISK_CREATE_ERROR="RAM disk device $dev was created but mount point was not found (expected $mount_point)"
1167 1450
   fi
1168 1451
 }
1169 1452
 
@@ -1196,6 +1479,14 @@ attempt_unmount_auto_staging_ramdisk() {
1196 1479
 
1197 1480
 cleanup_on_exit() {
1198 1481
   local rc=$?
1482
+  trap - ERR
1483
+  if [[ "$MAIN_FLOW_STARTED" == true && "$FINAL_REPORT_PRINTED" != true ]]; then
1484
+    if [[ "$rc" -eq 0 ]]; then
1485
+      log_msg "WARN" "Run exited before final report despite exit=0; this indicates an early-return path"
1486
+    else
1487
+      log_msg "ERROR" "Run exited before final report (exit=$rc)"
1488
+    fi
1489
+  fi
1199 1490
   attempt_unmount_auto_staging_ramdisk
1200 1491
   return "$rc"
1201 1492
 }
@@ -1213,6 +1504,9 @@ normalize_staging_dir() {
1213 1504
       STAGING_DIR="$AUTO_CREATED_STAGING_PATH"
1214 1505
       log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1215 1506
     else
1507
+      if [[ -n "$STAGING_RAMDISK_CREATE_ERROR" ]]; then
1508
+        die "Staging directory not found and RAM disk auto-create failed: $STAGING_DIR ($STAGING_RAMDISK_CREATE_ERROR)"
1509
+      fi
1216 1510
       die "Staging directory not found: $STAGING_DIR"
1217 1511
     fi
1218 1512
   fi
@@ -1299,6 +1593,7 @@ process_video_file() {
1299 1593
   local rel_path output_file temp_output_file out_dir
1300 1594
   local display_path
1301 1595
   local encode_input_file
1596
+  local staged_input_file=""
1302 1597
   local preferred_temp_dir=""
1303 1598
   local repacked_input_file=""
1304 1599
   local input_size_bytes=0 output_size_bytes=0
@@ -1315,9 +1610,36 @@ process_video_file() {
1315 1610
   display_path="${input_file#$PWD/}"
1316 1611
   encode_input_file="$input_file"
1317 1612
 
1318
-  mkdir -p "$out_dir"
1613
+  vlog_msg "INFO" "Preparing $rel_path"
1614
+
1615
+  if [[ "$OVERWRITE" != true && -f "$output_file" ]]; then
1616
+    vlog_msg "SKIP" "Video exists: $output_file"
1617
+    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
1618
+    return
1619
+  fi
1319 1620
 
1320
-  if ! source_video_is_readable "$input_file"; then
1621
+  if ! stage_input_for_encode "$encode_input_file"; then
1622
+    ERRORS=$((ERRORS + 1))
1623
+    cleanup_process_inputs "$STAGED_INPUT_PATH" "$repacked_input_file"
1624
+    if [[ "$STAGED_INPUT_FAILURE_RC" -eq 3 ]]; then
1625
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1626
+      return 3
1627
+    fi
1628
+    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1629
+    return 2
1630
+  fi
1631
+  staged_input_file="$STAGED_INPUT_PATH"
1632
+  if [[ -n "$staged_input_file" ]]; then
1633
+    encode_input_file="$staged_input_file"
1634
+  fi
1635
+
1636
+  if [[ "$DRY_RUN" == true ]]; then
1637
+    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1638
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1639
+    return 0
1640
+  fi
1641
+
1642
+  if ! source_video_is_readable "$encode_input_file"; then
1321 1643
     ERRORS=$((ERRORS + 1))
1322 1644
     INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1323 1645
     if [[ "$VERBOSE" == true ]]; then
@@ -1326,6 +1648,7 @@ process_video_file() {
1326 1648
       log_progress_start "$display_path"
1327 1649
       log_progress_skipped_unreadable "$display_path"
1328 1650
     fi
1651
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1329 1652
     return 2
1330 1653
   fi
1331 1654
 
@@ -1335,13 +1658,6 @@ process_video_file() {
1335 1658
     vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1336 1659
   fi
1337 1660
 
1338
-  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1339
-    vlog_msg "SKIP" "Video exists: $output_file"
1340
-    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
1341
-    cleanup_repacked_input "$repacked_input_file"
1342
-    return
1343
-  fi
1344
-
1345 1661
   local has_audio=false
1346 1662
   if probe_has_audio "$encode_input_file"; then
1347 1663
     has_audio=true
@@ -1368,6 +1684,7 @@ process_video_file() {
1368 1684
     ERRORS=$((ERRORS + 1))
1369 1685
     DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1370 1686
     log_msg "ERROR" "Could not create temporary output file: $output_file"
1687
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1371 1688
     return 3
1372 1689
   fi
1373 1690
   if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then
@@ -1396,11 +1713,6 @@ process_video_file() {
1396 1713
     log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1397 1714
   fi
1398 1715
 
1399
-  if [[ "$DRY_RUN" == true ]]; then
1400
-    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1401
-    return 0
1402
-  fi
1403
-
1404 1716
   if [[ "$VERBOSE" != true ]]; then
1405 1717
     log_progress_start "$display_path"
1406 1718
   fi
@@ -1436,6 +1748,7 @@ process_video_file() {
1436 1748
       ERRORS=$((ERRORS + 1))
1437 1749
       DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1438 1750
       log_msg "ERROR" "Could not create temporary ffmpeg log file"
1751
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1439 1752
       return 3
1440 1753
     fi
1441 1754
     if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
@@ -1454,7 +1767,7 @@ process_video_file() {
1454 1767
     # Treat any user-triggered abort as a clean stop — no error counters, no noise.
1455 1768
     if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1456 1769
       cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1457
-      cleanup_repacked_input "$repacked_input_file"
1770
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1458 1771
       return 4
1459 1772
     fi
1460 1773
 
@@ -1487,45 +1800,45 @@ process_video_file() {
1487 1800
     if [[ "$failure_rc" -eq 3 ]]; then
1488 1801
       log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1489 1802
     fi
1490
-    cleanup_repacked_input "$repacked_input_file"
1803
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1491 1804
     return "$failure_rc"
1492 1805
   fi
1493 1806
 
1494 1807
   TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1495 1808
 
1496
-  if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then
1809
+  if ! restore_metadata_with_exiftool "$encode_input_file" "$temp_output_file"; then
1497 1810
     cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1498 1811
     if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1499
-      cleanup_repacked_input "$repacked_input_file"
1812
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1500 1813
       return 4
1501 1814
     fi
1502 1815
     ERRORS=$((ERRORS + 1))
1503 1816
     DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1504
-    cleanup_repacked_input "$repacked_input_file"
1817
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1505 1818
     return 3
1506 1819
   fi
1507 1820
 
1508
-  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1821
+  if ! map_garmin_model_to_standard_tags "$encode_input_file" "$temp_output_file"; then
1509 1822
     cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1510 1823
     if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1511
-      cleanup_repacked_input "$repacked_input_file"
1824
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1512 1825
       return 4
1513 1826
     fi
1514 1827
     ERRORS=$((ERRORS + 1))
1515 1828
     DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1516
-    cleanup_repacked_input "$repacked_input_file"
1829
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1517 1830
     return 3
1518 1831
   fi
1519 1832
 
1520 1833
   if ! write_transcode_encoder_metadata "$temp_output_file"; then
1521 1834
     cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1522 1835
     if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1523
-      cleanup_repacked_input "$repacked_input_file"
1836
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1524 1837
       return 4
1525 1838
     fi
1526 1839
     ERRORS=$((ERRORS + 1))
1527 1840
     DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1528
-    cleanup_repacked_input "$repacked_input_file"
1841
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1529 1842
     return 3
1530 1843
   fi
1531 1844
 
@@ -1535,27 +1848,36 @@ process_video_file() {
1535 1848
       ERRORS=$((ERRORS + 1))
1536 1849
       if destination_cannot_accept_file "$input_file" "$out_dir" ""; then
1537 1850
         DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1538
-        cleanup_repacked_input "$repacked_input_file"
1851
+        cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1539 1852
         return 3
1540 1853
       fi
1541 1854
       # Validation failed but destination is healthy: the source or encoder produced
1542 1855
       # a corrupt/truncated output. Treat as source error so --keep-going can skip it.
1543 1856
       INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1544 1857
       log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file"
1545
-      cleanup_repacked_input "$repacked_input_file"
1858
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1546 1859
       return 2
1547 1860
     fi
1548 1861
   fi
1549 1862
 
1550 1863
   touch -r "$input_file" "$temp_output_file" || true
1551 1864
 
1865
+  if ! mkdir -p "$out_dir"; then
1866
+    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1867
+    ERRORS=$((ERRORS + 1))
1868
+    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1869
+    log_msg "ERROR" "Failed to create destination directory: $out_dir"
1870
+    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1871
+    return 3
1872
+  fi
1873
+
1552 1874
   if [[ "$OVERWRITE" == true ]]; then
1553 1875
     if ! mv -f "$temp_output_file" "$output_file"; then
1554 1876
       cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1555 1877
       ERRORS=$((ERRORS + 1))
1556 1878
       DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1557 1879
       log_msg "ERROR" "Failed to move completed output into place: $output_file"
1558
-      cleanup_repacked_input "$repacked_input_file"
1880
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1559 1881
       return 3
1560 1882
     fi
1561 1883
   else
@@ -1564,7 +1886,7 @@ process_video_file() {
1564 1886
       ERRORS=$((ERRORS + 1))
1565 1887
       DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1566 1888
       log_msg "ERROR" "Failed to move completed output into place: $output_file"
1567
-      cleanup_repacked_input "$repacked_input_file"
1889
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1568 1890
       return 3
1569 1891
     fi
1570 1892
   fi
@@ -1580,7 +1902,7 @@ process_video_file() {
1580 1902
     else
1581 1903
       ERRORS=$((ERRORS + 1))
1582 1904
       log_msg "ERROR" "Failed to remove source after validation: $input_file"
1583
-      cleanup_repacked_input "$repacked_input_file"
1905
+      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1584 1906
       return 1
1585 1907
     fi
1586 1908
   fi
@@ -1602,7 +1924,7 @@ process_video_file() {
1602 1924
     log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
1603 1925
   fi
1604 1926
 
1605
-  cleanup_repacked_input "$repacked_input_file"
1927
+  cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1606 1928
 
1607 1929
   return 0
1608 1930
 }
@@ -1612,10 +1934,21 @@ main() {
1612 1934
   total_started_at="$(date +%s)"
1613 1935
   RUN_STARTED_AT="$total_started_at"
1614 1936
 
1937
+  if ! pwd >/dev/null 2>&1; then
1938
+    log_msg "WARN" "Current working directory no longer exists or is inaccessible; switching to a stable directory before continuing."
1939
+    if [[ -n "${HOME:-}" && -d "$HOME" ]]; then
1940
+      cd "$HOME" || cd /
1941
+    else
1942
+      cd /
1943
+    fi
1944
+  fi
1945
+
1615 1946
   trap 'handle_interrupt' INT TERM
1947
+  trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR
1616 1948
   trap 'cleanup_on_exit' EXIT
1617 1949
 
1618 1950
   parse_args "$@"
1951
+  MAIN_FLOW_STARTED=true
1619 1952
   check_tools
1620 1953
 
1621 1954
   # Auto single-file detection: if --source points to a file, treat it as single-file mode
@@ -1626,35 +1959,47 @@ main() {
1626 1959
 
1627 1960
   normalize_source_dir
1628 1961
   normalize_dest_dir
1629
-  normalize_staging_dir
1630
-  collect_extensions
1631 1962
 
1632 1963
   if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1633 1964
     die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1634 1965
   fi
1635 1966
 
1967
+  preflight_destination_writable "$DEST_DIR"
1968
+  normalize_staging_dir
1969
+  if [[ "$STAGE_INPUT" == true ]]; then
1970
+    log_msg "INFO" "Input staging enabled; sources will be copied to staging before encoding"
1971
+  fi
1972
+  collect_extensions
1973
+
1636 1974
   detect_encoders
1637 1975
   resolve_encoder
1638 1976
 
1977
+  log_msg "INFO" "Scanning source for videos: $SOURCE_DIR"
1639 1978
   collect_video_files
1640 1979
   if [[ -z "${VIDEO_FILES[*]-}" ]]; then
1641 1980
     log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1981
+  else
1982
+    log_msg "INFO" "Found ${#VIDEO_FILES[@]} candidate video file(s)"
1642 1983
   fi
1643 1984
 
1644 1985
   local f
1986
+  vlog_msg "INFO" "Starting processing loop for ${#VIDEO_FILES[@]} candidate file(s)"
1645 1987
   for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1646
-    check_for_quit_key
1988
+    vlog_msg "CHECKPOINT" "loop_file: $f"
1989
+    check_for_quit_key || true
1647 1990
     if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1648 1991
       log_msg "INFO" "Stop requested; ending before next file"
1649 1992
       break
1650 1993
     fi
1651 1994
 
1652 1995
     local process_rc=0
1996
+    vlog_msg "INFO" "Dispatching $(rel_path_from_source "$f")"
1653 1997
     if process_video_file "$f"; then
1654 1998
       process_rc=0
1655 1999
     else
1656 2000
       process_rc=$?
1657 2001
     fi
2002
+    vlog_msg "CHECKPOINT" "process_rc=$process_rc file=$f"
1658 2003
 
1659 2004
     if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1660 2005
       DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
@@ -1763,6 +2108,7 @@ main() {
1763 2108
     "$staging_warmup_fmt" \
1764 2109
     "$INPUT_BYTES_PROCESSED" \
1765 2110
     "$OUTPUT_BYTES_PROCESSED"
2111
+  FINAL_REPORT_PRINTED=true
1766 2112
 
1767 2113
   if [[ "$ERRORS" -gt 0 ]]; then
1768 2114
     exit 1