Showing 5 changed files with 919 additions and 38 deletions
+15 -0
CHANGELOG.md
@@ -5,6 +5,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5 5
 
6 6
 ---
7 7
 
8
+## [Unreleased]
9
+
10
+### Changed
11
+- `--move-source` renamed to `--delete-source` (old name kept as alias)
12
+- `--continue-on-error` renamed to `--keep-going` (old name kept as alias)
13
+### Added
14
+- `--unattended` preset (`--delete-source` + `--keep-going`) for long unattended runs
15
+
16
+- `cleanup_garmin_varia_media_folder.sh` helper for media-folder hygiene (import + transcoded output):
17
+	removes AppleDouble artifacts (`._*`), removes zero-size MP4 files,
18
+	and normalizes single-suffix duplicate timestamp names
19
+
20
+### Behavior
21
+- Import cleanup returns exit code `1` when duplicate groups are blocked and need manual review
22
+
8 23
 ## [1.0.0] — 2026-05-04
9 24
 
10 25
 Initial release.
+52 -3
README.md
@@ -23,7 +23,13 @@ manifest placeholder for a future FIT sync pipeline.
23 23
 ./garmin_varia_transcode.sh -s SampleFootage/Day/clip.mp4 -d Output --verbose
24 24
 
25 25
 # Transcode + delete originals after validation
26
-./garmin_varia_transcode.sh -s SampleFootage -d Output --move-source
26
+./garmin_varia_transcode.sh -s SampleFootage -d Output --delete-source
27
+
28
+# Long unattended run preset (delete-source + keep-going)
29
+./garmin_varia_transcode.sh -s SampleFootage -d Output --unattended
30
+
31
+# Media cleanup helper (Apple artifacts, zero-byte MP4, duplicate suffix normalization)
32
+./cleanup_garmin_varia_media_folder.sh --dry-run ~/Autofs/xdev/autonas/ext01/@Camera/import
27 33
 ```
28 34
 
29 35
 ## Encoding Modes
@@ -50,7 +56,11 @@ Options:
50 56
   --no-recursive                  Process only the top-level source directory
51 57
   --extensions LIST               Comma-separated extensions (default: mp4,mov,avi,m4v)
52 58
   --verbose                       Full per-operation logs + ffmpeg/ffprobe output
53
-  --move-source                   Delete source after strict post-encode validation
59
+  --delete-source                 Delete source after strict post-encode validation
60
+  --keep-going                    Continue after source-file failures (default: stop)
61
+  --unattended                    Preset for long runs: --delete-source + --keep-going
62
+  --no-apple-repack-fallback      Disable macOS avconvert fallback for unreadable MP4/MOV sources
63
+  --apple-repack-fallback         Enable macOS avconvert fallback (default)
54 64
   -h, --help                      Show full help with encoding mode details
55 65
 ```
56 66
 
@@ -75,8 +85,46 @@ Options:
75 85
 ## Safety
76 86
 
77 87
 - Destination inside source is rejected with an error
78
-- `--move-source` only deletes source after codec + duration validation passes
88
+- `--delete-source` only deletes source after codec + duration validation passes
79 89
 - `--dry-run` never writes files
90
+- `Ctrl+C` behavior during long runs:
91
+  first press requests stop after current file; second press force-stops current encode
92
+- On macOS, unreadable MP4/MOV sources automatically try `avconvert --preset PresetPassthrough`
93
+  as fallback before being marked as unreadable
94
+
95
+Backward-compatible aliases:
96
+- `--move-source` maps to `--delete-source`
97
+- `--continue-on-error` maps to `--keep-going`
98
+
99
+## Media Cleanup Utility
100
+
101
+`cleanup_garmin_varia_media_folder.sh` is a companion utility for import and transcoded media folders.
102
+
103
+What it does:
104
+- Removes AppleDouble artifacts (`._*`) up to 4096 bytes
105
+- Removes zero-size `.mp4` files
106
+- Normalizes duplicate timestamp files:
107
+  if `YYYY-MM-DD_HH-MM-SS.mp4` is missing and exactly one
108
+  `YYYY-MM-DD_HH-MM-SS_<n>.mp4` exists, it renames it to base name
109
+- Reports blocked duplicate groups when base exists or multiple suffixed duplicates exist
110
+
111
+Usage:
112
+
113
+```bash
114
+# Preview only
115
+./cleanup_garmin_varia_media_folder.sh --dry-run ~/Autofs/xdev/autonas/ext01/@Camera/import
116
+
117
+# Apply changes
118
+./cleanup_garmin_varia_media_folder.sh ~/Autofs/xdev/autonas/ext01/@Camera/import
119
+
120
+# Example on transcoded output folder
121
+./cleanup_garmin_varia_media_folder.sh --dry-run ~/Autofs/xdev/is-baobab/nvme0n1/@backup/Garmin
122
+```
123
+
124
+Exit codes:
125
+- `0`: cleanup completed with no blocked duplicate groups
126
+- `1`: cleanup completed but blocked duplicate groups require manual review
127
+- `2`: invalid invocation or runtime error
80 128
 
81 129
 ## Source Files
82 130
 
@@ -84,6 +132,7 @@ Options:
84 132
 SampleFootage/          Original clips (gitignored)
85 133
 Output/                 Transcoded output (gitignored)
86 134
 garmin_varia_transcode.sh   Main script
135
+cleanup_garmin_varia_media_folder.sh   Media cleanup utility (import + transcoded folders)
87 136
 CHANGELOG.md            Version history
88 137
 DEVLOG.md               Development decisions and rationale
89 138
 ```
+8 -0
VariaReEncoder.code-workspace
@@ -0,0 +1,8 @@
1
+{
2
+	"folders": [
3
+		{
4
+			"path": "."
5
+		}
6
+	],
7
+	"settings": {}
8
+}
+356 -0
cleanup_garmin_varia_media_folder.sh
@@ -0,0 +1,356 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+TOOL_NAME="cleanup_garmin_varia_media_folder.sh"
5
+DEFAULT_MEDIA_ROOT="."
6
+MAX_APPLEDOUBLE_BYTES=4096
7
+
8
+DRY_RUN=false
9
+VERBOSE=false
10
+MEDIA_ROOT="$DEFAULT_MEDIA_ROOT"
11
+
12
+TMP_MP4_LIST=""
13
+TMP_ACTIONS=""
14
+TMP_NONSTANDARD=""
15
+TMP_ZERO=""
16
+TMP_BLOCKED=""
17
+TMP_APPLE=""
18
+
19
+APPLE_ARTIFACTS_REMOVED=0
20
+ZERO_SIZE_REMOVED=0
21
+NONSTANDARD_REMOVED=0
22
+RENAMES_DONE=0
23
+BLOCKED_GROUPS=0
24
+BLOCKED_FILES=0
25
+
26
+usage() {
27
+  cat <<'EOF'
28
+Usage:
29
+  cleanup_garmin_varia_media_folder.sh [options] [MEDIA_ROOT]
30
+
31
+Purpose:
32
+  Clean common Apple artifacts and zero-size MP4 files, then normalize
33
+  duplicate MP4 names produced during copy/import retries.
34
+
35
+Rules:
36
+  - Delete AppleDouble sidecars matching ._* only when size <= 4096 bytes
37
+  - Delete zero-size .mp4 files
38
+  - For canonical timestamp names:
39
+      YYYY-MM-DD_HH-MM-SS.mp4
40
+      YYYY-MM-DD_HH-MM-SS_<n>.mp4
41
+    if exactly one suffixed duplicate exists and the base file is missing,
42
+    rename duplicate to base name
43
+  - If base exists, or multiple suffixed duplicates exist, keep files unchanged
44
+    and report them as blocked
45
+
46
+Options:
47
+  --dry-run         Print actions without changing files
48
+  --verbose         Print per-file operations
49
+  -h, --help        Show this help
50
+
51
+Examples:
52
+  ./cleanup_garmin_varia_media_folder.sh --dry-run ~/Autofs/xdev/autonas/ext01/@Camera/import
53
+  ./cleanup_garmin_varia_media_folder.sh ~/Autofs/xdev/is-baobab/nvme0n1/@backup/Garmin
54
+EOF
55
+}
56
+
57
+log_msg() {
58
+  local level="$1"
59
+  shift
60
+  printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*"
61
+}
62
+
63
+vlog_msg() {
64
+  if [[ "$VERBOSE" == true ]]; then
65
+    log_msg "INFO" "$*"
66
+  fi
67
+}
68
+
69
+die() {
70
+  log_msg "ERROR" "$*"
71
+  exit 2
72
+}
73
+
74
+cleanup_tmp_files() {
75
+  rm -f -- "$TMP_MP4_LIST" "$TMP_ACTIONS" "$TMP_NONSTANDARD" "$TMP_ZERO" "$TMP_BLOCKED" "$TMP_APPLE" "$TMP_ACTIONS.tmp" 2>/dev/null || true
76
+}
77
+
78
+parse_args() {
79
+  while [[ $# -gt 0 ]]; do
80
+    case "$1" in
81
+      --dry-run)
82
+        DRY_RUN=true
83
+        shift
84
+        ;;
85
+      --verbose)
86
+        VERBOSE=true
87
+        shift
88
+        ;;
89
+      -h|--help)
90
+        usage
91
+        exit 0
92
+        ;;
93
+      -*)
94
+        die "Unknown option: $1"
95
+        ;;
96
+      *)
97
+        MEDIA_ROOT="$1"
98
+        shift
99
+        ;;
100
+    esac
101
+  done
102
+}
103
+
104
+init_tmp_files() {
105
+  TMP_MP4_LIST="$(mktemp)"
106
+  TMP_ACTIONS="$(mktemp)"
107
+  TMP_NONSTANDARD="$(mktemp)"
108
+  TMP_ZERO="$(mktemp)"
109
+  TMP_BLOCKED="$(mktemp)"
110
+  TMP_APPLE="$(mktemp)"
111
+  trap cleanup_tmp_files EXIT
112
+}
113
+
114
+validate_media_root() {
115
+  [[ -d "$MEDIA_ROOT" ]] || die "Media root not found: $MEDIA_ROOT"
116
+}
117
+
118
+rescan_mp4_files() {
119
+  find "$MEDIA_ROOT" -type f -name '*.mp4' | sort > "$TMP_MP4_LIST"
120
+}
121
+
122
+safe_remove_file() {
123
+  local path="$1"
124
+  if [[ "$DRY_RUN" == true ]]; then
125
+    return 0
126
+  fi
127
+  rm -f -- "$path"
128
+}
129
+
130
+remove_apple_artifacts() {
131
+  local file size
132
+  while IFS= read -r file; do
133
+    [[ -n "$file" ]] || continue
134
+    size="$(wc -c < "$file" | tr -d ' ')"
135
+
136
+    if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -le "$MAX_APPLEDOUBLE_BYTES" ]]; then
137
+      APPLE_ARTIFACTS_REMOVED=$((APPLE_ARTIFACTS_REMOVED + 1))
138
+      printf '%s\n' "$file" >> "$TMP_APPLE"
139
+      vlog_msg "Apple artifact: $file (${size} bytes)"
140
+      safe_remove_file "$file"
141
+    else
142
+      vlog_msg "Skipped ._* larger than threshold: $file (${size} bytes)"
143
+    fi
144
+  done < <(find "$MEDIA_ROOT" -type f -name '._*' | sort)
145
+
146
+  if [[ "$APPLE_ARTIFACTS_REMOVED" -gt 0 ]]; then
147
+    if [[ "$DRY_RUN" == true ]]; then
148
+      log_msg "INFO" "Would remove $APPLE_ARTIFACTS_REMOVED Apple artifact file(s)"
149
+    else
150
+      log_msg "INFO" "Removed $APPLE_ARTIFACTS_REMOVED Apple artifact file(s)"
151
+    fi
152
+    rescan_mp4_files
153
+  fi
154
+}
155
+
156
+remove_zero_size_mp4() {
157
+  local file
158
+  while IFS= read -r file; do
159
+    [[ -n "$file" ]] || continue
160
+    if [[ ! -s "$file" ]]; then
161
+      ZERO_SIZE_REMOVED=$((ZERO_SIZE_REMOVED + 1))
162
+      printf '%s\n' "$file" >> "$TMP_ZERO"
163
+      vlog_msg "Zero-size MP4: $file"
164
+      safe_remove_file "$file"
165
+    fi
166
+  done < "$TMP_MP4_LIST"
167
+
168
+  if [[ "$ZERO_SIZE_REMOVED" -gt 0 ]]; then
169
+    if [[ "$DRY_RUN" == true ]]; then
170
+      log_msg "INFO" "Would remove $ZERO_SIZE_REMOVED zero-size MP4 file(s)"
171
+    else
172
+      log_msg "INFO" "Removed $ZERO_SIZE_REMOVED zero-size MP4 file(s)"
173
+    fi
174
+    rescan_mp4_files
175
+  fi
176
+}
177
+
178
+collect_duplicate_actions() {
179
+  local file base dir timestamp suffix
180
+
181
+  : > "$TMP_ACTIONS"
182
+  : > "$TMP_NONSTANDARD"
183
+
184
+  while IFS= read -r file; do
185
+    [[ -n "$file" ]] || continue
186
+    base="$(basename "$file")"
187
+
188
+    if [[ "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2})(_[0-9]+)?\.mp4$ ]]; then
189
+      dir="$(dirname "$file")"
190
+      timestamp="${BASH_REMATCH[1]}"
191
+      suffix="${BASH_REMATCH[2]:-}"
192
+      if [[ -n "$suffix" ]]; then
193
+        suffix="${suffix#_}"
194
+      else
195
+        suffix=0
196
+      fi
197
+      printf '%s\t%s\t%s\t%s\n' "$dir" "$timestamp" "$suffix" "$file" >> "$TMP_ACTIONS"
198
+    elif [[ "$base" =~ _[0-9]+\.mp4$ ]]; then
199
+      printf '%s\n' "$file" >> "$TMP_NONSTANDARD"
200
+      NONSTANDARD_REMOVED=$((NONSTANDARD_REMOVED + 1))
201
+      vlog_msg "Non-standard duplicate-like file: $file"
202
+      safe_remove_file "$file"
203
+    fi
204
+  done < "$TMP_MP4_LIST"
205
+}
206
+
207
+build_rename_plan() {
208
+  awk -F'\t' '
209
+function flush() {
210
+  if (dup_count == 1 && orig_seen == 0) {
211
+    print "RENAME\t" dup_files[1] "\t" dir "\t" timestamp
212
+  } else if (dup_count > 0) {
213
+    print "GROUP\t" dir "\t" timestamp "\t" dup_count
214
+    for (i = 1; i <= dup_count; i++) {
215
+      print "BLOCK\t" dup_files[i]
216
+    }
217
+  }
218
+}
219
+
220
+BEGIN {
221
+  OFS = "\t"
222
+  dir = ""
223
+  timestamp = ""
224
+  orig_seen = 0
225
+  dup_count = 0
226
+}
227
+
228
+{
229
+  if ($1 != dir || $2 != timestamp) {
230
+    if (NR > 1) {
231
+      flush()
232
+    }
233
+    dir = $1
234
+    timestamp = $2
235
+    orig_seen = 0
236
+    dup_count = 0
237
+    delete dup_files
238
+  }
239
+
240
+  if ($3 == 0) {
241
+    orig_seen = 1
242
+  } else {
243
+    dup_count++
244
+    dup_files[dup_count] = $4
245
+  }
246
+}
247
+
248
+END {
249
+  if (NR > 0) {
250
+    flush()
251
+  }
252
+}
253
+' "$TMP_ACTIONS" > "$TMP_ACTIONS.tmp"
254
+
255
+  mv "$TMP_ACTIONS.tmp" "$TMP_ACTIONS"
256
+}
257
+
258
+apply_rename_plan() {
259
+  local action src dir timestamp dup_count dst
260
+
261
+  while IFS=$'\t' read -r action src dir timestamp dup_count; do
262
+    [[ -n "$action" ]] || continue
263
+    case "$action" in
264
+      RENAME)
265
+        dst="$dir/$timestamp.mp4"
266
+        RENAMES_DONE=$((RENAMES_DONE + 1))
267
+        if [[ "$DRY_RUN" == true ]]; then
268
+          log_msg "INFO" "DRY-RUN rename: $src -> $dst"
269
+        else
270
+          mv -- "$src" "$dst"
271
+          vlog_msg "Renamed: $src -> $dst"
272
+        fi
273
+        ;;
274
+      GROUP)
275
+        BLOCKED_GROUPS=$((BLOCKED_GROUPS + 1))
276
+        ;;
277
+      BLOCK)
278
+        BLOCKED_FILES=$((BLOCKED_FILES + 1))
279
+        printf '%s\n' "$src" >> "$TMP_BLOCKED"
280
+        ;;
281
+    esac
282
+  done < "$TMP_ACTIONS"
283
+}
284
+
285
+print_summary_lists() {
286
+  if [[ -s "$TMP_APPLE" ]]; then
287
+    if [[ "$DRY_RUN" == true ]]; then
288
+      log_msg "INFO" "Apple artifacts that would be removed:"
289
+    else
290
+      log_msg "INFO" "Apple artifacts removed:"
291
+    fi
292
+    cat "$TMP_APPLE"
293
+  fi
294
+
295
+  if [[ -s "$TMP_ZERO" ]]; then
296
+    if [[ "$DRY_RUN" == true ]]; then
297
+      log_msg "INFO" "Zero-size MP4 files that would be removed:"
298
+    else
299
+      log_msg "INFO" "Zero-size MP4 files removed:"
300
+    fi
301
+    cat "$TMP_ZERO"
302
+  fi
303
+
304
+  if [[ -s "$TMP_NONSTANDARD" ]]; then
305
+    if [[ "$DRY_RUN" == true ]]; then
306
+      log_msg "INFO" "Non-standard duplicate-like MP4 files that would be removed:"
307
+    else
308
+      log_msg "INFO" "Non-standard duplicate-like MP4 files removed:"
309
+    fi
310
+    cat "$TMP_NONSTANDARD"
311
+  fi
312
+
313
+  if [[ -s "$TMP_BLOCKED" ]]; then
314
+    log_msg "WARN" "Blocked duplicate files (manual review needed):"
315
+    cat "$TMP_BLOCKED"
316
+  fi
317
+}
318
+
319
+print_summary() {
320
+  local mode_text
321
+  mode_text="apply"
322
+  if [[ "$DRY_RUN" == true ]]; then
323
+    mode_text="dry-run"
324
+  fi
325
+
326
+  log_msg "INFO" "Summary ($mode_text): apple_removed=$APPLE_ARTIFACTS_REMOVED zero_removed=$ZERO_SIZE_REMOVED nonstandard_removed=$NONSTANDARD_REMOVED renamed=$RENAMES_DONE blocked_groups=$BLOCKED_GROUPS blocked_files=$BLOCKED_FILES"
327
+}
328
+
329
+main() {
330
+  parse_args "$@"
331
+  init_tmp_files
332
+  validate_media_root
333
+
334
+  log_msg "INFO" "Starting cleanup for media root: $MEDIA_ROOT"
335
+  if [[ "$DRY_RUN" == true ]]; then
336
+    log_msg "INFO" "Dry-run enabled; no files will be changed"
337
+  fi
338
+
339
+  rescan_mp4_files
340
+  remove_apple_artifacts
341
+  remove_zero_size_mp4
342
+  collect_duplicate_actions
343
+  build_rename_plan
344
+  apply_rename_plan
345
+  print_summary_lists
346
+  print_summary
347
+
348
+  if [[ "$BLOCKED_GROUPS" -gt 0 ]]; then
349
+    log_msg "WARN" "Cleanup completed with blocked duplicate groups"
350
+    exit 1
351
+  fi
352
+
353
+  log_msg "INFO" "Cleanup completed successfully"
354
+}
355
+
356
+main "$@"
+488 -35
garmin_varia_transcode.sh
@@ -39,26 +39,39 @@ VERBOSE=false
39 39
 MOVE_SOURCE=false
40 40
 SOURCE_PROVIDED=false
41 41
 DEST_PROVIDED=false
42
+FAIL_FAST=true
43
+SOURCE_READABLE_MODE="normal"
44
+APPLE_REPACK_FALLBACK=true
45
+REPACKED_SOURCE_PATH=""
42 46
 
43 47
 HAS_VIDEOTOOLBOX=false
44 48
 HAS_LIBX265=false
45 49
 HAS_LIBX264=false
50
+HAS_AVCONVERT=false
46 51
 
47 52
 ENCODER_KIND=""
48 53
 VIDEO_CODEC=""
49 54
 VIDEO_CRF=""
50 55
 VIDEO_ARGS=()
51 56
 FIND_EXT_EXPR=()
57
+EXT_LIST=()
58
+VIDEO_FILES=()
52 59
 
53 60
 VIDEOS_PROCESSED=0
54 61
 VIDEOS_SKIPPED=0
55 62
 JSON_COPIED=0
56 63
 JSON_SKIPPED=0
57 64
 ERRORS=0
65
+INVALID_SOURCES_SKIPPED=0
66
+DESTINATION_FAILURES=0
58 67
 TOTAL_VIDEO_TIME_SEC=0
59 68
 TOTAL_FILE_REAL_TIME_SEC=0
60 69
 
61 70
 DURATION_TOLERANCE_SEC=1.0
71
+STOP_AFTER_CURRENT=false
72
+INTERRUPT_COUNT=0
73
+CURRENT_FFMPEG_PID=""
74
+PROGRESS_LINE_OPEN=false
62 75
 
63 76
 usage() {
64 77
   cat <<'EOF'
@@ -75,7 +88,11 @@ Options:
75 88
   --no-recursive                  Process only the top-level source directory (default: recursive)
76 89
   --extensions LIST               Comma-separated video extensions (default: mp4,mov,avi,m4v)
77 90
   --verbose                       Log each operation with timestamp; show ffmpeg/ffprobe output
78
-  --move-source                   Remove source file only after strict post-encode validation
91
+  --delete-source                 Delete source file only after strict post-encode validation
92
+  --keep-going                    Continue after source-file failures (default: stop)
93
+  --unattended                    Preset for long runs: --delete-source + --keep-going
94
+  --no-apple-repack-fallback      Disable macOS avconvert fallback for unreadable MP4/MOV sources
95
+  --apple-repack-fallback         Enable macOS avconvert fallback (default)
79 96
   -h, --help                      Show this help
80 97
 
81 98
 Encoding Modes:
@@ -116,7 +133,8 @@ Examples:
116 133
   ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
117 134
   ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
118 135
   ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
119
-  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --move-source
136
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --delete-source
137
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --unattended
120 138
 EOF
121 139
 }
122 140
 
@@ -134,21 +152,103 @@ vlog_msg() {
134 152
   return 0
135 153
 }
136 154
 
137
-# Quiet-mode per-file progress line
138
-log_progress() {
155
+# Quiet-mode per-file progress lines
156
+log_progress_start() {
139 157
   local input_file="$1"
158
+  local ts
159
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
160
+  printf '%s : Transcoding %s ...' "$ts" "$input_file"
161
+  PROGRESS_LINE_OPEN=true
162
+}
163
+
164
+log_progress_done() {
140 165
   local real_elapsed_sec="$2"
141 166
   local encode_elapsed_sec="$3"
167
+  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
168
+    printf ' done in %ss (encode %ss)\n' "$real_elapsed_sec" "$encode_elapsed_sec"
169
+    PROGRESS_LINE_OPEN=false
170
+    return
171
+  fi
172
+
173
+  local input_file="$1"
142 174
   local ts
143 175
   ts="$(date '+%Y-%m-%d %H:%M:%S')"
144 176
   echo "$ts : Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
145 177
 }
146 178
 
179
+log_progress_failed() {
180
+  local input_file="$1"
181
+  local real_elapsed_sec="$2"
182
+  local encode_elapsed_sec="$3"
183
+  local ffmpeg_rc="$4"
184
+  local ffmpeg_log="$5"
185
+
186
+  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
187
+    printf ' FAILED after %ss (encode %ss, rc=%s, log=%s)\n' "$real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "${ffmpeg_log:-n/a}"
188
+    PROGRESS_LINE_OPEN=false
189
+    return
190
+  fi
191
+
192
+  local ts
193
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
194
+  echo "$ts : Transcoding $input_file ... FAILED after ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
195
+}
196
+
197
+log_progress_skipped_unreadable() {
198
+  local input_file="$1"
199
+
200
+  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
201
+    printf ' SKIPPED (unreadable/corrupted source)\n'
202
+    PROGRESS_LINE_OPEN=false
203
+    return
204
+  fi
205
+
206
+  local ts
207
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
208
+  echo "$ts : Transcoding $input_file ... SKIPPED (unreadable/corrupted source)"
209
+}
210
+
147 211
 die() {
148 212
   log_msg "ERROR" "$*"
149 213
   exit 1
150 214
 }
151 215
 
216
+handle_interrupt() {
217
+  INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
218
+  STOP_AFTER_CURRENT=true
219
+
220
+  if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
221
+    if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
222
+      log_msg "WARN" "Stop requested. Will stop after current file. Press Ctrl+C again to abort current encode immediately."
223
+    else
224
+      log_msg "WARN" "Stop requested. Exiting before next file."
225
+    fi
226
+    return
227
+  fi
228
+
229
+  if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
230
+    log_msg "WARN" "Force-stopping current encode (pid=$CURRENT_FFMPEG_PID)."
231
+    kill -INT "$CURRENT_FFMPEG_PID" 2>/dev/null || true
232
+  fi
233
+}
234
+
235
+run_ffmpeg_with_signal_guard() {
236
+  (
237
+    trap '' INT TERM
238
+    exec "$@"
239
+  ) &
240
+
241
+  CURRENT_FFMPEG_PID=$!
242
+  if wait "$CURRENT_FFMPEG_PID"; then
243
+    CURRENT_FFMPEG_PID=""
244
+    return 0
245
+  fi
246
+
247
+  local rc=$?
248
+  CURRENT_FFMPEG_PID=""
249
+  return "$rc"
250
+}
251
+
152 252
 format_seconds() {
153 253
   local sec="$1"
154 254
   local h m s
@@ -171,6 +271,56 @@ make_temp_log_file() {
171 271
   printf '%s\n' "$temp_path"
172 272
 }
173 273
 
274
+make_temp_output_file() {
275
+  local target_file="$1"
276
+  local target_dir target_base target_noext temp_path seed_path
277
+
278
+  target_dir="$(dirname "$target_file")"
279
+  target_base="$(basename "$target_file")"
280
+  target_noext="${target_base%.*}"
281
+
282
+  seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
283
+  if [[ -n "$seed_path" ]]; then
284
+    rm -f -- "$seed_path" 2>/dev/null || true
285
+    temp_path="${seed_path}.mp4"
286
+  else
287
+    temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
288
+    if ! touch "$temp_path" >/dev/null 2>&1; then
289
+      temp_path=""
290
+    else
291
+      rm -f -- "$temp_path" 2>/dev/null || true
292
+    fi
293
+  fi
294
+
295
+  printf '%s\n' "$temp_path"
296
+}
297
+
298
+cleanup_transcode_artifacts() {
299
+  local temp_output="$1"
300
+  local final_output="$2"
301
+
302
+  rm -f -- "$temp_output" 2>/dev/null || true
303
+
304
+  # Defensive cleanup for previously failed non-atomic runs.
305
+  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
306
+    rm -f -- "$final_output" 2>/dev/null || true
307
+  fi
308
+}
309
+
310
+is_apple_noise_file() {
311
+  local file_path="$1"
312
+  local base
313
+  base="$(basename "$file_path")"
314
+
315
+  case "$base" in
316
+    ._*|.DS_Store|.AppleDouble|._.DS_Store)
317
+      return 0
318
+      ;;
319
+  esac
320
+
321
+  return 1
322
+}
323
+
174 324
 require_value() {
175 325
   local flag="$1"
176 326
   local value="${2:-}"
@@ -262,10 +412,35 @@ parse_args() {
262 412
         VERBOSE=true
263 413
         shift
264 414
         ;;
415
+      --delete-source)
416
+        MOVE_SOURCE=true
417
+        shift
418
+        ;;
419
+      --keep-going)
420
+        FAIL_FAST=false
421
+        shift
422
+        ;;
423
+      --unattended)
424
+        MOVE_SOURCE=true
425
+        FAIL_FAST=false
426
+        shift
427
+        ;;
428
+      --no-apple-repack-fallback)
429
+        APPLE_REPACK_FALLBACK=false
430
+        shift
431
+        ;;
432
+      --apple-repack-fallback)
433
+        APPLE_REPACK_FALLBACK=true
434
+        shift
435
+        ;;
265 436
       --move-source)
266 437
         MOVE_SOURCE=true
267 438
         shift
268 439
         ;;
440
+      --continue-on-error)
441
+        FAIL_FAST=false
442
+        shift
443
+        ;;
269 444
       -h|--help)
270 445
         usage
271 446
         exit 0
@@ -294,6 +469,72 @@ check_tools() {
294 469
   command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
295 470
   command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
296 471
   command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
472
+
473
+  HAS_AVCONVERT=false
474
+  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
475
+    HAS_AVCONVERT=true
476
+  fi
477
+}
478
+
479
+make_temp_repack_file() {
480
+  local base_tmp="${TMPDIR:-/tmp}"
481
+  local seed_path temp_path
482
+
483
+  base_tmp="${base_tmp%/}"
484
+  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
485
+  if [[ -n "$seed_path" ]]; then
486
+    rm -f -- "$seed_path" 2>/dev/null || true
487
+    temp_path="${seed_path}.mp4"
488
+    printf '%s\n' "$temp_path"
489
+    return
490
+  fi
491
+
492
+  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
493
+  printf '%s\n' "$temp_path"
494
+}
495
+
496
+cleanup_repacked_input() {
497
+  local repacked_file="$1"
498
+  if [[ -n "$repacked_file" ]]; then
499
+    rm -f -- "$repacked_file" 2>/dev/null || true
500
+  fi
501
+}
502
+
503
+try_apple_repack_for_unreadable() {
504
+  local source_file="$1"
505
+  local repacked_file=""
506
+
507
+  REPACKED_SOURCE_PATH=""
508
+
509
+  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
510
+    return 1
511
+  fi
512
+
513
+  repacked_file="$(make_temp_repack_file)"
514
+  if [[ -z "$repacked_file" ]]; then
515
+    return 1
516
+  fi
517
+
518
+  if [[ "$VERBOSE" == true ]]; then
519
+    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
520
+    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
521
+      cleanup_repacked_input "$repacked_file"
522
+      return 1
523
+    fi
524
+  else
525
+    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
526
+      cleanup_repacked_input "$repacked_file"
527
+      return 1
528
+    fi
529
+  fi
530
+
531
+  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
532
+    REPACKED_SOURCE_PATH="$repacked_file"
533
+    return 0
534
+  fi
535
+
536
+  cleanup_repacked_input "$repacked_file"
537
+  return 1
297 538
 }
298 539
 
299 540
 restore_metadata_with_exiftool() {
@@ -493,6 +734,72 @@ ffprobe_video_codec_or_empty() {
493 734
   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
494 735
 }
495 736
 
737
+source_video_is_readable() {
738
+  local file_path="$1"
739
+  REPACKED_SOURCE_PATH=""
740
+
741
+  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
742
+    SOURCE_READABLE_MODE="normal"
743
+    return 0
744
+  fi
745
+
746
+  if [[ "$VERBOSE" == true ]]; then
747
+    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
748
+  fi
749
+
750
+  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
751
+    SOURCE_READABLE_MODE="tolerant"
752
+    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
753
+    return 0
754
+  fi
755
+
756
+  if try_apple_repack_for_unreadable "$file_path"; then
757
+    SOURCE_READABLE_MODE="repacked"
758
+    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
759
+    return 0
760
+  fi
761
+
762
+  SOURCE_READABLE_MODE="normal"
763
+  return 1
764
+}
765
+
766
+source_error_from_ffmpeg_log() {
767
+  local ffmpeg_log="$1"
768
+  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
769
+  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"
770
+}
771
+
772
+destination_cannot_accept_file() {
773
+  local input_file="$1"
774
+  local out_dir="$2"
775
+  local ffmpeg_log="$3"
776
+  local probe_path=""
777
+  local avail_kb=""
778
+  local avail_bytes=0
779
+
780
+  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
781
+    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
782
+      return 0
783
+    fi
784
+  fi
785
+
786
+  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
787
+  if [[ -z "$probe_path" ]]; then
788
+    return 0
789
+  fi
790
+  rm -f -- "$probe_path" 2>/dev/null || true
791
+
792
+  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
793
+  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
794
+    avail_bytes=$((avail_kb * 1024))
795
+    if [[ "$avail_bytes" -lt 67108864 ]]; then
796
+      return 0
797
+    fi
798
+  fi
799
+
800
+  return 1
801
+}
802
+
496 803
 validate_transcoded_output() {
497 804
   local input_file="$1"
498 805
   local output_file="$2"
@@ -619,6 +926,10 @@ collect_video_files() {
619 926
   build_find_expr_for_extensions
620 927
 
621 928
   while IFS= read -r -d '' file; do
929
+    if is_apple_noise_file "$file"; then
930
+      vlog_msg "SKIP" "Ignoring Apple artifact: $file"
931
+      continue
932
+    fi
622 933
     VIDEO_FILES+=("$file")
623 934
   done < <(
624 935
     if [[ "$RECURSIVE" == true ]]; then
@@ -634,7 +945,10 @@ collect_video_files() {
634 945
 
635 946
 process_video_file() {
636 947
   local input_file="$1"
637
-  local rel_path output_file out_dir
948
+  local rel_path output_file temp_output_file out_dir
949
+  local display_path
950
+  local encode_input_file
951
+  local repacked_input_file=""
638 952
   local file_started_at file_ended_at file_real_elapsed_sec
639 953
   local encode_started_at encode_ended_at encode_elapsed_sec=0
640 954
   local post_elapsed_sec
@@ -645,17 +959,38 @@ process_video_file() {
645 959
   rel_path="$(rel_path_from_source "$input_file")"
646 960
   output_file="$DEST_DIR/${rel_path%.*}.mp4"
647 961
   out_dir="$(dirname "$output_file")"
962
+  display_path="${input_file#$PWD/}"
963
+  encode_input_file="$input_file"
648 964
 
649 965
   mkdir -p "$out_dir"
650 966
 
967
+  if ! source_video_is_readable "$input_file"; then
968
+    ERRORS=$((ERRORS + 1))
969
+    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
970
+    if [[ "$VERBOSE" == true ]]; then
971
+      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
972
+    else
973
+      log_progress_start "$display_path"
974
+      log_progress_skipped_unreadable "$display_path"
975
+    fi
976
+    return 2
977
+  fi
978
+
979
+  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
980
+    repacked_input_file="$REPACKED_SOURCE_PATH"
981
+    encode_input_file="$REPACKED_SOURCE_PATH"
982
+    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
983
+  fi
984
+
651 985
   if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
652 986
     vlog_msg "SKIP" "Video exists: $output_file"
653 987
     VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
988
+    cleanup_repacked_input "$repacked_input_file"
654 989
     return
655 990
   fi
656 991
 
657 992
   local has_audio=false
658
-  if probe_has_audio "$input_file"; then
993
+  if probe_has_audio "$encode_input_file"; then
659 994
     has_audio=true
660 995
   fi
661 996
 
@@ -667,20 +1002,31 @@ process_video_file() {
667 1002
 
668 1003
   build_video_args
669 1004
 
1005
+  temp_output_file="$(make_temp_output_file "$output_file")"
1006
+  if [[ -z "$temp_output_file" ]]; then
1007
+    ERRORS=$((ERRORS + 1))
1008
+    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1009
+    log_msg "ERROR" "Could not create temporary output file: $output_file"
1010
+    return 3
1011
+  fi
1012
+
670 1013
   local cmd=(ffmpeg -hide_banner)
1014
+  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1015
+    cmd+=( -fflags +genpts -err_detect ignore_err )
1016
+  fi
671 1017
   if [[ "$OVERWRITE" == true ]]; then
672 1018
     cmd+=( -y )
673 1019
   else
674 1020
     cmd+=( -n )
675 1021
   fi
676 1022
 
677
-  cmd+=( -i "$input_file" -map 0:v:0 )
1023
+  cmd+=( -i "$encode_input_file" -map 0:v:0 )
678 1024
 
679 1025
   if [[ "$has_audio" == true ]]; then
680 1026
     cmd+=( -map 0:a? -c:a aac -b:a 128k )
681 1027
   fi
682 1028
 
683
-  cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$output_file" )
1029
+  cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$temp_output_file" )
684 1030
 
685 1031
   if [[ "$VERBOSE" == true ]]; then
686 1032
     log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
@@ -691,28 +1037,33 @@ process_video_file() {
691 1037
     return 0
692 1038
   fi
693 1039
 
694
-  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
1040
+  if [[ "$VERBOSE" != true ]]; then
1041
+    log_progress_start "$display_path"
1042
+  fi
1043
+
1044
+  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
695 1045
   encode_started_at="$(date +%s)"
696 1046
   vlog_msg "CHECKPOINT" "encode_start: $input_file"
697 1047
 
698 1048
   local ffmpeg_rc=0
1049
+  local ffmpeg_log=""
699 1050
   if [[ "$VERBOSE" == true ]]; then
700 1051
     # Verbose: show ffmpeg output directly
701
-    if "${cmd[@]}"; then
1052
+    if run_ffmpeg_with_signal_guard "${cmd[@]}"; then
702 1053
       :
703 1054
     else
704 1055
       ffmpeg_rc=$?
705 1056
     fi
706 1057
   else
707 1058
     # Quiet (default): redirect ffmpeg output; keep log on failure
708
-    local ffmpeg_log
709 1059
     ffmpeg_log="$(make_temp_log_file)"
710 1060
     if [[ -z "$ffmpeg_log" ]]; then
711 1061
       ERRORS=$((ERRORS + 1))
1062
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
712 1063
       log_msg "ERROR" "Could not create temporary ffmpeg log file"
713
-      return 1
1064
+      return 3
714 1065
     fi
715
-    if "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
1066
+    if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
716 1067
       rm -f "$ffmpeg_log"
717 1068
     else
718 1069
       ffmpeg_rc=$?
@@ -724,6 +1075,14 @@ process_video_file() {
724 1075
   vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
725 1076
 
726 1077
   if [[ "$ffmpeg_rc" -ne 0 ]]; then
1078
+    local failure_rc=1
1079
+    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1080
+      failure_rc=3
1081
+    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1082
+      failure_rc=2
1083
+    fi
1084
+
1085
+    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
727 1086
     file_ended_at="$(date +%s)"
728 1087
     file_real_elapsed_sec=$((file_ended_at - file_started_at))
729 1088
     local encode_elapsed_fmt
@@ -732,42 +1091,96 @@ process_video_file() {
732 1091
     real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
733 1092
 
734 1093
     ERRORS=$((ERRORS + 1))
1094
+    if [[ "$failure_rc" -eq 2 ]]; then
1095
+      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1096
+    elif [[ "$failure_rc" -eq 3 ]]; then
1097
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1098
+    fi
735 1099
     if [[ "$VERBOSE" == true ]]; then
736 1100
       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)"
737 1101
     else
738
-      local ts
739
-      ts="$(date '+%Y-%m-%d %H:%M:%S')"
740
-      echo "$ts : Transcoding $input_file ... FAILED after ${file_real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
741
-      rm -f "${ffmpeg_log:-}"
1102
+      log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log"
742 1103
     fi
743
-    return 1
1104
+    if [[ "$failure_rc" -eq 3 ]]; then
1105
+      log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1106
+    fi
1107
+    cleanup_repacked_input "$repacked_input_file"
1108
+    return "$failure_rc"
744 1109
   fi
745 1110
 
746 1111
   TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
747 1112
 
748
-  if ! restore_metadata_with_exiftool "$input_file" "$output_file"; then
1113
+  if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then
1114
+    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1115
+    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1116
+      cleanup_repacked_input "$repacked_input_file"
1117
+      return 4
1118
+    fi
749 1119
     ERRORS=$((ERRORS + 1))
750
-    return 1
1120
+    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1121
+    cleanup_repacked_input "$repacked_input_file"
1122
+    return 3
751 1123
   fi
752 1124
 
753
-  if ! map_garmin_model_to_standard_tags "$input_file" "$output_file"; then
1125
+  if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then
1126
+    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1127
+    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1128
+      cleanup_repacked_input "$repacked_input_file"
1129
+      return 4
1130
+    fi
754 1131
     ERRORS=$((ERRORS + 1))
755
-    return 1
1132
+    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1133
+    cleanup_repacked_input "$repacked_input_file"
1134
+    return 3
756 1135
   fi
757 1136
 
758
-  if ! write_transcode_encoder_metadata "$output_file"; then
1137
+  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1138
+    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1139
+    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1140
+      cleanup_repacked_input "$repacked_input_file"
1141
+      return 4
1142
+    fi
759 1143
     ERRORS=$((ERRORS + 1))
760
-    return 1
1144
+    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1145
+    cleanup_repacked_input "$repacked_input_file"
1146
+    return 3
761 1147
   fi
762 1148
 
763 1149
   if [[ "$MOVE_SOURCE" == true ]]; then
764
-    if ! validate_transcoded_output "$input_file" "$output_file"; then
1150
+    if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then
1151
+      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1152
+      if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1153
+        cleanup_repacked_input "$repacked_input_file"
1154
+        return 4
1155
+      fi
765 1156
       ERRORS=$((ERRORS + 1))
766
-      return 1
1157
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1158
+      cleanup_repacked_input "$repacked_input_file"
1159
+      return 3
767 1160
     fi
768 1161
   fi
769 1162
 
770
-  touch -r "$input_file" "$output_file" || true
1163
+  touch -r "$input_file" "$temp_output_file" || true
1164
+
1165
+  if [[ "$OVERWRITE" == true ]]; then
1166
+    if ! mv -f "$temp_output_file" "$output_file"; then
1167
+      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1168
+      ERRORS=$((ERRORS + 1))
1169
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1170
+      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1171
+      cleanup_repacked_input "$repacked_input_file"
1172
+      return 3
1173
+    fi
1174
+  else
1175
+    if ! mv -n "$temp_output_file" "$output_file"; then
1176
+      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1177
+      ERRORS=$((ERRORS + 1))
1178
+      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1179
+      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1180
+      cleanup_repacked_input "$repacked_input_file"
1181
+      return 3
1182
+    fi
1183
+  fi
771 1184
 
772 1185
   if [[ "$MOVE_SOURCE" == true ]]; then
773 1186
     if rm -f "$input_file"; then
@@ -775,6 +1188,7 @@ process_video_file() {
775 1188
     else
776 1189
       ERRORS=$((ERRORS + 1))
777 1190
       log_msg "ERROR" "Failed to remove source after validation: $input_file"
1191
+      cleanup_repacked_input "$repacked_input_file"
778 1192
       return 1
779 1193
     fi
780 1194
   fi
@@ -793,10 +1207,11 @@ process_video_file() {
793 1207
   if [[ "$VERBOSE" == true ]]; then
794 1208
     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"))"
795 1209
   else
796
-    local display_path="${input_file#$PWD/}"
797
-    log_progress "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
1210
+    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
798 1211
   fi
799 1212
 
1213
+  cleanup_repacked_input "$repacked_input_file"
1214
+
800 1215
   return 0
801 1216
 }
802 1217
 
@@ -862,6 +1277,10 @@ copy_sidecars_json() {
862 1277
 
863 1278
   local jf
864 1279
   for jf in "${json_files[@]}"; do
1280
+    if is_apple_noise_file "$jf"; then
1281
+      vlog_msg "SKIP" "Ignoring Apple artifact JSON: $jf"
1282
+      continue
1283
+    fi
865 1284
     copy_one_json "$jf"
866 1285
   done
867 1286
 }
@@ -907,6 +1326,8 @@ main() {
907 1326
   local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
908 1327
   total_started_at="$(date +%s)"
909 1328
 
1329
+  trap 'handle_interrupt' INT TERM
1330
+
910 1331
   parse_args "$@"
911 1332
   check_tools
912 1333
 
@@ -928,19 +1349,51 @@ main() {
928 1349
   resolve_encoder
929 1350
 
930 1351
   collect_video_files
931
-  if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
1352
+  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
932 1353
     log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
933 1354
   fi
934 1355
 
935 1356
   local f
936
-  for f in "${VIDEO_FILES[@]}"; do
937
-    if ! process_video_file "$f"; then
938
-      log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1357
+  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
1358
+    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1359
+      log_msg "INFO" "Stop requested; ending before next file"
939 1360
       break
940 1361
     fi
1362
+
1363
+    local process_rc=0
1364
+    if process_video_file "$f"; then
1365
+      continue
1366
+    else
1367
+      process_rc=$?
1368
+    fi
1369
+
1370
+    case "$process_rc" in
1371
+      2)
1372
+        log_msg "INFO" "Continuing after unreadable/corrupted source file"
1373
+        continue
1374
+        ;;
1375
+      3)
1376
+        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
1377
+        break
1378
+        ;;
1379
+      4)
1380
+        log_msg "INFO" "Stopped by user after current file"
1381
+        break
1382
+        ;;
1383
+      *)
1384
+        if [[ "$FAIL_FAST" == true ]]; then
1385
+          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
1386
+          break
1387
+        fi
1388
+        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
1389
+        continue
1390
+        ;;
1391
+    esac
941 1392
   done
942 1393
 
943
-  if [[ "$ERRORS" -eq 0 ]]; then
1394
+  if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1395
+    log_msg "INFO" "Skipping sidecar copy and manifest because run was stopped by user"
1396
+  elif [[ "$ERRORS" -eq 0 ]]; then
944 1397
     copy_sidecars_json
945 1398
     write_manifest
946 1399
   else
@@ -975,7 +1428,7 @@ main() {
975 1428
   fi
976 1429
   run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
977 1430
 
978
-  log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
1431
+  log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED invalid_sources_skipped=$INVALID_SOURCES_SKIPPED destination_failures=$DESTINATION_FAILURES json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
979 1432
   log_msg "INFO" "Timing: run_real=${total_elapsed_sec}s / $total_elapsed_fmt, files_real_total=${TOTAL_FILE_REAL_TIME_SEC}s / $(format_seconds "$TOTAL_FILE_REAL_TIME_SEC"), files_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), files_post_total=${file_post_total_sec}s / $file_post_total_fmt, run_non_file_overhead=${run_non_file_overhead_sec}s / $run_non_file_overhead_fmt"
980 1433
   log_msg "INFO" "Timing(avg): file_real_avg=${avg_file_real_time_sec}s / $avg_file_real_time_fmt, file_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
981 1434