VariaReEncoder / garmin_varia_transcode.sh
Newer Older
1780 lines | 57.084kb
Bogdan Timofte authored a month ago
1
#!/usr/bin/env bash
2
set -euo pipefail
3

            
4
# Garmin Varia batch transcoder (macOS/Linux)
5
#
6
# Apple compatibility note:
7
# For HEVC outputs we explicitly set -tag:v hvc1 (not hev1), which is required
8
# for reliable QuickTime / Photos compatibility.
9
#
10
# Encoding strategy note:
11
# Do not derive output bitrate mechanically from fixed Garmin source sizes.
12
# Prefer CRF (software) or constrained VBR (hardware), as used below.
13

            
Bogdan Timofte authored a month ago
14
TOOL_NAME="ffmpeg"
Bogdan Timofte authored a month ago
15

            
16
DEFAULT_SOURCE="."
17
DEFAULT_MODE="hardware"
18
DEFAULT_EXTENSIONS="mp4,mov,avi,m4v"
19
DEFAULT_CRF_HEVC=20
20
DEFAULT_CRF_H264=19
Bogdan Timofte authored a month ago
21
DEFAULT_STAGING_RAMDISK_MB=8192
Bogdan Timofte authored a month ago
22

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

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

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

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

            
74
VIDEOS_PROCESSED=0
75
VIDEOS_SKIPPED=0
76
ERRORS=0
Bogdan Timofte authored a month ago
77
INVALID_SOURCES_SKIPPED=0
78
DESTINATION_FAILURES=0
Bogdan Timofte authored a month ago
79
TOTAL_VIDEO_TIME_SEC=0
Bogdan Timofte authored a month ago
80
TOTAL_FILE_REAL_TIME_SEC=0
Bogdan Timofte authored a month ago
81
INPUT_BYTES_PROCESSED=0
82
OUTPUT_BYTES_PROCESSED=0
Bogdan Timofte authored a month ago
83

            
84
DURATION_TOLERANCE_SEC=1.0
Bogdan Timofte authored a month ago
85
STOP_AFTER_CURRENT=false
86
INTERRUPT_COUNT=0
87
CURRENT_FFMPEG_PID=""
88
PROGRESS_LINE_OPEN=false
Bogdan Timofte authored a month ago
89

            
90
usage() {
91
  cat <<'EOF'
92
Usage:
93
  garmin_varia_transcode.sh [options]
94

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

            
117
Encoding Modes:
118
  hardware   Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only.
119
             ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro).
120
             Best choice when running on battery or transcoding large libraries.
121
             Output quality is good for dashcam/action footage.
122
             Falls back to an error on Linux or if videotoolbox is absent.
123

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

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

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

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

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

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

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

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

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

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

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

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

            
209
  local ts
210
  ts="$(date '+%Y-%m-%d %H:%M:%S')"
Bogdan Timofte authored a month ago
211
  echo "[$ts] [ERROR] Transcoding $input_file ... FAILED after ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
Bogdan Timofte authored a month ago
212
}
213

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
370
  printf '\nData Volume\n'
371
  printf '+---------------------------+----------------+-----------------+\n'
372
  printf '| %-25s | %-14s | %-15s |\n' "Metric" "Bytes" "Human"
373
  printf '+---------------------------+----------------+-----------------+\n'
374
  printf '| %-25s | %14s | %-15s |\n' "Input processed" "${input_bytes_processed} B" "$(format_bytes_human "$input_bytes_processed")"
375
  printf '| %-25s | %14s | %-15s |\n' "Output written" "${output_bytes_processed} B" "$(format_bytes_human "$output_bytes_processed")"
376
  printf '+---------------------------+----------------+-----------------+\n'
377

            
Bogdan Timofte authored a month ago
378
  printf '\nPer-File Averages\n'
379
  printf '+---------------------------+---------+----------+\n'
380
  printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed"
381
  printf '+---------------------------+---------+----------+\n'
382
  printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt"
383
  printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt"
384
  printf '+---------------------------+---------+----------+\n'
385
}
386

            
Bogdan Timofte authored a month ago
387
make_temp_log_file() {
388
  local temp_path
389
  local base_tmp="${TMPDIR:-/tmp}"
390
  base_tmp="${base_tmp%/}"
391

            
392
  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
393
  if [[ -z "$temp_path" ]]; then
394
    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
395
  fi
396

            
397
  printf '%s\n' "$temp_path"
398
}
399

            
Bogdan Timofte authored a month ago
400
make_temp_output_file() {
401
  local target_file="$1"
Bogdan Timofte authored a month ago
402
  local preferred_dir="${2:-}"
403
  local target_dir target_base target_noext temp_path seed_path work_dir
Bogdan Timofte authored a month ago
404

            
405
  target_dir="$(dirname "$target_file")"
406
  target_base="$(basename "$target_file")"
407
  target_noext="${target_base%.*}"
Bogdan Timofte authored a month ago
408
  work_dir="$target_dir"
409

            
410
  if [[ -n "$preferred_dir" ]]; then
411
    work_dir="$preferred_dir"
412
  fi
413

            
414
  seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
415
  if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then
416
    seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
417
  fi
Bogdan Timofte authored a month ago
418

            
419
  if [[ -n "$seed_path" ]]; then
420
    rm -f -- "$seed_path" 2>/dev/null || true
421
    temp_path="${seed_path}.mp4"
422
  else
Bogdan Timofte authored a month ago
423
    temp_path="$work_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
Bogdan Timofte authored a month ago
424
    if ! touch "$temp_path" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
425
      if [[ "$work_dir" != "$target_dir" ]]; then
426
        temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4"
427
        if ! touch "$temp_path" >/dev/null 2>&1; then
428
          temp_path=""
429
        else
430
          rm -f -- "$temp_path" 2>/dev/null || true
431
        fi
432
      else
433
        temp_path=""
434
      fi
Bogdan Timofte authored a month ago
435
    else
436
      rm -f -- "$temp_path" 2>/dev/null || true
437
    fi
438
  fi
439

            
440
  printf '%s\n' "$temp_path"
441
}
442

            
443
cleanup_transcode_artifacts() {
444
  local temp_output="$1"
445
  local final_output="$2"
Bogdan Timofte authored a month ago
446
  local exif_backup_default="${temp_output}_original"
447
  local exif_backup_alt="${temp_output}.exif_original"
Bogdan Timofte authored a month ago
448

            
449
  rm -f -- "$temp_output" 2>/dev/null || true
Bogdan Timofte authored a month ago
450
  rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true
Bogdan Timofte authored a month ago
451

            
452
  # Defensive cleanup for previously failed non-atomic runs.
453
  if [[ -f "$final_output" && ! -s "$final_output" ]]; then
454
    rm -f -- "$final_output" 2>/dev/null || true
455
  fi
456
}
457

            
458
is_apple_noise_file() {
459
  local file_path="$1"
460
  local base
461
  base="$(basename "$file_path")"
462

            
463
  case "$base" in
464
    ._*|.DS_Store|.AppleDouble|._.DS_Store)
465
      return 0
466
      ;;
467
  esac
468

            
469
  return 1
470
}
471

            
Bogdan Timofte authored a month ago
472
require_value() {
473
  local flag="$1"
474
  local value="${2:-}"
475
  if [[ -z "$value" ]]; then
476
    die "Missing value for $flag"
477
  fi
478
}
479

            
480
to_abs_path() {
481
  local p="$1"
482
  if [[ "$p" = /* ]]; then
483
    printf '%s\n' "$p"
484
  else
485
    printf '%s\n' "$PWD/$p"
486
  fi
487
}
488

            
489
path_is_within() {
490
  local child="$1"
491
  local parent="$2"
492
  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
493
}
494

            
Bogdan Timofte authored a month ago
495
file_size_bytes_or_zero() {
496
  local file_path="$1"
497
  local file_size="0"
498

            
499
  file_size="$(stat -f%z "$file_path" 2>/dev/null || true)"
500
  if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then
501
    file_size="$(stat -c%s "$file_path" 2>/dev/null || true)"
502
  fi
503

            
504
  if [[ "$file_size" =~ ^[0-9]+$ ]]; then
505
    printf '%s\n' "$file_size"
506
  else
507
    printf '0\n'
508
  fi
509
}
510

            
511
dir_available_bytes_or_zero() {
512
  local dir_path="$1"
513
  local avail_kb=""
514

            
515
  avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
516
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
517
    printf '%s\n' $((avail_kb * 1024))
518
  else
519
    printf '0\n'
520
  fi
521
}
522

            
523
staging_has_space_for_input() {
524
  local staging_dir="$1"
525
  local input_file="$2"
526
  local input_size_bytes=0
527
  local needed_bytes=0
528
  local available_bytes=0
529

            
530
  [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1
531

            
532
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
533
  if [[ "$input_size_bytes" -le 0 ]]; then
534
    return 1
535
  fi
536

            
537
  # Keep one simple rule: need roughly 2x input size in staging.
538
  # This covers temp output plus metadata rewrite overhead.
539
  needed_bytes=$((input_size_bytes * 2))
540
  available_bytes="$(dir_available_bytes_or_zero "$staging_dir")"
541
  [[ "$available_bytes" -ge "$needed_bytes" ]]
542
}
543

            
Bogdan Timofte authored a month ago
544
join_cmd_for_log() {
545
  local out=""
546
  local arg
547
  for arg in "$@"; do
548
    out+="$(printf '%q' "$arg") "
549
  done
550
  printf '%s\n' "${out% }"
551
}
552

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

            
667
  case "$MODE" in
668
    auto|hardware|quality|compat) ;;
669
    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
670
  esac
671

            
672
  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
673
    die "At least one of --source or --destination must be provided"
674
  fi
675

            
676
  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
677
    die "--crf must be an integer"
678
  fi
Bogdan Timofte authored a month ago
679

            
680
  if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then
681
    if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then
682
      die "--debug-timing must be a positive integer"
683
    fi
684
    if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then
685
      die "--debug-timing must be greater than 0"
686
    fi
687
  fi
688

            
689
  if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then
690
    die "--staging-ramdisk-mb must be a positive integer"
691
  fi
692
  if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then
693
    die "--staging-ramdisk-mb must be greater than 0"
694
  fi
Bogdan Timofte authored a month ago
695
}
696

            
697
check_tools() {
698
  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
699
  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
Bogdan Timofte authored a month ago
700
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
Bogdan Timofte authored a month ago
701

            
702
  HAS_AVCONVERT=false
703
  if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then
704
    HAS_AVCONVERT=true
705
  fi
706
}
707

            
708
make_temp_repack_file() {
709
  local base_tmp="${TMPDIR:-/tmp}"
710
  local seed_path temp_path
711

            
712
  base_tmp="${base_tmp%/}"
713
  seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)"
714
  if [[ -n "$seed_path" ]]; then
715
    rm -f -- "$seed_path" 2>/dev/null || true
716
    temp_path="${seed_path}.mp4"
717
    printf '%s\n' "$temp_path"
718
    return
719
  fi
720

            
721
  temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4"
722
  printf '%s\n' "$temp_path"
723
}
724

            
725
cleanup_repacked_input() {
726
  local repacked_file="$1"
727
  if [[ -n "$repacked_file" ]]; then
728
    rm -f -- "$repacked_file" 2>/dev/null || true
729
  fi
730
}
731

            
732
try_apple_repack_for_unreadable() {
733
  local source_file="$1"
734
  local repacked_file=""
735

            
736
  REPACKED_SOURCE_PATH=""
737

            
738
  if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then
739
    return 1
740
  fi
741

            
742
  repacked_file="$(make_temp_repack_file)"
743
  if [[ -z "$repacked_file" ]]; then
744
    return 1
745
  fi
746

            
747
  if [[ "$VERBOSE" == true ]]; then
748
    log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file"
749
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then
750
      cleanup_repacked_input "$repacked_file"
751
      return 1
752
    fi
753
  else
754
    if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then
755
      cleanup_repacked_input "$repacked_file"
756
      return 1
757
    fi
758
  fi
759

            
760
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then
761
    REPACKED_SOURCE_PATH="$repacked_file"
762
    return 0
763
  fi
764

            
765
  cleanup_repacked_input "$repacked_file"
766
  return 1
Bogdan Timofte authored a month ago
767
}
768

            
769
restore_metadata_with_exiftool() {
770
  local input_file="$1"
771
  local output_file="$2"
772

            
773
  vlog_msg "CHECKPOINT" "metadata_start: $output_file"
774

            
775
  if [[ "$VERBOSE" == true ]]; then
776
    if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then
777
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
778
      return 0
779
    fi
780
  else
781
    if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then
782
      vlog_msg "CHECKPOINT" "metadata_end: $output_file"
783
      return 0
784
    fi
785
  fi
786

            
Bogdan Timofte authored a month ago
787
  if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
788
    log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file"
789
    return 1
790
  fi
791

            
Bogdan Timofte authored a month ago
792
  log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file"
793
  return 1
794
}
795

            
796
map_garmin_model_to_standard_tags() {
797
  local input_file="$1"
798
  local output_file="$2"
799
  local garmin_model
800

            
801
  garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)"
802
  if [[ -z "$garmin_model" ]]; then
803
    vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file"
804
    return 0
805
  fi
806

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

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

            
845
  log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file"
846
  return 1
847
}
848

            
849
write_transcode_encoder_metadata() {
850
  local output_file="$1"
851
  local encoder_meta
852

            
853
  encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC"
854

            
855
  vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)"
856

            
857
  if [[ "$VERBOSE" == true ]]; then
858
    if exiftool -overwrite_original -m \
859
      -Software="$encoder_meta" \
860
      -UserData:Software="$encoder_meta" \
861
      "$output_file"; then
862
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
863
      return 0
864
    fi
865
  else
866
    if exiftool -overwrite_original -m -q -q \
867
      -Software="$encoder_meta" \
868
      -UserData:Software="$encoder_meta" \
869
      "$output_file" >/dev/null 2>&1; then
870
      vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file"
871
      return 0
872
    fi
873
  fi
874

            
875
  log_msg "ERROR" "Failed to write encoder metadata: $output_file"
876
  return 1
Bogdan Timofte authored a month ago
877
}
878

            
879
detect_encoders() {
880
  local encoders
881
  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
882

            
883
  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
884
    HAS_VIDEOTOOLBOX=true
885
  fi
886
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
887
    HAS_LIBX265=true
888
  fi
889
  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
890
    HAS_LIBX264=true
891
  fi
892

            
893
  if [[ "$VERBOSE" == true ]]; then
894
    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
895
  fi
896
}
897

            
898
resolve_encoder() {
899
  local os_name
900
  os_name="$(uname -s)"
901

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

            
929
  case "$ENCODER_KIND" in
930
    hardware)
931
      VIDEO_CODEC="hevc_videotoolbox"
932
      VIDEO_CRF=""
933
      ;;
934
    quality)
935
      VIDEO_CODEC="libx265"
936
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
937
      ;;
938
    compat)
939
      VIDEO_CODEC="libx264"
940
      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
941
      ;;
942
  esac
943

            
944
  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
945
}
946

            
947
probe_has_audio() {
948
  local input_file="$1"
949
  local out
950
  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
951
  [[ -n "$out" ]]
952
}
953

            
954
print_verbose_probe() {
955
  local input_file="$1"
956
  ffprobe -v error \
957
    -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 \
958
    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
959
}
960

            
961
ffprobe_duration_or_empty() {
962
  local file_path="$1"
963
  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
964
}
965

            
966
ffprobe_video_codec_or_empty() {
967
  local file_path="$1"
968
  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
969
}
970

            
Bogdan Timofte authored a month ago
971
source_video_is_readable() {
972
  local file_path="$1"
973
  REPACKED_SOURCE_PATH=""
974

            
975
  if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then
976
    SOURCE_READABLE_MODE="normal"
977
    return 0
978
  fi
979

            
980
  if [[ "$VERBOSE" == true ]]; then
981
    log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path"
982
  fi
983

            
984
  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
985
    SOURCE_READABLE_MODE="tolerant"
986
    vlog_msg "INFO" "Source readable in tolerant mode: $file_path"
987
    return 0
988
  fi
989

            
990
  if try_apple_repack_for_unreadable "$file_path"; then
991
    SOURCE_READABLE_MODE="repacked"
992
    log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path"
993
    return 0
994
  fi
995

            
996
  SOURCE_READABLE_MODE="normal"
997
  return 1
998
}
999

            
1000
source_error_from_ffmpeg_log() {
1001
  local ffmpeg_log="$1"
1002
  [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1
1003
  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"
1004
}
1005

            
1006
destination_cannot_accept_file() {
1007
  local input_file="$1"
1008
  local out_dir="$2"
1009
  local ffmpeg_log="$3"
1010
  local probe_path=""
1011
  local avail_kb=""
1012
  local avail_bytes=0
1013

            
1014
  if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then
1015
    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
1016
      return 0
1017
    fi
1018
  fi
1019

            
1020
  probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)"
1021
  if [[ -z "$probe_path" ]]; then
1022
    return 0
1023
  fi
1024
  rm -f -- "$probe_path" 2>/dev/null || true
1025

            
1026
  avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
1027
  if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then
1028
    avail_bytes=$((avail_kb * 1024))
1029
    if [[ "$avail_bytes" -lt 67108864 ]]; then
1030
      return 0
1031
    fi
1032
  fi
1033

            
1034
  return 1
1035
}
1036

            
Bogdan Timofte authored a month ago
1037
validate_transcoded_output() {
1038
  local input_file="$1"
1039
  local output_file="$2"
1040

            
1041
  if [[ ! -f "$output_file" ]]; then
1042
    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
1043
    return 1
1044
  fi
1045

            
1046
  local expected_codec actual_codec
1047
  case "$ENCODER_KIND" in
1048
    hardware|quality) expected_codec="hevc" ;;
1049
    compat) expected_codec="h264" ;;
1050
    *)
1051
      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
1052
      return 1
1053
      ;;
1054
  esac
1055

            
1056
  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
1057
  if [[ -z "$actual_codec" ]]; then
1058
    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
1059
    return 1
1060
  fi
1061
  if [[ "$actual_codec" != "$expected_codec" ]]; then
1062
    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
1063
    return 1
1064
  fi
1065

            
1066
  local in_duration out_duration duration_delta
1067
  in_duration="$(ffprobe_duration_or_empty "$input_file")"
1068
  out_duration="$(ffprobe_duration_or_empty "$output_file")"
1069

            
1070
  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
1071
    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
1072
    return 1
1073
  fi
1074

            
1075
  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
1076
  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
1077
    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)"
1078
    return 1
1079
  fi
1080

            
1081
  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
1082
  return 0
1083
}
1084

            
1085
build_video_args() {
1086
  VIDEO_ARGS=()
1087

            
1088
  case "$ENCODER_KIND" in
1089
    hardware)
1090
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
1091
      ;;
1092
    quality)
1093
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
1094
      ;;
1095
    compat)
1096
      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
1097
      ;;
1098
  esac
1099
}
1100

            
1101
normalize_source_dir() {
1102
  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
1103
  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
1104
  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
1105
}
1106

            
1107
normalize_dest_dir() {
1108
  DEST_DIR="$(to_abs_path "$DEST_DIR")"
1109
}
1110

            
Bogdan Timofte authored a month ago
1111
# Sets globals: AUTO_CREATED_STAGING_RAMDISK, AUTO_CREATED_STAGING_PATH,
1112
# AUTO_CREATED_STAGING_DEV, STAGING_RAMDISK_CREATED_AT
1113
# Must be called directly (NOT inside $(...)) to preserve global assignments.
Bogdan Timofte authored a month ago
1114
create_missing_staging_ramdisk_if_needed() {
1115
  local staging_path="$1"
1116
  local ramdisk_name=""
1117
  local sectors=0
1118
  local dev=""
1119
  local mount_point=""
1120

            
1121
  staging_path="${staging_path%/}"
1122

            
1123
  if [[ -d "$staging_path" ]]; then
Bogdan Timofte authored a month ago
1124
    # Already exists; nothing to create.
Bogdan Timofte authored a month ago
1125
    return
1126
  fi
1127

            
1128
  if [[ "$(uname -s)" != "Darwin" ]]; then
1129
    return
1130
  fi
1131

            
1132
  case "$staging_path" in
1133
    /Volumes/*)
1134
      ;;
1135
    *)
1136
      return
1137
      ;;
1138
  esac
1139

            
1140
  ramdisk_name="$(basename "$staging_path")"
1141
  if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then
1142
    return
1143
  fi
1144

            
1145
  if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then
1146
    # Only auto-create for top-level /Volumes/<Name>, not nested paths.
1147
    return
1148
  fi
1149

            
1150
  [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk"
1151
  [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk"
1152

            
1153
  sectors=$((STAGING_RAMDISK_MB * 2048))
1154
  vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)"
1155
  dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)"
1156
  if [[ -z "$dev" ]]; then
1157
    return
1158
  fi
1159

            
1160
  if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then
1161
    /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true
1162
    return
1163
  fi
1164

            
1165
  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
1166
  if [[ -z "$mount_point" ]]; then
1167
    mount_point="$staging_path"
Bogdan Timofte authored a month ago
1168
  fi
1169

            
Bogdan Timofte authored a month ago
1170
  if [[ -d "$mount_point" ]]; then
Bogdan Timofte authored a month ago
1171
    AUTO_CREATED_STAGING_RAMDISK=true
Bogdan Timofte authored a month ago
1172
    AUTO_CREATED_STAGING_PATH="$mount_point"
1173
    AUTO_CREATED_STAGING_DEV="$dev"
Bogdan Timofte authored a month ago
1174
    STAGING_RAMDISK_CREATED_AT="$(date +%s)"
1175
  fi
1176
}
1177

            
1178
attempt_unmount_auto_staging_ramdisk() {
1179
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then
1180
    return
1181
  fi
1182

            
Bogdan Timofte authored a month ago
1183
  if [[ "$AUTO_STAGING_CLEANED_UP" == true ]]; then
1184
    return
1185
  fi
1186
  AUTO_STAGING_CLEANED_UP=true
1187

            
Bogdan Timofte authored a month ago
1188
  if [[ "$(uname -s)" != "Darwin" ]]; then
1189
    return
1190
  fi
1191

            
Bogdan Timofte authored a month ago
1192
  local target="${AUTO_CREATED_STAGING_DEV:-$AUTO_CREATED_STAGING_PATH}"
1193
  if /usr/bin/hdiutil detach "$target" >/dev/null 2>&1; then
Bogdan Timofte authored a month ago
1194
    log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH"
1195
    return
1196
  fi
1197

            
1198
  if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1199
    log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH"
1200
  else
Bogdan Timofte authored a month ago
1201
    log_msg "INFO" "Auto-created staging RAM disk already gone: $AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1202
  fi
1203
}
1204

            
Bogdan Timofte authored a month ago
1205
cleanup_on_exit() {
1206
  local rc=$?
1207
  attempt_unmount_auto_staging_ramdisk
1208
  return "$rc"
1209
}
1210

            
Bogdan Timofte authored a month ago
1211
normalize_staging_dir() {
1212
  if [[ "$STAGING_PROVIDED" != true ]]; then
1213
    STAGING_DIR=""
1214
    return
1215
  fi
1216

            
1217
  STAGING_DIR="$(to_abs_path "$STAGING_DIR")"
Bogdan Timofte authored a month ago
1218
  if [[ ! -d "$STAGING_DIR" ]]; then
Bogdan Timofte authored a month ago
1219
    create_missing_staging_ramdisk_if_needed "$STAGING_DIR"
1220
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && -d "$AUTO_CREATED_STAGING_PATH" ]]; then
1221
      STAGING_DIR="$AUTO_CREATED_STAGING_PATH"
Bogdan Timofte authored a month ago
1222
      log_msg "INFO" "Created staging RAM disk: $STAGING_DIR"
1223
    else
1224
      die "Staging directory not found: $STAGING_DIR"
1225
    fi
1226
  fi
Bogdan Timofte authored a month ago
1227
  [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR"
1228
  STAGING_DIR="$(cd "$STAGING_DIR" && pwd)"
1229
  [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR"
1230

            
1231
  if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then
1232
    die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR"
1233
  fi
1234
}
1235

            
Bogdan Timofte authored a month ago
1236
collect_extensions() {
1237
  local raw="$EXTENSIONS_CSV"
1238
  local token
1239
  EXT_LIST=()
1240

            
1241
  IFS=',' read -r -a tokens <<< "$raw"
1242
  for token in "${tokens[@]}"; do
1243
    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
1244
    token="${token#.}"
1245
    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
1246
    [[ -n "$token" ]] && EXT_LIST+=("$token")
1247
  done
1248

            
1249
  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
1250
    die "No valid extensions after parsing --extensions"
1251
  fi
1252
}
1253

            
1254
build_find_expr_for_extensions() {
1255
  FIND_EXT_EXPR=()
1256
  local ext
1257
  for ext in "${EXT_LIST[@]}"; do
1258
    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
1259
  done
1260
  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
1261
    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
1262
  fi
1263
}
1264

            
1265
rel_path_from_source() {
1266
  local abs_file="$1"
1267
  if path_is_within "$abs_file" "$SOURCE_DIR"; then
1268
    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
1269
  else
1270
    printf '%s\n' "$(basename "$abs_file")"
1271
  fi
1272
}
1273

            
1274
collect_video_files() {
1275
  VIDEO_FILES=()
1276

            
1277
  if [[ -n "$SINGLE_FILE" ]]; then
1278
    local single_abs
1279
    single_abs="$(to_abs_path "$SINGLE_FILE")"
1280
    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
1281
    VIDEO_FILES+=("$single_abs")
1282
    return
1283
  fi
1284

            
1285
  build_find_expr_for_extensions
1286

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

            
1305
process_video_file() {
1306
  local input_file="$1"
Bogdan Timofte authored a month ago
1307
  local rel_path output_file temp_output_file out_dir
1308
  local display_path
1309
  local encode_input_file
Bogdan Timofte authored a month ago
1310
  local preferred_temp_dir=""
Bogdan Timofte authored a month ago
1311
  local repacked_input_file=""
Bogdan Timofte authored a month ago
1312
  local input_size_bytes=0 output_size_bytes=0
Bogdan Timofte authored a month ago
1313
  local file_started_at file_ended_at file_real_elapsed_sec
1314
  local encode_started_at encode_ended_at encode_elapsed_sec=0
1315
  local post_elapsed_sec
1316

            
1317
  file_started_at="$(date +%s)"
1318
  vlog_msg "CHECKPOINT" "file_start: $input_file"
Bogdan Timofte authored a month ago
1319

            
1320
  rel_path="$(rel_path_from_source "$input_file")"
1321
  output_file="$DEST_DIR/${rel_path%.*}.mp4"
1322
  out_dir="$(dirname "$output_file")"
Bogdan Timofte authored a month ago
1323
  display_path="${input_file#$PWD/}"
1324
  encode_input_file="$input_file"
Bogdan Timofte authored a month ago
1325

            
1326
  mkdir -p "$out_dir"
1327

            
Bogdan Timofte authored a month ago
1328
  if ! source_video_is_readable "$input_file"; then
1329
    ERRORS=$((ERRORS + 1))
1330
    INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1))
1331
    if [[ "$VERBOSE" == true ]]; then
1332
      log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file"
1333
    else
1334
      log_progress_start "$display_path"
1335
      log_progress_skipped_unreadable "$display_path"
1336
    fi
1337
    return 2
1338
  fi
1339

            
1340
  if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then
1341
    repacked_input_file="$REPACKED_SOURCE_PATH"
1342
    encode_input_file="$REPACKED_SOURCE_PATH"
1343
    vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file"
1344
  fi
1345

            
Bogdan Timofte authored a month ago
1346
  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
1347
    vlog_msg "SKIP" "Video exists: $output_file"
1348
    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
Bogdan Timofte authored a month ago
1349
    cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1350
    return
1351
  fi
1352

            
1353
  local has_audio=false
Bogdan Timofte authored a month ago
1354
  if probe_has_audio "$encode_input_file"; then
Bogdan Timofte authored a month ago
1355
    has_audio=true
1356
  fi
1357

            
1358
  if [[ "$VERBOSE" == true ]]; then
1359
    log_msg "INFO" "ffprobe summary: $input_file"
1360
    print_verbose_probe "$input_file"
1361
    log_msg "INFO" "Audio detected: $has_audio"
1362
  fi
1363

            
1364
  build_video_args
1365

            
Bogdan Timofte authored a month ago
1366
  preferred_temp_dir="$STAGING_DIR"
1367
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then
1368
    if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then
1369
      preferred_temp_dir=""
1370
      log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file"
1371
    fi
1372
  fi
1373

            
1374
  temp_output_file="$(make_temp_output_file "$output_file" "$preferred_temp_dir")"
Bogdan Timofte authored a month ago
1375
  if [[ -z "$temp_output_file" ]]; then
1376
    ERRORS=$((ERRORS + 1))
1377
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1378
    log_msg "ERROR" "Could not create temporary output file: $output_file"
1379
    return 3
1380
  fi
Bogdan Timofte authored a month ago
1381
  if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then
Bogdan Timofte authored a month ago
1382
    log_msg "WARN" "Staging unavailable for temp output; using destination directory: $temp_output_file"
1383
  fi
Bogdan Timofte authored a month ago
1384

            
Bogdan Timofte authored a month ago
1385
  local cmd=(ffmpeg -hide_banner)
Bogdan Timofte authored a month ago
1386
  if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then
1387
    cmd+=( -fflags +genpts -err_detect ignore_err )
1388
  fi
Bogdan Timofte authored a month ago
1389
  if [[ "$OVERWRITE" == true ]]; then
1390
    cmd+=( -y )
1391
  else
1392
    cmd+=( -n )
1393
  fi
1394

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

            
1397
  if [[ "$has_audio" == true ]]; then
1398
    cmd+=( -map 0:a? -c:a aac -b:a 128k )
1399
  fi
1400

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

            
1403
  if [[ "$VERBOSE" == true ]]; then
1404
    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
1405
  fi
1406

            
1407
  if [[ "$DRY_RUN" == true ]]; then
1408
    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
1409
    return 0
1410
  fi
1411

            
Bogdan Timofte authored a month ago
1412
  if [[ "$VERBOSE" != true ]]; then
1413
    log_progress_start "$display_path"
1414
  fi
1415

            
1416
  vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)"
Bogdan Timofte authored a month ago
1417
  if [[ "$FIRST_ENCODE_STARTED_AT" -eq 0 ]]; then
1418
    local startup_warmup_sec=0
1419
    local warmup_elapsed_sec=0
1420
    FIRST_ENCODE_STARTED_AT="$(date +%s)"
1421
    if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 ]]; then
1422
      warmup_elapsed_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1423
      if [[ "$warmup_elapsed_sec" -lt 0 ]]; then
1424
        warmup_elapsed_sec=0
1425
      fi
1426
    fi
1427
  fi
Bogdan Timofte authored a month ago
1428
  encode_started_at="$(date +%s)"
1429
  vlog_msg "CHECKPOINT" "encode_start: $input_file"
Bogdan Timofte authored a month ago
1430

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

            
Bogdan Timofte authored a month ago
1456
  encode_ended_at="$(date +%s)"
1457
  encode_elapsed_sec=$((encode_ended_at - encode_started_at))
1458
  vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
Bogdan Timofte authored a month ago
1459

            
1460
  if [[ "$ffmpeg_rc" -ne 0 ]]; then
Bogdan Timofte authored a month ago
1461
    # Ctrl+C reached ffmpeg directly (same process group); it stopped mid-encode.
1462
    # Treat any user-triggered abort as a clean stop — no error counters, no noise.
1463
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1464
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1465
      cleanup_repacked_input "$repacked_input_file"
1466
      return 4
1467
    fi
1468

            
Bogdan Timofte authored a month ago
1469
    local failure_rc=1
1470
    if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then
1471
      failure_rc=3
1472
    elif source_error_from_ffmpeg_log "$ffmpeg_log"; then
1473
      failure_rc=2
1474
    fi
1475

            
1476
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
Bogdan Timofte authored a month ago
1477
    file_ended_at="$(date +%s)"
1478
    file_real_elapsed_sec=$((file_ended_at - file_started_at))
1479
    local encode_elapsed_fmt
1480
    local real_elapsed_fmt
1481
    encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")"
1482
    real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")"
1483

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

            
Bogdan Timofte authored a month ago
1502
  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))
1503

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

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

            
Bogdan Timofte authored a month ago
1528
  if ! write_transcode_encoder_metadata "$temp_output_file"; then
1529
    cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1530
    if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then
1531
      cleanup_repacked_input "$repacked_input_file"
1532
      return 4
1533
    fi
Bogdan Timofte authored a month ago
1534
    ERRORS=$((ERRORS + 1))
Bogdan Timofte authored a month ago
1535
    DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1536
    cleanup_repacked_input "$repacked_input_file"
1537
    return 3
Bogdan Timofte authored a month ago
1538
  fi
Bogdan Timofte authored a month ago
1539

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

            
Bogdan Timofte authored a month ago
1558
  touch -r "$input_file" "$temp_output_file" || true
1559

            
1560
  if [[ "$OVERWRITE" == true ]]; then
1561
    if ! mv -f "$temp_output_file" "$output_file"; then
1562
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1563
      ERRORS=$((ERRORS + 1))
1564
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1565
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1566
      cleanup_repacked_input "$repacked_input_file"
1567
      return 3
1568
    fi
1569
  else
1570
    if ! mv -n "$temp_output_file" "$output_file"; then
1571
      cleanup_transcode_artifacts "$temp_output_file" "$output_file"
1572
      ERRORS=$((ERRORS + 1))
1573
      DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1))
1574
      log_msg "ERROR" "Failed to move completed output into place: $output_file"
1575
      cleanup_repacked_input "$repacked_input_file"
1576
      return 3
1577
    fi
1578
  fi
Bogdan Timofte authored a month ago
1579

            
Bogdan Timofte authored a month ago
1580
  input_size_bytes="$(file_size_bytes_or_zero "$input_file")"
1581
  output_size_bytes="$(file_size_bytes_or_zero "$output_file")"
1582
  INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes))
1583
  OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes))
1584

            
Bogdan Timofte authored a month ago
1585
  if [[ "$MOVE_SOURCE" == true ]]; then
1586
    if rm -f "$input_file"; then
1587
      vlog_msg "INFO" "Removed source after successful validation: $input_file"
1588
    else
1589
      ERRORS=$((ERRORS + 1))
1590
      log_msg "ERROR" "Failed to remove source after validation: $input_file"
Bogdan Timofte authored a month ago
1591
      cleanup_repacked_input "$repacked_input_file"
Bogdan Timofte authored a month ago
1592
      return 1
1593
    fi
1594
  fi
1595

            
Bogdan Timofte authored a month ago
1596
  file_ended_at="$(date +%s)"
1597
  file_real_elapsed_sec=$((file_ended_at - file_started_at))
1598
  post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec))
1599
  if [[ "$post_elapsed_sec" -lt 0 ]]; then
1600
    post_elapsed_sec=0
1601
  fi
1602
  TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec))
1603
  vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
1604

            
Bogdan Timofte authored a month ago
1605
  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
1606

            
1607
  if [[ "$VERBOSE" == true ]]; then
Bogdan Timofte authored a month ago
1608
    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
1609
  else
Bogdan Timofte authored a month ago
1610
    log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec"
Bogdan Timofte authored a month ago
1611
  fi
1612

            
Bogdan Timofte authored a month ago
1613
  cleanup_repacked_input "$repacked_input_file"
1614

            
Bogdan Timofte authored a month ago
1615
  return 0
1616
}
1617

            
1618
main() {
1619
  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
1620
  total_started_at="$(date +%s)"
Bogdan Timofte authored a month ago
1621
  RUN_STARTED_AT="$total_started_at"
Bogdan Timofte authored a month ago
1622

            
Bogdan Timofte authored a month ago
1623
  trap 'handle_interrupt' INT TERM
Bogdan Timofte authored a month ago
1624
  trap 'cleanup_on_exit' EXIT
Bogdan Timofte authored a month ago
1625

            
Bogdan Timofte authored a month ago
1626
  parse_args "$@"
1627
  check_tools
1628

            
1629
  # Auto single-file detection: if --source points to a file, treat it as single-file mode
1630
  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
1631
    SINGLE_FILE="$SOURCE_DIR"
1632
    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
1633
  fi
1634

            
1635
  normalize_source_dir
1636
  normalize_dest_dir
Bogdan Timofte authored a month ago
1637
  normalize_staging_dir
Bogdan Timofte authored a month ago
1638
  collect_extensions
1639

            
1640
  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
1641
    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
1642
  fi
1643

            
1644
  detect_encoders
1645
  resolve_encoder
1646

            
1647
  collect_video_files
Bogdan Timofte authored a month ago
1648
  if [[ -z "${VIDEO_FILES[*]-}" ]]; then
Bogdan Timofte authored a month ago
1649
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
1650
  fi
1651

            
1652
  local f
Bogdan Timofte authored a month ago
1653
  for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do
Bogdan Timofte authored a month ago
1654
    check_for_quit_key
Bogdan Timofte authored a month ago
1655
    if [[ "$STOP_AFTER_CURRENT" == true ]]; then
1656
      log_msg "INFO" "Stop requested; ending before next file"
Bogdan Timofte authored a month ago
1657
      break
1658
    fi
Bogdan Timofte authored a month ago
1659

            
1660
    local process_rc=0
1661
    if process_video_file "$f"; then
Bogdan Timofte authored a month ago
1662
      process_rc=0
Bogdan Timofte authored a month ago
1663
    else
1664
      process_rc=$?
1665
    fi
1666

            
Bogdan Timofte authored a month ago
1667
    if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then
1668
      DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1))
1669
      if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then
1670
        DEBUG_TIMING_STOPPED=true
1671
      fi
1672
    fi
1673

            
1674
    if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then
1675
      log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file"
1676
      break
1677
    fi
1678

            
1679
    if [[ "$process_rc" -eq 0 ]]; then
1680
      continue
1681
    fi
1682

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

            
1711
  total_ended_at="$(date +%s)"
1712
  total_elapsed_sec=$((total_ended_at - total_started_at))
1713
  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
1714

            
1715
  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
Bogdan Timofte authored a month ago
1716
  local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00"
1717
  local file_post_total_sec=0 file_post_total_fmt="00:00:00"
1718
  local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00"
Bogdan Timofte authored a month ago
1719
  local startup_warmup_sec=-1 startup_warmup_fmt="n/a"
1720
  local staging_warmup_sec=-1 staging_warmup_fmt="n/a"
Bogdan Timofte authored a month ago
1721

            
Bogdan Timofte authored a month ago
1722
  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
1723
    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
1724
    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
Bogdan Timofte authored a month ago
1725
    avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED))
1726
    avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")"
1727
  fi
1728

            
1729
  file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC))
1730
  if [[ "$file_post_total_sec" -lt 0 ]]; then
1731
    file_post_total_sec=0
1732
  fi
1733
  file_post_total_fmt="$(format_seconds "$file_post_total_sec")"
1734

            
1735
  run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC))
1736
  if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then
1737
    run_non_file_overhead_sec=0
Bogdan Timofte authored a month ago
1738
  fi
Bogdan Timofte authored a month ago
1739
  run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")"
Bogdan Timofte authored a month ago
1740

            
Bogdan Timofte authored a month ago
1741
  if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1742
    startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT))
1743
    if [[ "$startup_warmup_sec" -lt 0 ]]; then
1744
      startup_warmup_sec=0
1745
    fi
1746
    startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")"
1747
  fi
1748

            
1749
  if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then
1750
    staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT))
1751
    if [[ "$staging_warmup_sec" -lt 0 ]]; then
1752
      staging_warmup_sec=0
1753
    fi
1754
    staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")"
1755
  fi
1756

            
Bogdan Timofte authored a month ago
1757
  print_final_report \
1758
    "$total_elapsed_sec" \
1759
    "$total_elapsed_fmt" \
1760
    "$file_post_total_sec" \
1761
    "$file_post_total_fmt" \
1762
    "$run_non_file_overhead_sec" \
1763
    "$run_non_file_overhead_fmt" \
1764
    "$avg_file_real_time_sec" \
1765
    "$avg_file_real_time_fmt" \
1766
    "$avg_video_time_sec" \
Bogdan Timofte authored a month ago
1767
    "$avg_video_time_fmt" \
1768
    "$startup_warmup_sec" \
1769
    "$startup_warmup_fmt" \
1770
    "$staging_warmup_sec" \
1771
    "$staging_warmup_fmt" \
1772
    "$INPUT_BYTES_PROCESSED" \
1773
    "$OUTPUT_BYTES_PROCESSED"
1774

            
Bogdan Timofte authored a month ago
1775
  if [[ "$ERRORS" -gt 0 ]]; then
1776
    exit 1
1777
  fi
1778
}
1779

            
1780
main "$@"