VariaReEncoder / garmin_varia_transcode.sh
Newer Older
2118 lines | 68.296kb
Bogdan Timofte authored a month ago
1
#!/usr/bin/env bash
Bogdan Timofte authored 2 weeks ago
2
set -Eeuo pipefail
Bogdan Timofte authored a month ago
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 2 weeks ago
22
DEFAULT_PREFLIGHT_TIMEOUT_SEC=10
Bogdan Timofte authored a month ago
23

            
Bogdan Timofte authored a month ago
24
# Garmin publicly declares a 140-degree field of view.
25
# We infer practical focal metadata from this single spec.
26
# 35mm-equivalent focal length is integer-only in EXIF, so we round to 7 mm.
27
GARMIN_DECLARED_FOV_DEG=140
28
GARMIN_INFERRED_FOCAL_MM="6.5"
29
GARMIN_INFERRED_FOCAL_35MM_MM="7"
30

            
Bogdan Timofte authored a month ago
31
SOURCE_DIR="$DEFAULT_SOURCE"
32
DEST_DIR="$DEFAULT_SOURCE"
Bogdan Timofte authored a month ago
33
STAGING_DIR=""
Bogdan Timofte authored a month ago
34
MODE="$DEFAULT_MODE"
35
EXTENSIONS_CSV="$DEFAULT_EXTENSIONS"
36
CRF_OVERRIDE=""
37
OVERWRITE=true
38
DRY_RUN=false
39
RECURSIVE=true
40
SINGLE_FILE=""
41
VERBOSE=false
42
MOVE_SOURCE=false
Bogdan Timofte authored 2 weeks ago
43
UNATTENDED=false
Bogdan Timofte authored a month ago
44
SOURCE_PROVIDED=false
45
DEST_PROVIDED=false
Bogdan Timofte authored a month ago
46
STAGING_PROVIDED=false
Bogdan Timofte authored 2 weeks ago
47
STAGE_INPUT=false
Bogdan Timofte authored a month ago
48
STAGING_RAMDISK_MB="$DEFAULT_STAGING_RAMDISK_MB"
49
AUTO_CREATED_STAGING_RAMDISK=false
50
AUTO_CREATED_STAGING_PATH=""
Bogdan Timofte authored a month ago
51
AUTO_CREATED_STAGING_DEV=""
Bogdan Timofte authored a month ago
52
STAGING_RAMDISK_CREATED_AT=0
Bogdan Timofte authored 2 weeks ago
53
STAGING_RAMDISK_CREATE_ERROR=""
Bogdan Timofte authored a month ago
54
AUTO_STAGING_CLEANED_UP=false
Bogdan Timofte authored a month ago
55
RUN_STARTED_AT=0
56
FIRST_ENCODE_STARTED_AT=0
57
DEBUG_TIMING_LIMIT=0
58
DEBUG_TIMING_FILES=0
59
DEBUG_TIMING_STOPPED=false
Bogdan Timofte authored a month ago
60
FAIL_FAST=true
61
SOURCE_READABLE_MODE="normal"
62
APPLE_REPACK_FALLBACK=true
63
REPACKED_SOURCE_PATH=""
Bogdan Timofte authored 2 weeks ago
64
STAGED_INPUT_PATH=""
65
STAGED_INPUT_FAILURE_RC=0
Bogdan Timofte authored a month ago
66

            
67
HAS_VIDEOTOOLBOX=false
68
HAS_LIBX265=false
69
HAS_LIBX264=false
Bogdan Timofte authored a month ago
70
HAS_AVCONVERT=false
Bogdan Timofte authored a month ago
71

            
72
ENCODER_KIND=""
73
VIDEO_CODEC=""
74
VIDEO_CRF=""
75
VIDEO_ARGS=()
76
FIND_EXT_EXPR=()
Bogdan Timofte authored a month ago
77
EXT_LIST=()
78
VIDEO_FILES=()
Bogdan Timofte authored a month ago
79

            
80
VIDEOS_PROCESSED=0
81
VIDEOS_SKIPPED=0
82
ERRORS=0
Bogdan Timofte authored a month ago
83
INVALID_SOURCES_SKIPPED=0
84
DESTINATION_FAILURES=0
Bogdan Timofte authored a month ago
85
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
86
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
87
INPUT_BYTES_PROCESSED=0
88
OUTPUT_BYTES_PROCESSED=0
Bogdan Timofte authored a month ago
89

            
90
DURATION_TOLERANCE_SEC=1.0
Bogdan Timofte authored 2 weeks ago
91
PREFLIGHT_TIMEOUT_SEC="$DEFAULT_PREFLIGHT_TIMEOUT_SEC"
Bogdan Timofte authored a month ago
92
STOP_AFTER_CURRENT=false
93
INTERRUPT_COUNT=0
94
CURRENT_FFMPEG_PID=""
95
PROGRESS_LINE_OPEN=false
Bogdan Timofte authored 2 weeks ago
96
FINAL_REPORT_PRINTED=false
97
MAIN_FLOW_STARTED=false
Bogdan Timofte authored a month ago
98

            
99
usage() {
100
  cat <<'EOF'
101
Usage:
102
  garmin_varia_transcode.sh [options]
103

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

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
136
Encoding Modes:
137
  hardware   Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only.
138
             ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro).
139
             Best choice when running on battery or transcoding large libraries.
140
             Output quality is good for dashcam/action footage.
141
             Falls back to an error on Linux or if videotoolbox is absent.
142

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

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

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

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

            
166
Examples:
167
  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
168
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
169
  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
170
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
171
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
Bogdan Timofte authored a month ago
172
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --delete-source
173
  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --unattended
Bogdan Timofte authored a month ago
174
EOF
175
}
176

            
177
log_msg() {
178
  local level="$1"
179
  shift
180
  local ts
181
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
182
  echo "[$ts] [$level] $*"
183
}
184

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
203
# Verbose-only log: suppressed in default quiet mode
204
vlog_msg() {
205
  [[ "$VERBOSE" == true ]] && log_msg "$@"
206
  return 0
207
}
208

            
Bogdan Timofte authored a month ago
209
# Quiet-mode per-file progress lines
210
log_progress_start() {
Bogdan Timofte authored a month ago
211
  local input_file="$1"
Bogdan Timofte authored a month ago
212
  local ts
213
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
214
  printf '[%s] [INFO] Transcoding %s ...' "$ts" "$input_file"
Bogdan Timofte authored a month ago
215
  PROGRESS_LINE_OPEN=true
216
}
217

            
218
log_progress_done() {
Bogdan Timofte authored a month ago
219
  local real_elapsed_sec="$2"
220
  local encode_elapsed_sec="$3"
Bogdan Timofte authored a month ago
221
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
222
    printf ' done in %ss (encode %ss)\n' "$real_elapsed_sec" "$encode_elapsed_sec"
223
    PROGRESS_LINE_OPEN=false
224
    return
225
  fi
226

            
227
  local input_file="$1"
Bogdan Timofte authored a month ago
228
  local ts
229
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
230
  echo "[$ts] [INFO] Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
231
}
232

            
Bogdan Timofte authored a month ago
233
log_progress_failed() {
234
  local input_file="$1"
235
  local real_elapsed_sec="$2"
236
  local encode_elapsed_sec="$3"
237
  local ffmpeg_rc="$4"
238
  local ffmpeg_log="$5"
239

            
240
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
241
    printf ' FAILED after %ss (encode %ss, rc=%s, log=%s)\n' "$real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "${ffmpeg_log:-n/a}"
242
    PROGRESS_LINE_OPEN=false
243
    return
244
  fi
245

            
246
  local ts
247
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
248
  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
249
}
250

            
251
log_progress_skipped_unreadable() {
252
  local input_file="$1"
253

            
254
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
255
    printf ' SKIPPED (unreadable/corrupted source)\n'
256
    PROGRESS_LINE_OPEN=false
257
    return
258
  fi
259

            
260
  local ts
261
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
262
  echo "[$ts] [INFO] Transcoding $input_file ... SKIPPED (unreadable/corrupted source)"
Bogdan Timofte authored a month ago
263
}
264

            
Bogdan Timofte authored a month ago
265
die() {
266
  log_msg "ERROR" "$*"
267
  exit 1
268
}
269

            
Bogdan Timofte authored a month ago
270
handle_interrupt() {
271
  INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
272
  STOP_AFTER_CURRENT=true
273

            
Bogdan Timofte authored a month ago
274
  # If quiet-mode progress is mid-line, break it before interrupt logs.
275
  if [[ "$PROGRESS_LINE_OPEN" == true ]]; then
276
    printf '\n'
277
    PROGRESS_LINE_OPEN=false
278
  fi
279

            
Bogdan Timofte authored a month ago
280
  if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
Bogdan Timofte authored a month ago
281
    # ffmpeg is in the same process group and already received SIGINT; it will
282
    # stop gracefully on its own.  A second Ctrl+C force-kills it if needed.
283
    log_msg "WARN" "Interrupted; stopping after current encode. Ctrl+C again to force-kill."
284
  elif [[ -n "$CURRENT_FFMPEG_PID" ]]; then
285
    log_msg "WARN" "Force-stopping encode (pid=$CURRENT_FFMPEG_PID)."
286
    kill -9 "$CURRENT_FFMPEG_PID" 2>/dev/null || true
Bogdan Timofte authored a month ago
287
  fi
Bogdan Timofte authored a month ago
288
}
Bogdan Timofte authored a month ago
289

            
Bogdan Timofte authored a month ago
290
# Drain all pending keystrokes from /dev/tty (non-blocking) and set
291
# STOP_AFTER_CURRENT if any of them is 'q'/'Q'.  Self-contained: saves/restores
292
# stty settings so it works regardless of the terminal's current mode.
293
# Uses min=0 time=0 so the terminal's read() returns immediately when the
294
# buffer is empty (bash treats a 0-byte read as EOF and exits the loop).
295
# -t 1 is a safety net for platforms where select() on a VMIN=0 fd does not
296
# report readable-when-empty; in that case we block at most 1s then exit.
297
check_for_quit_key() {
Bogdan Timofte authored 2 weeks ago
298
  [[ "$UNATTENDED" == true ]] && return 0
299
  [[ -t 0 ]] || return 0
Bogdan Timofte authored a month ago
300
  [[ -e /dev/tty ]] || return 0
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored a month ago
309
    if [[ "$key" == "q" || "$key" == "Q" ]]; then
310
      found=true
311
      break
312
    fi
313
  done
Bogdan Timofte authored 2 weeks ago
314
  stty "$old_stty" <&"$tty_fd" 2>/dev/null || true
315
  exec {tty_fd}<&-
Bogdan Timofte authored a month ago
316
  if [[ "$found" == true ]]; then
317
    STOP_AFTER_CURRENT=true
318
    log_msg "INFO" "Quit key pressed; stopping before next file"
Bogdan Timofte authored a month ago
319
  fi
Bogdan Timofte authored 2 weeks ago
320
  return 0
Bogdan Timofte authored a month ago
321
}
322

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

            
330
  CURRENT_FFMPEG_PID=$!
331
  if wait "$CURRENT_FFMPEG_PID"; then
332
    CURRENT_FFMPEG_PID=""
333
    return 0
334
  fi
335

            
336
  local rc=$?
337
  CURRENT_FFMPEG_PID=""
338
  return "$rc"
339
}
340

            
Bogdan Timofte authored a month ago
341
format_seconds() {
342
  local sec="$1"
343
  local h m s
344
  h=$((sec / 3600))
345
  m=$(((sec % 3600) / 60))
346
  s=$((sec % 60))
347
  printf '%02d:%02d:%02d' "$h" "$m" "$s"
348
}
349

            
Bogdan Timofte authored a month ago
350
format_bytes_human() {
351
  local bytes="$1"
352
  awk -v b="$bytes" 'BEGIN {
353
    split("B KiB MiB GiB TiB PiB", u, " ");
354
    i = 1;
355
    while (b >= 1024 && i < 6) {
356
      b = b / 1024;
357
      i++;
358
    }
359
    if (i == 1) {
360
      printf "%d %s", b, u[i];
361
    } else {
362
      printf "%.2f %s", b, u[i];
363
    }
364
  }'
365
}
366

            
Bogdan Timofte authored a month ago
367
print_final_report() {
368
  local total_elapsed_sec="$1"
369
  local total_elapsed_fmt="$2"
370
  local file_post_total_sec="$3"
371
  local file_post_total_fmt="$4"
372
  local run_non_file_overhead_sec="$5"
373
  local run_non_file_overhead_fmt="$6"
374
  local avg_file_real_time_sec="$7"
375
  local avg_file_real_time_fmt="$8"
376
  local avg_video_time_sec="$9"
377
  local avg_video_time_fmt="${10}"
Bogdan Timofte authored a month ago
378
  local startup_warmup_sec="${11}"
379
  local startup_warmup_fmt="${12}"
380
  local staging_warmup_sec="${13}"
381
  local staging_warmup_fmt="${14}"
382
  local input_bytes_processed="${15}"
383
  local output_bytes_processed="${16}"
Bogdan Timofte authored a month ago
384

            
385
  printf '\n'
386
  printf 'Run Summary\n'
387
  printf '+---------------------------+-------+\n'
388
  printf '| %-25s | %-5s |\n' "Metric" "Value"
389
  printf '+---------------------------+-------+\n'
390
  printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED"
391
  printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED"
392
  printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED"
393
  printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES"
394
  printf '| %-25s | %5s |\n' "Errors" "$ERRORS"
395
  printf '+---------------------------+-------+\n'
396

            
397
  printf '\nTimings\n'
398
  printf '+---------------------------+---------+----------+\n'
399
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
400
  printf '+---------------------------+---------+----------+\n'
401
  printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt"
402
  printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")"
403
  printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")"
404
  printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt"
405
  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
406
  if [[ "$startup_warmup_sec" -ge 0 ]]; then
407
    printf '| %-25s | %7s | %-8s |\n' "Startup warm-up" "${startup_warmup_sec}s" "$startup_warmup_fmt"
408
  fi
409
  if [[ "$staging_warmup_sec" -ge 0 ]]; then
410
    printf '| %-25s | %7s | %-8s |\n' "Staging warm-up" "${staging_warmup_sec}s" "$staging_warmup_fmt"
411
  fi
Bogdan Timofte authored a month ago
412
  printf '+---------------------------+---------+----------+\n'
413

            
414
  printf '\nPer-File Averages\n'
415
  printf '+---------------------------+---------+----------+\n'
416
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
417
  printf '+---------------------------+---------+----------+\n'
418
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
419
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
420
  printf '+---------------------------+---------+----------+\n'
421
}
422

            
Bogdan Timofte authored a month ago
423
make_temp_log_file() {
424
  local temp_path
425
  local base_tmp="${TMPDIR:-/tmp}"
426
  base_tmp="${base_tmp%/}"
427

            
428
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
429
  if [[ -z "$temp_path" ]]; then
430
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
431
  fi
432

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

            
Bogdan Timofte authored a month ago
436
make_temp_output_file() {
437
  local target_file="$1"
Bogdan Timofte authored a month ago
438
  local preferred_dir="${2:-}"
439
  local target_dir target_base target_noext temp_path seed_path work_dir
Bogdan Timofte authored a month ago
440

            
441
  target_dir="$(dirname "$target_file")"
442
  target_base="$(basename "$target_file")"
443
  target_noext="${target_base%.*}"
Bogdan Timofte authored a month ago
444
  work_dir="$target_dir"
445

            
446
  if [[ -n "$preferred_dir" ]]; then
447
    work_dir="$preferred_dir"
448
  fi
449

            
450
  seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
451
  if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then
452
    seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
453
  fi
Bogdan Timofte authored a month ago
454

            
455
  if [[ -n "$seed_path" ]]; then
456
    rm -f -- "$seed_path" 2>/dev/null || true
457
    temp_path="${seed_path}.mp4"
458
  else
Bogdan Timofte authored a month ago
459
    temp_path="$work_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
Bogdan Timofte authored a month ago
460
    if ! touch "$temp_path" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
461
      if [[ "$work_dir" != "$target_dir" ]]; then
462
        temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
463
        if ! touch "$temp_path" >/dev/null 2>&1; then
464
          temp_path=""
465
        else
466
          rm -f -- "$temp_path" 2>/dev/null || true
467
        fi
468
      else
469
        temp_path=""
470
      fi
Bogdan Timofte authored a month ago
471
    else
472
      rm -f -- "$temp_path" 2>/dev/null || true
473
    fi
474
  fi
475

            
476
  printf '%s\n' "$temp_path"
477
}
478

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
504
cleanup_transcode_artifacts() {
505
  local temp_output="$1"
506
  local final_output="$2"
Bogdan Timofte authored a month ago
507
  local exif_backup_default="${temp_output}_original"
508
  local exif_backup_alt="${temp_output}.exif_original"
Bogdan Timofte authored a month ago
509

            
510
  rm -f -- "$temp_output" 2>/dev/null || true
Bogdan Timofte authored a month ago
511
  rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true
Bogdan Timofte authored a month ago
512

            
513
  # Defensive cleanup for previously failed non-atomic runs.
514
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
515
    rm -f -- "$final_output" 2>/dev/null || true
516
  fi
517
}
518

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
532
is_apple_noise_file() {
533
  local file_path="$1"
534
  local base
535
  base="$(basename "$file_path")"
536

            
537
  case "$base" in
538
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
539
      return 0
540
      ;;
541
  esac
542

            
543
  return 1
544
}
545

            
Bogdan Timofte authored a month ago
546
require_value() {
547
  local flag="$1"
548
  local value="${2:-}"
549
  if [[ -z "$value" ]]; then
550
    die "Missing value for $flag"
551
  fi
552
}
553

            
554
to_abs_path() {
555
  local p="$1"
556
  if [[ "$p" = /* ]]; then
557
    printf '%s\n' "$p"
558
  else
559
    printf '%s\n' "$PWD/$p"
560
  fi
561
}
562

            
563
path_is_within() {
564
  local child="$1"
565
  local parent="$2"
566
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
567
}
568

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
640
file_size_bytes_or_zero() {
641
  local file_path="$1"
642
  local file_size="0"
643

            
644
  file_size="$(stat -f%z "$file_path" 2>/dev/null || true)"
645
  if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
646
    file_size="$(stat -c%s "$file_path" 2>/dev/null || true)"
647
  fi
648

            
649
  if [[ "$file_size" =~ ^[0-9]+$ ]]; then
650
    printf '%s\n' "$file_size"
651
  else
652
    printf '0\n'
653
  fi
654
}
655

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
668
dir_available_bytes_or_zero() {
669
  local dir_path="$1"
670
  local avail_kb=""
671

            
672
  avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
673
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
674
    printf '%s\n' $((avail_kb * 1024))
675
  else
676
    printf '0\n'
677
  fi
678
}
679

            
680
staging_has_space_for_input() {
681
  local staging_dir="$1"
682
  local input_file="$2"
683
  local input_size_bytes=0
684
  local needed_bytes=0
685
  local available_bytes=0
686

            
687
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
688

            
689
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
690
  if [[ "$input_size_bytes" -le 0 ]]; then
691
    return 1
692
  fi
693

            
694
  # Keep one simple rule: need roughly 2x input size in staging.
695
  # This covers temp output plus metadata rewrite overhead.
696
  needed_bytes=$((input_size_bytes * 2))
697
  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
698
  [[ "$available_bytes" -ge "$needed_bytes" ]]
699
}
700

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
723
join_cmd_for_log() {
724
  local out=""
725
  local arg
726
  for arg in "$@"; do
727
    out+="$(printf '%q' "$arg") "
728
  done
729
  printf '%s\n' "${out% }"
730
}
731

            
732
parse_args() {
733
  while [[ $# -gt 0 ]]; do
734
    case "$1" in
735
      -s|--source|--input)
736
        require_value "$1" "${2:-}"
737
        SOURCE_DIR="$2"
738
        SOURCE_PROVIDED=true
739
        shift 2
740
        ;;
741
      -d|--destination|--output)
742
        require_value "$1" "${2:-}"
743
        DEST_DIR="$2"
744
        DEST_PROVIDED=true
745
        shift 2
746
        ;;
Bogdan Timofte authored a month ago
747
      --staging-dir)
748
        require_value "$1" "${2:-}"
749
        STAGING_DIR="$2"
750
        STAGING_PROVIDED=true
751
        shift 2
752
        ;;
Bogdan Timofte authored 2 weeks ago
753
      --stage-input)
754
        STAGE_INPUT=true
755
        shift
756
        ;;
Bogdan Timofte authored a month ago
757
      --staging-ramdisk-mb)
758
        require_value "$1" "${2:-}"
759
        STAGING_RAMDISK_MB="$2"
760
        shift 2
761
        ;;
762
      --debug-timing)
763
        require_value "$1" "${2:-}"
764
        DEBUG_TIMING_LIMIT="$2"
765
        shift 2
766
        ;;
Bogdan Timofte authored a month ago
767
      --mode)
768
        require_value "$1" "${2:-}"
769
        MODE="$2"
770
        shift 2
771
        ;;
772
      --crf)
773
        require_value "$1" "${2:-}"
774
        CRF_OVERRIDE="$2"
775
        shift 2
776
        ;;
777
      --overwrite)
778
        OVERWRITE=true
779
        shift
780
        ;;
781
      --no-overwrite)
782
        OVERWRITE=false
783
        shift
784
        ;;
785
      --dry-run)
786
        DRY_RUN=true
787
        shift
788
        ;;
789
      --recursive)
790
        RECURSIVE=true
791
        shift
792
        ;;
793
      --no-recursive)
794
        RECURSIVE=false
795
        shift
796
        ;;
797
      --extensions)
798
        require_value "$1" "${2:-}"
799
        EXTENSIONS_CSV="$2"
800
        shift 2
801
        ;;
802
      --single)
803
        require_value "$1" "${2:-}"
804
        SINGLE_FILE="$2"
805
        shift 2
806
        ;;
807
      --verbose)
808
        VERBOSE=true
809
        shift
810
        ;;
Bogdan Timofte authored a month ago
811
      --delete-source)
812
        MOVE_SOURCE=true
813
        shift
814
        ;;
815
      --keep-going)
816
        FAIL_FAST=false
817
        shift
818
        ;;
819
      --unattended)
Bogdan Timofte authored 2 weeks ago
820
        UNATTENDED=true
Bogdan Timofte authored a month ago
821
        MOVE_SOURCE=true
822
        FAIL_FAST=false
823
        shift
824
        ;;
825
      --no-apple-repack-fallback)
826
        APPLE_REPACK_FALLBACK=false
827
        shift
828
        ;;
829
      --apple-repack-fallback)
830
        APPLE_REPACK_FALLBACK=true
831
        shift
832
        ;;
Bogdan Timofte authored a month ago
833
      --move-source)
834
        MOVE_SOURCE=true
835
        shift
836
        ;;
Bogdan Timofte authored a month ago
837
      --continue-on-error)
838
        FAIL_FAST=false
839
        shift
840
        ;;
Bogdan Timofte authored a month ago
841
      -h|--help)
842
        usage
843
        exit 0
844
        ;;
845
      *)
846
        die "Unknown argument: $1"
847
        ;;
848
    esac
849
  done
850

            
851
  case "$MODE" in
852
    auto|hardware|quality|compat) ;;
853
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
854
  esac
855

            
856
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
857
    die "At least one of --source or --destination must be provided"
858
  fi
859

            
860
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
861
    die "--crf must be an integer"
862
  fi
Bogdan Timofte authored a month ago
863

            
864
  if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then
865
    if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then
866
      die "--debug-timing must be a positive integer"
867
    fi
868
    if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then
869
      die "--debug-timing must be greater than 0"
870
    fi
871
  fi
872

            
873
  if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then
874
    die "--staging-ramdisk-mb must be a positive integer"
875
  fi
876
  if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
877
    die "--staging-ramdisk-mb must be greater than 0"
878
  fi
Bogdan Timofte authored 2 weeks ago
879

            
880
  if [[ "$STAGE_INPUT" != true && "$STAGING_PROVIDED" == true && "$MOVE_SOURCE" == true && "$FAIL_FAST" == false ]]; then
881
    STAGE_INPUT=true
882
  fi
Bogdan Timofte authored a month ago
883
}
884

            
885
check_tools() {
886
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
887
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
888
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
889

            
890
  HAS_AVCONVERT=false
891
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
892
    HAS_AVCONVERT=true
893
  fi
894
}
895

            
896
make_temp_repack_file() {
897
  local base_tmp="${TMPDIR:-/tmp}"
898
  local seed_path temp_path
899

            
900
  base_tmp="${base_tmp%/}"
901
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
902
  if [[ -n "$seed_path" ]]; then
903
    rm -f -- "$seed_path" 2>/dev/null || true
904
    temp_path="${seed_path}.mp4"
905
    printf '%s\n' "$temp_path"
906
    return
907
  fi
908

            
909
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
910
  printf '%s\n' "$temp_path"
911
}
912

            
913
cleanup_repacked_input() {
914
  local repacked_file="$1"
915
  if [[ -n "$repacked_file" ]]; then
916
    rm -f -- "$repacked_file" 2>/dev/null || true
917
  fi
918
}
919

            
920
try_apple_repack_for_unreadable() {
921
  local source_file="$1"
922
  local repacked_file=""
923

            
924
  REPACKED_SOURCE_PATH=""
925

            
926
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
927
    return 1
928
  fi
929

            
930
  repacked_file="$(make_temp_repack_file)"
931
  if [[ -z "$repacked_file" ]]; then
932
    return 1
933
  fi
934

            
935
  if [[ "$VERBOSE" == true ]]; then
936
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
937
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
938
      cleanup_repacked_input "$repacked_file"
939
      return 1
940
    fi
941
  else
942
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
943
      cleanup_repacked_input "$repacked_file"
944
      return 1
945
    fi
946
  fi
947

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

            
953
  cleanup_repacked_input "$repacked_file"
954
  return 1
Bogdan Timofte authored a month ago
955
}
956

            
957
restore_metadata_with_exiftool() {
958
  local input_file="$1"
959
  local output_file="$2"
960

            
961
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
962

            
963
  if [[ "$VERBOSE" == true ]]; then
964
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
965
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
966
      return 0
967
    fi
968
  else
969
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
970
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
971
      return 0
972
    fi
973
  fi
974

            
Bogdan Timofte authored a month ago
975
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
976
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
977
    return 1
978
  fi
979

            
Bogdan Timofte authored a month ago
980
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
981
  return 1
982
}
983

            
984
map_garmin_model_to_standard_tags() {
985
  local input_file="$1"
986
  local output_file="$2"
987
  local garmin_model
988

            
989
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
990
  if [[ -z "$garmin_model" ]]; then
991
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
992
    return 0
993
  fi
994

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

            
999
  if [[ "$VERBOSE" == true ]]; then
1000
    if exiftool -overwrite_original -m \
1001
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
1002
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
1003
      -Keys:CompatibleBrands="isom, iso2, mp41" \
1004
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
1005
      -Make="Garmin" -Model="Varia RCT715" \
1006
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
1007
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
1008
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
1009
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
1010
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
1011
      "$output_file"; then
1012
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
1013
      return 0
1014
    fi
1015
  else
1016
    if exiftool -overwrite_original -m -q -q \
1017
      -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \
1018
      -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \
Bogdan Timofte authored a month ago
1019
      -Keys:CompatibleBrands="isom, iso2, mp41" \
1020
      -Keys:MajorBrand="isom" \
Bogdan Timofte authored a month ago
1021
      -Make="Garmin" -Model="Varia RCT715" \
1022
      -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
1023
      -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
1024
      -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
1025
      -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \
1026
      -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
1027
      "$output_file" >/dev/null 2>&1; then
1028
      vlog_msg "CHECKPOINT" "model_map_end: $output_file"
1029
      return 0
1030
    fi
1031
  fi
1032

            
1033
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
1034
  return 1
1035
}
1036

            
1037
write_transcode_encoder_metadata() {
1038
  local output_file="$1"
1039
  local encoder_meta
1040

            
1041
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
1042

            
1043
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
1044

            
1045
  if [[ "$VERBOSE" == true ]]; then
1046
    if exiftool -overwrite_original -m \
1047
      -Software="$encoder_meta" \
1048
      -UserData:Software="$encoder_meta" \
1049
      "$output_file"; then
1050
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
1051
      return 0
1052
    fi
1053
  else
1054
    if exiftool -overwrite_original -m -q -q \
1055
      -Software="$encoder_meta" \
1056
      -UserData:Software="$encoder_meta" \
1057
      "$output_file" >/dev/null 2>&1; then
1058
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
1059
      return 0
1060
    fi
1061
  fi
1062

            
1063
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
1064
  return 1
Bogdan Timofte authored a month ago
1065
}
1066

            
1067
detect_encoders() {
1068
  local encoders
1069
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
1070

            
1071
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
1072
    HAS_VIDEOTOOLBOX=true
1073
  fi
1074
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
1075
    HAS_LIBX265=true
1076
  fi
1077
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
1078
    HAS_LIBX264=true
1079
  fi
1080

            
1081
  if [[ "$VERBOSE" == true ]]; then
1082
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
1083
  fi
1084
}
1085

            
1086
resolve_encoder() {
1087
  local os_name
1088
  os_name="$(uname -s)"
1089

            
1090
  case "$MODE" in
1091
    auto)
1092
      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
1093
        ENCODER_KIND="hardware"
1094
      elif [[ "$HAS_LIBX265" == true ]]; then
1095
        ENCODER_KIND="quality"
1096
      elif [[ "$HAS_LIBX264" == true ]]; then
1097
        ENCODER_KIND="compat"
1098
      else
1099
        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
1100
      fi
1101
      ;;
1102
    hardware)
1103
      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
1104
      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
1105
      ENCODER_KIND="hardware"
1106
      ;;
1107
    quality)
1108
      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
1109
      ENCODER_KIND="quality"
1110
      ;;
1111
    compat)
1112
      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
1113
      ENCODER_KIND="compat"
1114
      ;;
1115
  esac
1116

            
1117
  case "$ENCODER_KIND" in
1118
    hardware)
1119
      VIDEO_CODEC="hevc_videotoolbox"
1120
      VIDEO_CRF=""
1121
      ;;
1122
    quality)
1123
      VIDEO_CODEC="libx265"
1124
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
1125
      ;;
1126
    compat)
1127
      VIDEO_CODEC="libx264"
1128
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
1129
      ;;
1130
  esac
1131

            
1132
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
1133
}
1134

            
1135
probe_has_audio() {
1136
  local input_file="$1"
1137
  local out
1138
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
1139
  [[ -n "$out" ]]
1140
}
1141

            
1142
print_verbose_probe() {
1143
  local input_file="$1"
1144
  ffprobe -v error \
1145
    -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 \
1146
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
1147
}
1148

            
1149
ffprobe_duration_or_empty() {
1150
  local file_path="$1"
1151
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
1152
}
1153

            
1154
ffprobe_video_codec_or_empty() {
1155
  local file_path="$1"
1156
  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
1157
}
1158

            
Bogdan Timofte authored a month ago
1159
source_video_is_readable() {
1160
  local file_path="$1"
1161
  REPACKED_SOURCE_PATH=""
1162

            
1163
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
1164
    SOURCE_READABLE_MODE="normal"
1165
    return 0
1166
  fi
1167

            
1168
  if [[ "$VERBOSE" == true ]]; then
1169
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
1170
  fi
1171

            
1172
  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
1173
    SOURCE_READABLE_MODE="tolerant"
1174
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
1175
    return 0
1176
  fi
1177

            
1178
  if try_apple_repack_for_unreadable "$file_path"; then
1179
    SOURCE_READABLE_MODE="repacked"
1180
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
1181
    return 0
1182
  fi
1183

            
1184
  SOURCE_READABLE_MODE="normal"
1185
  return 1
1186
}
1187

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
1265
source_error_from_ffmpeg_log() {
1266
  local ffmpeg_log="$1"
1267
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
1268
  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"
1269
}
1270

            
1271
destination_cannot_accept_file() {
1272
  local input_file="$1"
1273
  local out_dir="$2"
1274
  local ffmpeg_log="$3"
1275
  local probe_path=""
1276
  local avail_kb=""
1277
  local avail_bytes=0
1278

            
1279
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
1280
    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
1281
      return 0
1282
    fi
1283
  fi
1284

            
1285
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
1286
  if [[ -z "$probe_path" ]]; then
1287
    return 0
1288
  fi
1289
  rm -f -- "$probe_path" 2>/dev/null || true
1290

            
1291
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
1292
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
1293
    avail_bytes=$((avail_kb * 1024))
1294
    if [[ "$avail_bytes" -lt 67108864 ]]; then
1295
      return 0
1296
    fi
1297
  fi
1298

            
1299
  return 1
1300
}
1301

            
Bogdan Timofte authored a month ago
1302
validate_transcoded_output() {
1303
  local input_file="$1"
1304
  local output_file="$2"
1305

            
1306
  if [[ ! -f "$output_file" ]]; then
1307
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
1308
    return 1
1309
  fi
1310

            
1311
  local expected_codec actual_codec
1312
  case "$ENCODER_KIND" in
1313
    hardware|quality) expected_codec="hevc" ;;
1314
    compat) expected_codec="h264" ;;
1315
    *)
1316
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
1317
      return 1
1318
      ;;
1319
  esac
1320

            
1321
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
1322
  if [[ -z "$actual_codec" ]]; then
1323
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
1324
    return 1
1325
  fi
1326
  if [[ "$actual_codec" != "$expected_codec" ]]; then
1327
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
1328
    return 1
1329
  fi
1330

            
1331
  local in_duration out_duration duration_delta
1332
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1333
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1334

            
1335
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
1336
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
1337
    return 1
1338
  fi
1339

            
1340
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
1341
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
1342
    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)"
1343
    return 1
1344
  fi
1345

            
1346
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
1347
  return 0
1348
}
1349

            
1350
build_video_args() {
1351
  VIDEO_ARGS=()
1352

            
1353
  case "$ENCODER_KIND" in
1354
    hardware)
1355
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
1356
      ;;
1357
    quality)
1358
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
1359
      ;;
1360
    compat)
1361
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
1362
      ;;
1363
  esac
1364
}
1365

            
1366
normalize_source_dir() {
1367
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1368
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
Bogdan Timofte authored 2 weeks ago
1369
  preflight_source_readable "$SOURCE_DIR"
Bogdan Timofte authored a month ago
1370
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1371
}
1372

            
1373
normalize_dest_dir() {
1374
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1375
}
1376

            
Bogdan Timofte authored a month ago
1377
# Sets globals: AUTO_CREATED_STAGING_RAMDISK, AUTO_CREATED_STAGING_PATH,
1378
# AUTO_CREATED_STAGING_DEV, STAGING_RAMDISK_CREATED_AT
1379
# Must be called directly (NOT inside $(...)) to preserve global assignments.
Bogdan Timofte authored a month ago
1380
create_missing_staging_ramdisk_if_needed() {
1381
  local staging_path="$1"
1382
  local ramdisk_name=""
1383
  local sectors=0
1384
  local dev=""
1385
  local mount_point=""
1386

            
Bogdan Timofte authored 2 weeks ago
1387
  STAGING_RAMDISK_CREATE_ERROR=""
Bogdan Timofte authored a month ago
1388
  staging_path="${staging_path%/}"
1389

            
1390
  if [[ -d "$staging_path" ]]; then
Bogdan Timofte authored a month ago
1391
    # Already exists; nothing to create.
Bogdan Timofte authored a month ago
1392
    return
1393
  fi
1394

            
1395
  if [[ "$(uname -s)" != "Darwin" ]]; then
Bogdan Timofte authored 2 weeks ago
1396
    STAGING_RAMDISK_CREATE_ERROR="auto-create is only supported on macOS"
Bogdan Timofte authored a month ago
1397
    return
1398
  fi
1399

            
1400
  case "$staging_path" in
1401
    /Volumes/*)
1402
      ;;
1403
    *)
Bogdan Timofte authored 2 weeks ago
1404
      STAGING_RAMDISK_CREATE_ERROR="auto-create only supports top-level /Volumes/<Name> paths"
Bogdan Timofte authored a month ago
1405
      return
1406
      ;;
1407
  esac
1408

            
1409
  ramdisk_name="$(basename "$staging_path")"
1410
  if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
Bogdan Timofte authored 2 weeks ago
1411
    STAGING_RAMDISK_CREATE_ERROR="invalid RAM disk volume name derived from staging path"
Bogdan Timofte authored a month ago
1412
    return
1413
  fi
1414

            
1415
  if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1416
    # Only auto-create for top-level /Volumes/<Name>, not nested paths.
Bogdan Timofte authored 2 weeks ago
1417
    STAGING_RAMDISK_CREATE_ERROR="auto-create only supports top-level /Volumes/<Name> paths"
Bogdan Timofte authored a month ago
1418
    return
1419
  fi
1420

            
1421
  [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk"
1422
  [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk"
1423

            
1424
  sectors=$((STAGING_RAMDISK_MB * 2048))
1425
  vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)"
1426
  dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)"
1427
  if [[ -z "$dev" ]]; then
Bogdan Timofte authored 2 weeks ago
1428
    STAGING_RAMDISK_CREATE_ERROR="hdiutil attach failed for ${STAGING_RAMDISK_MB}MB RAM disk"
Bogdan Timofte authored a month ago
1429
    return
1430
  fi
1431

            
1432
  if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
Bogdan Timofte authored 2 weeks ago
1433
    STAGING_RAMDISK_CREATE_ERROR="diskutil eraseVolume failed for RAM disk device $dev"
Bogdan Timofte authored a month ago
1434
    /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1435
    return
1436
  fi
1437

            
1438
  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
1439
  if [[ -z "$mount_point" ]]; then
1440
    mount_point="$staging_path"
Bogdan Timofte authored a month ago
1441
  fi
1442

            
Bogdan Timofte authored a month ago
1443
  if [[ -d "$mount_point" ]]; then
Bogdan Timofte authored a month ago
1444
    AUTO_CREATED_STAGING_RAMDISK=true
Bogdan Timofte authored a month ago
1445
    AUTO_CREATED_STAGING_PATH="$mount_point"
1446
    AUTO_CREATED_STAGING_DEV="$dev"
Bogdan Timofte authored a month ago
1447
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
Bogdan Timofte authored 2 weeks ago
1448
  else
1449
    STAGING_RAMDISK_CREATE_ERROR="RAM disk device $dev was created but mount point was not found (expected $mount_point)"
Bogdan Timofte authored a month ago
1450
  fi
1451
}
1452

            
1453
attempt_unmount_auto_staging_ramdisk() {
1454
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then
1455
    return
1456
  fi
1457

            
Bogdan Timofte authored a month ago
1458
  if [[ "$AUTO_STAGING_CLEANED_UP" == true ]]; then
1459
    return
1460
  fi
1461
  AUTO_STAGING_CLEANED_UP=true
1462

            
Bogdan Timofte authored a month ago
1463
  if [[ "$(uname -s)" != "Darwin" ]]; then
1464
    return
1465
  fi
1466

            
Bogdan Timofte authored a month ago
1467
  local target="${AUTO_CREATED_STAGING_DEV:-$AUTO_CREATED_STAGING_PATH}"
1468
  if /usr/bin/hdiutil detach "$target" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
1469
    log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH"
1470
    return
1471
  fi
1472

            
1473
  if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1474
    log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH"
1475
  else
Bogdan Timofte authored a month ago
1476
    log_msg "INFO" "Auto-created staging RAM disk already gone: $AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1477
  fi
1478
}
1479

            
Bogdan Timofte authored a month ago
1480
cleanup_on_exit() {
1481
  local rc=$?
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored a month ago
1490
  attempt_unmount_auto_staging_ramdisk
1491
  return "$rc"
1492
}
1493

            
Bogdan Timofte authored a month ago
1494
normalize_staging_dir() {
1495
  if [[ "$STAGING_PROVIDED" != true ]]; then
1496
    STAGING_DIR=""
1497
    return
1498
  fi
1499

            
1500
  STAGING_DIR="$(to_abs_path "$STAGING_DIR")"
Bogdan Timofte authored a month ago
1501
  if [[ ! -d "$STAGING_DIR" ]]; then
Bogdan Timofte authored a month ago
1502
    create_missing_staging_ramdisk_if_needed "$STAGING_DIR"
1503
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1504
      STAGING_DIR="$AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1505
      log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1506
    else
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored a month ago
1510
      die "Staging directory not found: $STAGING_DIR"
1511
    fi
1512
  fi
Bogdan Timofte authored a month ago
1513
  [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR"
1514
  STAGING_DIR="$(cd "$STAGING_DIR" && pwd)"
1515
  [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR"
1516

            
1517
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1518
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1519
  fi
1520
}
1521

            
Bogdan Timofte authored a month ago
1522
collect_extensions() {
1523
  local raw="$EXTENSIONS_CSV"
1524
  local token
1525
  EXT_LIST=()
1526

            
1527
  IFS=',' read -r -a tokens <<< "$raw"
1528
  for token in "${tokens[@]}"; do
1529
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
1530
    token="${token#.}"
1531
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
1532
    [[ -n "$token" ]] && EXT_LIST+=("$token")
1533
  done
1534

            
1535
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1536
    die "No valid extensions after parsing --extensions"
1537
  fi
1538
}
1539

            
1540
build_find_expr_for_extensions() {
1541
  FIND_EXT_EXPR=()
1542
  local ext
1543
  for ext in "${EXT_LIST[@]}"; do
1544
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
1545
  done
1546
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
1547
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
1548
  fi
1549
}
1550

            
1551
rel_path_from_source() {
1552
  local abs_file="$1"
1553
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1554
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1555
  else
1556
    printf '%s\n' "$(basename "$abs_file")"
1557
  fi
1558
}
1559

            
1560
collect_video_files() {
1561
  VIDEO_FILES=()
1562

            
1563
  if [[ -n "$SINGLE_FILE" ]]; then
1564
    local single_abs
1565
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1566
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1567
    VIDEO_FILES+=("$single_abs")
1568
    return
1569
  fi
1570

            
1571
  build_find_expr_for_extensions
1572

            
1573
  while IFS= read -r -d '' file; do
Bogdan Timofte authored a month ago
1574
    if is_apple_noise_file "$file"; then
1575
      vlog_msg "SKIP" "Ignoring Apple artifact: $file"
1576
      continue
1577
    fi
Bogdan Timofte authored a month ago
1578
    VIDEO_FILES+=("$file")
1579
  done < <(
1580
    if [[ "$RECURSIVE" == true ]]; then
1581
      find "$SOURCE_DIR" \
1582
        -path "$DEST_DIR" -prune -o \
1583
        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1584
    else
1585
      find "$SOURCE_DIR" \
1586
        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
1587
    fi
1588
  )
1589
}
1590

            
1591
process_video_file() {
1592
  local input_file="$1"
Bogdan Timofte authored a month ago
1593
  local rel_path output_file temp_output_file out_dir
1594
  local display_path
1595
  local encode_input_file
Bogdan Timofte authored 2 weeks ago
1596
  local staged_input_file=""
Bogdan Timofte authored a month ago
1597
  local preferred_temp_dir=""
Bogdan Timofte authored a month ago
1598
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1599
  local input_size_bytes=0 output_size_bytes=0
Bogdan Timofte authored a month ago
1600
  local file_started_at file_ended_at file_real_elapsed_sec
1601
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1602
  local post_elapsed_sec
1603

            
1604
  file_started_at="$(date +%s)"
1605
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1606

            
1607
  rel_path="$(rel_path_from_source "$input_file")"
1608
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1609
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1610
  display_path="${input_file#$PWD/}"
1611
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1612

            
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored a month ago
1620

            
Bogdan Timofte authored 2 weeks ago
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
Bogdan Timofte authored a month ago
1643
    ERRORS=$((ERRORS + 1))
1644
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1645
    if [[ "$VERBOSE" == true ]]; then
1646
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1647
    else
1648
      log_progress_start "$display_path"
1649
      log_progress_skipped_unreadable "$display_path"
1650
    fi
Bogdan Timofte authored 2 weeks ago
1651
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1652
    return 2
1653
  fi
1654

            
1655
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1656
    repacked_input_file="$REPACKED_SOURCE_PATH"
1657
    encode_input_file="$REPACKED_SOURCE_PATH"
1658
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1659
  fi
1660

            
Bogdan Timofte authored a month ago
1661
  local has_audio=false
Bogdan Timofte authored a month ago
1662
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1663
    has_audio=true
1664
  fi
1665

            
1666
  if [[ "$VERBOSE" == true ]]; then
1667
    log_msg "INFO" "ffprobe summary: $input_file"
1668
    print_verbose_probe "$input_file"
1669
    log_msg "INFO" "Audio detected: $has_audio"
1670
  fi
1671

            
1672
  build_video_args
1673

            
Bogdan Timofte authored a month ago
1674
  preferred_temp_dir="$STAGING_DIR"
1675
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then
1676
    if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then
1677
      preferred_temp_dir=""
1678
      log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file"
1679
    fi
1680
  fi
1681

            
1682
  temp_output_file="$(make_temp_output_file "$output_file" "$preferred_temp_dir")"
Bogdan Timofte authored a month ago
1683
  if [[ -z "$temp_output_file" ]]; then
1684
    ERRORS=$((ERRORS + 1))
1685
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1686
    log_msg "ERROR" "Could not create temporary output file: $output_file"
Bogdan Timofte authored 2 weeks ago
1687
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1688
    return 3
1689
  fi
Bogdan Timofte authored a month ago
1690
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then
Bogdan Timofte authored a month ago
1691
    log_msg "WARN" "Staging unavailable for temp output; using destination directory: $temp_output_file"
1692
  fi
Bogdan Timofte authored a month ago
1693

            
Bogdan Timofte authored a month ago
1694
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1695
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1696
    cmd+=( -fflags +genpts -err_detect ignore_err )
1697
  fi
Bogdan Timofte authored a month ago
1698
  if [[ "$OVERWRITE" == true ]]; then
1699
    cmd+=( -y )
1700
  else
1701
    cmd+=( -n )
1702
  fi
1703

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

            
1706
  if [[ "$has_audio" == true ]]; then
1707
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1708
  fi
1709

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

            
1712
  if [[ "$VERBOSE" == true ]]; then
1713
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1714
  fi
1715

            
Bogdan Timofte authored a month ago
1716
  if [[ "$VERBOSE" != true ]]; then
1717
    log_progress_start "$display_path"
1718
  fi
1719

            
1720
  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
Bogdan Timofte authored a month ago
1721
  if [[ "$FIRST_ENCODE_STARTED_AT" -eq 0 ]]; then
1722
    local startup_warmup_sec=0
1723
    local warmup_elapsed_sec=0
1724
    FIRST_ENCODE_STARTED_AT="$(date +%s)"
1725
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 ]]; then
1726
      warmup_elapsed_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1727
      if [[ "$warmup_elapsed_sec" -lt 0 ]]; then
1728
        warmup_elapsed_sec=0
1729
      fi
1730
    fi
1731
  fi
Bogdan Timofte authored a month ago
1732
  encode_started_at="$(date +%s)"
1733
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
1734

            
1735
  local ffmpeg_rc=0
Bogdan Timofte authored a month ago
1736
  local ffmpeg_log=""
Bogdan Timofte authored a month ago
1737
  if [[ "$VERBOSE" == true ]]; then
1738
    # Verbose: show ffmpeg output directly
Bogdan Timofte authored a month ago
1739
    if run_ffmpeg_with_signal_guard "${cmd[@]}"; then
Bogdan Timofte authored a month ago
1740
      :
1741
    else
1742
      ffmpeg_rc=$?
1743
    fi
1744
  else
1745
    # Quiet (default): redirect ffmpeg output; keep log on failure
1746
    ffmpeg_log="$(make_temp_log_file)"
1747
    if [[ -z "$ffmpeg_log" ]]; then
1748
      ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1749
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored a month ago
1750
      log_msg "ERROR" "Could not create temporary ffmpeg log file"
Bogdan Timofte authored 2 weeks ago
1751
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1752
      return 3
Bogdan Timofte authored a month ago
1753
    fi
Bogdan Timofte authored a month ago
1754
    if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
Bogdan Timofte authored a month ago
1755
      rm -f "$ffmpeg_log"
1756
    else
1757
      ffmpeg_rc=$?
1758
    fi
1759
  fi
1760

            
Bogdan Timofte authored a month ago
1761
  encode_ended_at="$(date +%s)"
1762
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1763
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1764

            
1765
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1766
    # Ctrl+C reached ffmpeg directly (same process group); it stopped mid-encode.
1767
    # Treat any user-triggered abort as a clean stop — no error counters, no noise.
1768
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1769
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored 2 weeks ago
1770
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1771
      return 4
1772
    fi
1773

            
Bogdan Timofte authored a month ago
1774
    local failure_rc=1
1775
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1776
      failure_rc=3
1777
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1778
      failure_rc=2
1779
    fi
1780

            
1781
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1782
    file_ended_at="$(date +%s)"
1783
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1784
    local encode_elapsed_fmt
1785
    local real_elapsed_fmt
1786
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1787
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1788

            
Bogdan Timofte authored a month ago
1789
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1790
    if [[ "$failure_rc" -eq 2 ]]; then
1791
      INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1792
    elif [[ "$failure_rc" -eq 3 ]]; then
1793
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1794
    fi
Bogdan Timofte authored a month ago
1795
    if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1796
      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
1797
    else
Bogdan Timofte authored a month ago
1798
      log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log"
Bogdan Timofte authored a month ago
1799
    fi
Bogdan Timofte authored a month ago
1800
    if [[ "$failure_rc" -eq 3 ]]; then
1801
      log_msg "ERROR" "Destination cannot accept more output data: $out_dir"
1802
    fi
Bogdan Timofte authored 2 weeks ago
1803
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1804
    return "$failure_rc"
Bogdan Timofte authored a month ago
1805
  fi
1806

            
Bogdan Timofte authored a month ago
1807
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1808

            
Bogdan Timofte authored 2 weeks ago
1809
  if ! restore_metadata_with_exiftool "$encode_input_file" "$temp_output_file"; then
Bogdan Timofte authored a month ago
1810
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1811
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
Bogdan Timofte authored 2 weeks ago
1812
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1813
      return 4
1814
    fi
Bogdan Timofte authored a month ago
1815
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1816
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored 2 weeks ago
1817
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1818
    return 3
Bogdan Timofte authored a month ago
1819
  fi
1820

            
Bogdan Timofte authored 2 weeks ago
1821
  if ! map_garmin_model_to_standard_tags "$encode_input_file" "$temp_output_file"; then
Bogdan Timofte authored a month ago
1822
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1823
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
Bogdan Timofte authored 2 weeks ago
1824
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1825
      return 4
1826
    fi
Bogdan Timofte authored a month ago
1827
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1828
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored 2 weeks ago
1829
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1830
    return 3
Bogdan Timofte authored a month ago
1831
  fi
1832

            
Bogdan Timofte authored a month ago
1833
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1834
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1835
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
Bogdan Timofte authored 2 weeks ago
1836
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1837
      return 4
1838
    fi
Bogdan Timofte authored a month ago
1839
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1840
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored 2 weeks ago
1841
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1842
    return 3
Bogdan Timofte authored a month ago
1843
  fi
Bogdan Timofte authored a month ago
1844

            
Bogdan Timofte authored 2 weeks ago
1845
  if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then
1846
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1847
    ERRORS=$((ERRORS + 1))
1848
    if destination_cannot_accept_file "$input_file" "$out_dir" ""; then
1849
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
Bogdan Timofte authored 2 weeks ago
1850
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored 2 weeks ago
1851
      return 3
Bogdan Timofte authored a month ago
1852
    fi
Bogdan Timofte authored 2 weeks ago
1853
    # Validation failed but destination is healthy: the source or encoder produced
1854
    # a corrupt/truncated output. Treat as source error so --keep-going can skip it.
1855
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1856
    log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file"
1857
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1858
    return 2
Bogdan Timofte authored a month ago
1859
  fi
1860

            
Bogdan Timofte authored a month ago
1861
  touch -r "$input_file" "$temp_output_file" || true
1862

            
Bogdan Timofte authored 2 weeks ago
1863
  if ! mkdir -p "$out_dir"; then
1864
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1865
    ERRORS=$((ERRORS + 1))
1866
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1867
    log_msg "ERROR" "Failed to create destination directory: $out_dir"
1868
    cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
1869
    return 3
1870
  fi
1871

            
Bogdan Timofte authored a month ago
1872
  if [[ "$OVERWRITE" == true ]]; then
1873
    if ! mv -f "$temp_output_file" "$output_file"; then
1874
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1875
      ERRORS=$((ERRORS + 1))
1876
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1877
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
Bogdan Timofte authored 2 weeks ago
1878
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1879
      return 3
1880
    fi
1881
  else
1882
    if ! mv -n "$temp_output_file" "$output_file"; then
1883
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1884
      ERRORS=$((ERRORS + 1))
1885
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1886
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
Bogdan Timofte authored 2 weeks ago
1887
      cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1888
      return 3
1889
    fi
1890
  fi
Bogdan Timofte authored a month ago
1891

            
Bogdan Timofte authored a month ago
1892
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1893
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1894
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1895
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1896

            
Bogdan Timofte authored a month ago
1897
  if [[ "$MOVE_SOURCE" == true ]]; then
Bogdan Timofte authored 2 weeks ago
1898
    if timeout 5s rm -f "$input_file" 2>/dev/null; then
Bogdan Timofte authored a month ago
1899
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
1900
    else
Bogdan Timofte authored 2 weeks ago
1901
      local rm_rc=$?
1902
      if [[ $rm_rc -eq 124 ]]; then
1903
        log_msg "WARN" "Source file removal timed out (NFS unresponsive?): $input_file"
1904
      else
1905
        log_msg "WARN" "Could not remove source file: $input_file"
1906
      fi
Bogdan Timofte authored a month ago
1907
    fi
1908
  fi
1909

            
Bogdan Timofte authored a month ago
1910
  file_ended_at="$(date +%s)"
1911
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1912
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1913
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1914
    post_elapsed_sec=0
1915
  fi
1916
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1917
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1918

            
Bogdan Timofte authored a month ago
1919
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1920

            
1921
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1922
    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
1923
  else
Bogdan Timofte authored a month ago
1924
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1925
  fi
1926

            
Bogdan Timofte authored 2 weeks ago
1927
  cleanup_process_inputs "$staged_input_file" "$repacked_input_file"
Bogdan Timofte authored a month ago
1928

            
Bogdan Timofte authored a month ago
1929
  return 0
1930
}
1931

            
1932
main() {
1933
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1934
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1935
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1936

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
1946
  trap 'handle_interrupt' INT TERM
Bogdan Timofte authored 2 weeks ago
1947
  trap 'handle_unexpected_error "$LINENO" "$BASH_COMMAND"' ERR
Bogdan Timofte authored a month ago
1948
  trap 'cleanup_on_exit' EXIT
Bogdan Timofte authored a month ago
1949

            
Bogdan Timofte authored a month ago
1950
  parse_args "$@"
Bogdan Timofte authored 2 weeks ago
1951
  MAIN_FLOW_STARTED=true
Bogdan Timofte authored a month ago
1952
  check_tools
1953

            
1954
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1955
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1956
    SINGLE_FILE="$SOURCE_DIR"
1957
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1958
  fi
1959

            
1960
  normalize_source_dir
1961
  normalize_dest_dir
1962

            
1963
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1964
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1965
  fi
1966

            
Bogdan Timofte authored 2 weeks ago
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

            
Bogdan Timofte authored a month ago
1974
  detect_encoders
1975
  resolve_encoder
1976

            
Bogdan Timofte authored 2 weeks ago
1977
  log_msg "INFO" "Scanning source for videos: $SOURCE_DIR"
Bogdan Timofte authored a month ago
1978
  collect_video_files
Bogdan Timofte authored a month ago
1979
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1980
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
Bogdan Timofte authored 2 weeks ago
1981
  else
1982
    log_msg "INFO" "Found ${#VIDEO_FILES[@]} candidate video file(s)"
Bogdan Timofte authored a month ago
1983
  fi
1984

            
1985
  local f
Bogdan Timofte authored 2 weeks ago
1986
  vlog_msg "INFO" "Starting processing loop for ${#VIDEO_FILES[@]} candidate file(s)"
Bogdan Timofte authored a month ago
1987
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
Bogdan Timofte authored 2 weeks ago
1988
    vlog_msg "CHECKPOINT" "loop_file: $f"
1989
    check_for_quit_key || true
Bogdan Timofte authored a month ago
1990
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1991
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1992
      break
1993
    fi
Bogdan Timofte authored a month ago
1994

            
1995
    local process_rc=0
Bogdan Timofte authored 2 weeks ago
1996
    vlog_msg "INFO" "Dispatching $(rel_path_from_source "$f")"
Bogdan Timofte authored a month ago
1997
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1998
      process_rc=0
Bogdan Timofte authored a month ago
1999
    else
2000
      process_rc=$?
2001
    fi
Bogdan Timofte authored 2 weeks ago
2002
    vlog_msg "CHECKPOINT" "process_rc=$process_rc file=$f"
Bogdan Timofte authored a month ago
2003

            
Bogdan Timofte authored a month ago
2004
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
2005
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
2006
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
2007
        DEBUG_TIMING_STOPPED=true
2008
      fi
2009
    fi
2010

            
2011
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
2012
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
2013
      break
2014
    fi
2015

            
2016
    if [[ "$process_rc" -eq 0 ]]; then
2017
      continue
2018
    fi
2019

            
Bogdan Timofte authored a month ago
2020
    case "$process_rc" in
2021
      2)
Bogdan Timofte authored a month ago
2022
        if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
2023
          log_msg "INFO" "Stopped by user after current file"
2024
          break
2025
        fi
Bogdan Timofte authored a month ago
2026
        log_msg "INFO" "Continuing after unreadable/corrupted source file"
2027
        continue
2028
        ;;
2029
      3)
2030
        log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space"
2031
        break
2032
        ;;
2033
      4)
2034
        log_msg "INFO" "Stopped by user after current file"
2035
        break
2036
        ;;
2037
      *)
2038
        if [[ "$FAIL_FAST" == true ]]; then
2039
          log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
2040
          break
2041
        fi
2042
        log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled"
2043
        continue
2044
        ;;
2045
    esac
Bogdan Timofte authored a month ago
2046
  done
2047

            
2048
  total_ended_at="$(date +%s)"
2049
  total_elapsed_sec=$((total_ended_at - total_started_at))
2050
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
2051

            
2052
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
2053
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
2054
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
2055
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
Bogdan Timofte authored a month ago
2056
  local startup_warmup_sec=-1 startup_warmup_fmt="n/a"
2057
  local staging_warmup_sec=-1 staging_warmup_fmt="n/a"
Bogdan Timofte authored a month ago
2058

            
Bogdan Timofte authored a month ago
2059
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
2060
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
2061
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
2062
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
2063
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
2064
  fi
2065

            
2066
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
2067
  if [[ "$file_post_total_sec" -lt 0 ]]; then
2068
    file_post_total_sec=0
2069
  fi
2070
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
2071

            
2072
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
2073
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
2074
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
2075
  fi
Bogdan Timofte authored a month ago
2076
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
2077

            
Bogdan Timofte authored a month ago
2078
  if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
2079
    startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT))
2080
    if [[ "$startup_warmup_sec" -lt 0 ]]; then
2081
      startup_warmup_sec=0
2082
    fi
2083
    startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")"
2084
  fi
2085

            
2086
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
2087
    staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
2088
    if [[ "$staging_warmup_sec" -lt 0 ]]; then
2089
      staging_warmup_sec=0
2090
    fi
2091
    staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")"
2092
  fi
2093

            
Bogdan Timofte authored a month ago
2094
  print_final_report \
2095
    "$total_elapsed_sec" \
2096
    "$total_elapsed_fmt" \
2097
    "$file_post_total_sec" \
2098
    "$file_post_total_fmt" \
2099
    "$run_non_file_overhead_sec" \
2100
    "$run_non_file_overhead_fmt" \
2101
    "$avg_file_real_time_sec" \
2102
    "$avg_file_real_time_fmt" \
2103
    "$avg_video_time_sec" \
Bogdan Timofte authored a month ago
2104
    "$avg_video_time_fmt" \
2105
    "$startup_warmup_sec" \
2106
    "$startup_warmup_fmt" \
2107
    "$staging_warmup_sec" \
2108
    "$staging_warmup_fmt" \
2109
    "$INPUT_BYTES_PROCESSED" \
2110
    "$OUTPUT_BYTES_PROCESSED"
Bogdan Timofte authored 2 weeks ago
2111
  FINAL_REPORT_PRINTED=true
Bogdan Timofte authored a month ago
2112

            
Bogdan Timofte authored a month ago
2113
  if [[ "$ERRORS" -gt 0 ]]; then
2114
    exit 1
2115
  fi
2116
}
2117

            
2118
main "$@"