#!/usr/bin/env bash set -euo pipefail # Garmin Varia batch transcoder (macOS/Linux) # # Apple compatibility note: # For HEVC outputs we explicitly set -tag:v hvc1 (not hev1), which is required # for reliable QuickTime / Photos compatibility. # # Encoding strategy note: # Do not derive output bitrate mechanically from fixed Garmin source sizes. # Prefer CRF (software) or constrained VBR (hardware), as used below. TOOL_NAME="ffmpeg" DEFAULT_SOURCE="." DEFAULT_MODE="hardware" DEFAULT_EXTENSIONS="mp4,mov,avi,m4v" DEFAULT_CRF_HEVC=20 DEFAULT_CRF_H264=19 DEFAULT_STAGING_RAMDISK_MB=8192 # Garmin publicly declares a 140-degree field of view. # We infer practical focal metadata from this single spec. # 35mm-equivalent focal length is integer-only in EXIF, so we round to 7 mm. GARMIN_DECLARED_FOV_DEG=140 GARMIN_INFERRED_FOCAL_MM="6.5" GARMIN_INFERRED_FOCAL_35MM_MM="7" SOURCE_DIR="$DEFAULT_SOURCE" DEST_DIR="$DEFAULT_SOURCE" STAGING_DIR="" MODE="$DEFAULT_MODE" EXTENSIONS_CSV="$DEFAULT_EXTENSIONS" CRF_OVERRIDE="" OVERWRITE=true DRY_RUN=false RECURSIVE=true SINGLE_FILE="" VERBOSE=false MOVE_SOURCE=false SOURCE_PROVIDED=false DEST_PROVIDED=false STAGING_PROVIDED=false STAGING_RAMDISK_MB="$DEFAULT_STAGING_RAMDISK_MB" AUTO_CREATED_STAGING_RAMDISK=false AUTO_CREATED_STAGING_PATH="" AUTO_CREATED_STAGING_DEV="" STAGING_RAMDISK_CREATED_AT=0 AUTO_STAGING_CLEANED_UP=false RUN_STARTED_AT=0 FIRST_ENCODE_STARTED_AT=0 DEBUG_TIMING_LIMIT=0 DEBUG_TIMING_FILES=0 DEBUG_TIMING_STOPPED=false FAIL_FAST=true SOURCE_READABLE_MODE="normal" APPLE_REPACK_FALLBACK=true REPACKED_SOURCE_PATH="" HAS_VIDEOTOOLBOX=false HAS_LIBX265=false HAS_LIBX264=false HAS_AVCONVERT=false ENCODER_KIND="" VIDEO_CODEC="" VIDEO_CRF="" VIDEO_ARGS=() FIND_EXT_EXPR=() EXT_LIST=() VIDEO_FILES=() VIDEOS_PROCESSED=0 VIDEOS_SKIPPED=0 ERRORS=0 INVALID_SOURCES_SKIPPED=0 DESTINATION_FAILURES=0 TOTAL_VIDEO_TIME_SEC=0 TOTAL_FILE_REAL_TIME_SEC=0 INPUT_BYTES_PROCESSED=0 OUTPUT_BYTES_PROCESSED=0 DURATION_TOLERANCE_SEC=1.0 STOP_AFTER_CURRENT=false INTERRUPT_COUNT=0 CURRENT_FFMPEG_PID="" PROGRESS_LINE_OPEN=false usage() { cat <<'EOF' Usage: garmin_varia_transcode.sh [options] Options: -s, --source, --input DIR Source directory or single file (default: current directory) -d, --destination, --output DIR Destination directory (default: current directory) --staging-dir DIR Temporary staging directory for intermediate output files (falls back to destination if staging cannot be used) --staging-ramdisk-mb N RAM disk size in MB when auto-creating missing /Volumes staging directory on macOS (default: 8192) --debug-timing N Process at most N video files, then stop and print timing stats --mode MODE Encoding mode (default: hardware); see Encoding Modes below --crf N CRF value for quality/compat modes (lower = better; default: 20/19) --no-overwrite Skip files that already exist at destination (default: overwrite) --dry-run Print actions without writing files --no-recursive Process only the top-level source directory (default: recursive) --extensions LIST Comma-separated video extensions (default: mp4,mov,avi,m4v) --verbose Log each operation with timestamp; show ffmpeg/ffprobe output --delete-source Delete source file only after strict post-encode validation --keep-going Continue after source-file failures (default: stop) --unattended Preset for long runs: --delete-source + --keep-going --no-apple-repack-fallback Disable macOS avconvert fallback for unreadable MP4/MOV sources --apple-repack-fallback Enable macOS avconvert fallback (default) -h, --help Show this help Encoding Modes: hardware Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only. ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro). Best choice when running on battery or transcoding large libraries. Output quality is good for dashcam/action footage. Falls back to an error on Linux or if videotoolbox is absent. auto Like hardware on macOS with videotoolbox, otherwise falls back to quality, then compat. Safe cross-platform default when the machine is unknown. quality Uses libx265 (software HEVC, CRF 20). Platform-independent. ~50s per 30s clip, ~80W (measured on Apple Silicon MacBook Pro). Best compression ratio and quality for archival, high-resolution, or visually complex sources (e.g. cinema, screen recordings). Overkill for dashcam footage; prefer hardware or auto for those. compat Uses libx264 (software H.264, CRF 19). Maximum player compatibility. Use when the destination player cannot decode HEVC (older TVs, Android 4.x, web browsers without HEVC support). Larger files than HEVC modes. Behavior: - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible) - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility - Metadata is restored from source to output with exiftool - Garmin camera model is also mapped to standard Make/Model tags for compatibility - Inferred lens metadata is written from Garmin's declared 140-degree FOV - Transcoding encoder/mode is written in metadata (Software) - Original directory structure is preserved under destination - When --source points to a file, only that file is processed Examples: ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18 ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat ./garmin_varia_transcode.sh -s SampleFootage -d encoded --delete-source ./garmin_varia_transcode.sh -s SampleFootage -d encoded --unattended EOF } log_msg() { local level="$1" shift local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "[$ts] [$level] $*" } # Verbose-only log: suppressed in default quiet mode vlog_msg() { [[ "$VERBOSE" == true ]] && log_msg "$@" return 0 } # Quiet-mode per-file progress lines log_progress_start() { local input_file="$1" local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" printf '[%s] [INFO] Transcoding %s ...' "$ts" "$input_file" PROGRESS_LINE_OPEN=true } log_progress_done() { local real_elapsed_sec="$2" local encode_elapsed_sec="$3" if [[ "$PROGRESS_LINE_OPEN" == true ]]; then printf ' done in %ss (encode %ss)\n' "$real_elapsed_sec" "$encode_elapsed_sec" PROGRESS_LINE_OPEN=false return fi local input_file="$1" local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "[$ts] [INFO] Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)" } log_progress_failed() { local input_file="$1" local real_elapsed_sec="$2" local encode_elapsed_sec="$3" local ffmpeg_rc="$4" local ffmpeg_log="$5" if [[ "$PROGRESS_LINE_OPEN" == true ]]; then printf ' FAILED after %ss (encode %ss, rc=%s, log=%s)\n' "$real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "${ffmpeg_log:-n/a}" PROGRESS_LINE_OPEN=false return fi local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" 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})" } log_progress_skipped_unreadable() { local input_file="$1" if [[ "$PROGRESS_LINE_OPEN" == true ]]; then printf ' SKIPPED (unreadable/corrupted source)\n' PROGRESS_LINE_OPEN=false return fi local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "[$ts] [INFO] Transcoding $input_file ... SKIPPED (unreadable/corrupted source)" } die() { log_msg "ERROR" "$*" exit 1 } handle_interrupt() { INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1)) STOP_AFTER_CURRENT=true # If quiet-mode progress is mid-line, break it before interrupt logs. if [[ "$PROGRESS_LINE_OPEN" == true ]]; then printf '\n' PROGRESS_LINE_OPEN=false fi if [[ "$INTERRUPT_COUNT" -eq 1 ]]; then # ffmpeg is in the same process group and already received SIGINT; it will # stop gracefully on its own. A second Ctrl+C force-kills it if needed. log_msg "WARN" "Interrupted; stopping after current encode. Ctrl+C again to force-kill." elif [[ -n "$CURRENT_FFMPEG_PID" ]]; then log_msg "WARN" "Force-stopping encode (pid=$CURRENT_FFMPEG_PID)." kill -9 "$CURRENT_FFMPEG_PID" 2>/dev/null || true fi } # Drain all pending keystrokes from /dev/tty (non-blocking) and set # STOP_AFTER_CURRENT if any of them is 'q'/'Q'. Self-contained: saves/restores # stty settings so it works regardless of the terminal's current mode. # Uses min=0 time=0 so the terminal's read() returns immediately when the # buffer is empty (bash treats a 0-byte read as EOF and exits the loop). # -t 1 is a safety net for platforms where select() on a VMIN=0 fd does not # report readable-when-empty; in that case we block at most 1s then exit. check_for_quit_key() { [[ -e /dev/tty ]] || return 0 local key old_stty found=false old_stty=$(stty -g /dev/null) || return 0 stty -echo -icanon min 0 time 0 /dev/null \ || { stty "$old_stty" /dev/null || true; return 0; } while IFS= read -r -s -n 1 -t 1 key /dev/null; do if [[ "$key" == "q" || "$key" == "Q" ]]; then found=true break fi done stty "$old_stty" /dev/null || true if [[ "$found" == true ]]; then STOP_AFTER_CURRENT=true log_msg "INFO" "Quit key pressed; stopping before next file" fi } run_ffmpeg_with_signal_guard() { # ffmpeg runs in the same process group as the shell so Ctrl+C (SIGINT) # reaches it directly and triggers its own graceful shutdown. stdin is # /dev/null so ffmpeg disables keyboard control and avoids SIGTTIN. # CURRENT_FFMPEG_PID is tracked for the force-kill second-Ctrl+C path. "$@" = 1024 && i < 6) { b = b / 1024; i++; } if (i == 1) { printf "%d %s", b, u[i]; } else { printf "%.2f %s", b, u[i]; } }' } print_final_report() { local total_elapsed_sec="$1" local total_elapsed_fmt="$2" local file_post_total_sec="$3" local file_post_total_fmt="$4" local run_non_file_overhead_sec="$5" local run_non_file_overhead_fmt="$6" local avg_file_real_time_sec="$7" local avg_file_real_time_fmt="$8" local avg_video_time_sec="$9" local avg_video_time_fmt="${10}" local startup_warmup_sec="${11}" local startup_warmup_fmt="${12}" local staging_warmup_sec="${13}" local staging_warmup_fmt="${14}" local input_bytes_processed="${15}" local output_bytes_processed="${16}" printf '\n' printf 'Run Summary\n' printf '+---------------------------+-------+\n' printf '| %-25s | %-5s |\n' "Metric" "Value" printf '+---------------------------+-------+\n' printf '| %-25s | %5s |\n' "Videos processed" "$VIDEOS_PROCESSED" printf '| %-25s | %5s |\n' "Videos skipped" "$VIDEOS_SKIPPED" printf '| %-25s | %5s |\n' "Invalid sources skipped" "$INVALID_SOURCES_SKIPPED" printf '| %-25s | %5s |\n' "Destination failures" "$DESTINATION_FAILURES" printf '| %-25s | %5s |\n' "Errors" "$ERRORS" printf '+---------------------------+-------+\n' printf '\nTimings\n' printf '+---------------------------+---------+----------+\n' printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed" printf '+---------------------------+---------+----------+\n' printf '| %-25s | %7s | %-8s |\n' "Total run time" "${total_elapsed_sec}s" "$total_elapsed_fmt" printf '| %-25s | %7s | %-8s |\n' "File wall time total" "${TOTAL_FILE_REAL_TIME_SEC}s" "$(format_seconds "$TOTAL_FILE_REAL_TIME_SEC")" printf '| %-25s | %7s | %-8s |\n' "Encode time total" "${TOTAL_VIDEO_TIME_SEC}s" "$(format_seconds "$TOTAL_VIDEO_TIME_SEC")" printf '| %-25s | %7s | %-8s |\n' "Post-processing total" "${file_post_total_sec}s" "$file_post_total_fmt" printf '| %-25s | %7s | %-8s |\n' "Non-file overhead" "${run_non_file_overhead_sec}s" "$run_non_file_overhead_fmt" if [[ "$startup_warmup_sec" -ge 0 ]]; then printf '| %-25s | %7s | %-8s |\n' "Startup warm-up" "${startup_warmup_sec}s" "$startup_warmup_fmt" fi if [[ "$staging_warmup_sec" -ge 0 ]]; then printf '| %-25s | %7s | %-8s |\n' "Staging warm-up" "${staging_warmup_sec}s" "$staging_warmup_fmt" fi printf '+---------------------------+---------+----------+\n' printf '\nPer-File Averages\n' printf '+---------------------------+---------+----------+\n' printf '| %-25s | %-7s | %-8s |\n' "Metric" "Seconds" "Elapsed" printf '+---------------------------+---------+----------+\n' printf '| %-25s | %7s | %-8s |\n' "Average file wall time" "${avg_file_real_time_sec}s" "$avg_file_real_time_fmt" printf '| %-25s | %7s | %-8s |\n' "Average encode time" "${avg_video_time_sec}s" "$avg_video_time_fmt" printf '+---------------------------+---------+----------+\n' } make_temp_log_file() { local temp_path local base_tmp="${TMPDIR:-/tmp}" base_tmp="${base_tmp%/}" temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)" if [[ -z "$temp_path" ]]; then temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)" fi printf '%s\n' "$temp_path" } make_temp_output_file() { local target_file="$1" local preferred_dir="${2:-}" local target_dir target_base target_noext temp_path seed_path work_dir target_dir="$(dirname "$target_file")" target_base="$(basename "$target_file")" target_noext="${target_base%.*}" work_dir="$target_dir" if [[ -n "$preferred_dir" ]]; then work_dir="$preferred_dir" fi seed_path="$(mktemp "$work_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)" if [[ -z "$seed_path" && "$work_dir" != "$target_dir" ]]; then seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)" fi if [[ -n "$seed_path" ]]; then rm -f -- "$seed_path" 2>/dev/null || true temp_path="${seed_path}.mp4" else temp_path="$work_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4" if ! touch "$temp_path" >/dev/null 2>&1; then if [[ "$work_dir" != "$target_dir" ]]; then temp_path="$target_dir/.varia_tmp.${target_noext}.$$.$RANDOM.mp4" if ! touch "$temp_path" >/dev/null 2>&1; then temp_path="" else rm -f -- "$temp_path" 2>/dev/null || true fi else temp_path="" fi else rm -f -- "$temp_path" 2>/dev/null || true fi fi printf '%s\n' "$temp_path" } cleanup_transcode_artifacts() { local temp_output="$1" local final_output="$2" local exif_backup_default="${temp_output}_original" local exif_backup_alt="${temp_output}.exif_original" rm -f -- "$temp_output" 2>/dev/null || true rm -f -- "$exif_backup_default" "$exif_backup_alt" 2>/dev/null || true # Defensive cleanup for previously failed non-atomic runs. if [[ -f "$final_output" && ! -s "$final_output" ]]; then rm -f -- "$final_output" 2>/dev/null || true fi } is_apple_noise_file() { local file_path="$1" local base base="$(basename "$file_path")" case "$base" in ._*|.DS_Store|.AppleDouble|._.DS_Store) return 0 ;; esac return 1 } require_value() { local flag="$1" local value="${2:-}" if [[ -z "$value" ]]; then die "Missing value for $flag" fi } to_abs_path() { local p="$1" if [[ "$p" = /* ]]; then printf '%s\n' "$p" else printf '%s\n' "$PWD/$p" fi } path_is_within() { local child="$1" local parent="$2" [[ "$child" == "$parent" || "$child" == "$parent"/* ]] } file_size_bytes_or_zero() { local file_path="$1" local file_size="0" file_size="$(stat -f%z "$file_path" 2>/dev/null || true)" if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then file_size="$(stat -c%s "$file_path" 2>/dev/null || true)" fi if [[ "$file_size" =~ ^[0-9]+$ ]]; then printf '%s\n' "$file_size" else printf '0\n' fi } dir_available_bytes_or_zero() { local dir_path="$1" local avail_kb="" avail_kb="$(df -Pk "$dir_path" 2>/dev/null | awk 'NR==2 {print $4}' || true)" if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then printf '%s\n' $((avail_kb * 1024)) else printf '0\n' fi } staging_has_space_for_input() { local staging_dir="$1" local input_file="$2" local input_size_bytes=0 local needed_bytes=0 local available_bytes=0 [[ -n "$staging_dir" && -d "$staging_dir" && -w "$staging_dir" ]] || return 1 input_size_bytes="$(file_size_bytes_or_zero "$input_file")" if [[ "$input_size_bytes" -le 0 ]]; then return 1 fi # Keep one simple rule: need roughly 2x input size in staging. # This covers temp output plus metadata rewrite overhead. needed_bytes=$((input_size_bytes * 2)) available_bytes="$(dir_available_bytes_or_zero "$staging_dir")" [[ "$available_bytes" -ge "$needed_bytes" ]] } join_cmd_for_log() { local out="" local arg for arg in "$@"; do out+="$(printf '%q' "$arg") " done printf '%s\n' "${out% }" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in -s|--source|--input) require_value "$1" "${2:-}" SOURCE_DIR="$2" SOURCE_PROVIDED=true shift 2 ;; -d|--destination|--output) require_value "$1" "${2:-}" DEST_DIR="$2" DEST_PROVIDED=true shift 2 ;; --staging-dir) require_value "$1" "${2:-}" STAGING_DIR="$2" STAGING_PROVIDED=true shift 2 ;; --staging-ramdisk-mb) require_value "$1" "${2:-}" STAGING_RAMDISK_MB="$2" shift 2 ;; --debug-timing) require_value "$1" "${2:-}" DEBUG_TIMING_LIMIT="$2" shift 2 ;; --mode) require_value "$1" "${2:-}" MODE="$2" shift 2 ;; --crf) require_value "$1" "${2:-}" CRF_OVERRIDE="$2" shift 2 ;; --overwrite) OVERWRITE=true shift ;; --no-overwrite) OVERWRITE=false shift ;; --dry-run) DRY_RUN=true shift ;; --recursive) RECURSIVE=true shift ;; --no-recursive) RECURSIVE=false shift ;; --extensions) require_value "$1" "${2:-}" EXTENSIONS_CSV="$2" shift 2 ;; --single) require_value "$1" "${2:-}" SINGLE_FILE="$2" shift 2 ;; --verbose) VERBOSE=true shift ;; --delete-source) MOVE_SOURCE=true shift ;; --keep-going) FAIL_FAST=false shift ;; --unattended) MOVE_SOURCE=true FAIL_FAST=false shift ;; --no-apple-repack-fallback) APPLE_REPACK_FALLBACK=false shift ;; --apple-repack-fallback) APPLE_REPACK_FALLBACK=true shift ;; --move-source) MOVE_SOURCE=true shift ;; --continue-on-error) FAIL_FAST=false shift ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1" ;; esac done case "$MODE" in auto|hardware|quality|compat) ;; *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;; esac if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then die "At least one of --source or --destination must be provided" fi if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then die "--crf must be an integer" fi if [[ "$DEBUG_TIMING_LIMIT" != "0" ]]; then if ! [[ "$DEBUG_TIMING_LIMIT" =~ ^[0-9]+$ ]]; then die "--debug-timing must be a positive integer" fi if [[ "$DEBUG_TIMING_LIMIT" -le 0 ]]; then die "--debug-timing must be greater than 0" fi fi if ! [[ "$STAGING_RAMDISK_MB" =~ ^[0-9]+$ ]]; then die "--staging-ramdisk-mb must be a positive integer" fi if [[ "$STAGING_RAMDISK_MB" -le 0 ]]; then die "--staging-ramdisk-mb must be greater than 0" fi } check_tools() { command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH" command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH" command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH" HAS_AVCONVERT=false if [[ "$(uname -s)" == "Darwin" ]] && command -v avconvert >/dev/null 2>&1; then HAS_AVCONVERT=true fi } make_temp_repack_file() { local base_tmp="${TMPDIR:-/tmp}" local seed_path temp_path base_tmp="${base_tmp%/}" seed_path="$(mktemp "$base_tmp/varia_repack.XXXXXX" 2>/dev/null || true)" if [[ -n "$seed_path" ]]; then rm -f -- "$seed_path" 2>/dev/null || true temp_path="${seed_path}.mp4" printf '%s\n' "$temp_path" return fi temp_path="$base_tmp/varia_repack.$$.$RANDOM.mp4" printf '%s\n' "$temp_path" } cleanup_repacked_input() { local repacked_file="$1" if [[ -n "$repacked_file" ]]; then rm -f -- "$repacked_file" 2>/dev/null || true fi } try_apple_repack_for_unreadable() { local source_file="$1" local repacked_file="" REPACKED_SOURCE_PATH="" if [[ "$APPLE_REPACK_FALLBACK" != true || "$HAS_AVCONVERT" != true || "$(uname -s)" != "Darwin" ]]; then return 1 fi repacked_file="$(make_temp_repack_file)" if [[ -z "$repacked_file" ]]; then return 1 fi if [[ "$VERBOSE" == true ]]; then log_msg "WARN" "Trying avconvert passthrough repack fallback: $source_file" if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace; then cleanup_repacked_input "$repacked_file" return 1 fi else if ! avconvert --source "$source_file" --preset PresetPassthrough --output "$repacked_file" --replace >/dev/null 2>&1; then cleanup_repacked_input "$repacked_file" return 1 fi fi if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$repacked_file" >/dev/null 2>&1; then REPACKED_SOURCE_PATH="$repacked_file" return 0 fi cleanup_repacked_input "$repacked_file" return 1 } restore_metadata_with_exiftool() { local input_file="$1" local output_file="$2" vlog_msg "CHECKPOINT" "metadata_start: $output_file" if [[ "$VERBOSE" == true ]]; then if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then vlog_msg "CHECKPOINT" "metadata_end: $output_file" return 0 fi else if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then vlog_msg "CHECKPOINT" "metadata_end: $output_file" return 0 fi fi if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then log_msg "INFO" "Metadata restore interrupted by user while stopping: $output_file" return 1 fi log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file" return 1 } map_garmin_model_to_standard_tags() { local input_file="$1" local output_file="$2" local garmin_model garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)" if [[ -z "$garmin_model" ]]; then vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file" return 0 fi # garmin_model confirms this is a Garmin Varia source; fixed metadata values are # used below because Garmin does not embed a parseable model string we can map. vlog_msg "CHECKPOINT" "model_map_start: $output_file (GarminModel=$garmin_model)" if [[ "$VERBOSE" == true ]]; then if exiftool -overwrite_original -m \ -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \ -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \ -Keys:CompatibleBrands="isom, iso2, mp41" \ -Keys:MajorBrand="isom" \ -Make="Garmin" -Model="Varia RCT715" \ -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \ -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \ -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \ "$output_file"; then vlog_msg "CHECKPOINT" "model_map_end: $output_file" return 0 fi else if exiftool -overwrite_original -m -q -q \ -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \ -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \ -Keys:CompatibleBrands="isom, iso2, mp41" \ -Keys:MajorBrand="isom" \ -Make="Garmin" -Model="Varia RCT715" \ -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \ -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \ -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \ "$output_file" >/dev/null 2>&1; then vlog_msg "CHECKPOINT" "model_map_end: $output_file" return 0 fi fi log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file" return 1 } write_transcode_encoder_metadata() { local output_file="$1" local encoder_meta encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC" vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)" if [[ "$VERBOSE" == true ]]; then if exiftool -overwrite_original -m \ -Software="$encoder_meta" \ -UserData:Software="$encoder_meta" \ "$output_file"; then vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file" return 0 fi else if exiftool -overwrite_original -m -q -q \ -Software="$encoder_meta" \ -UserData:Software="$encoder_meta" \ "$output_file" >/dev/null 2>&1; then vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file" return 0 fi fi log_msg "ERROR" "Failed to write encoder metadata: $output_file" return 1 } detect_encoders() { local encoders encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)" if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then HAS_VIDEOTOOLBOX=true fi if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then HAS_LIBX265=true fi if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then HAS_LIBX264=true fi if [[ "$VERBOSE" == true ]]; then log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264" fi } resolve_encoder() { local os_name os_name="$(uname -s)" case "$MODE" in auto) if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then ENCODER_KIND="hardware" elif [[ "$HAS_LIBX265" == true ]]; then ENCODER_KIND="quality" elif [[ "$HAS_LIBX264" == true ]]; then ENCODER_KIND="compat" else die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264." fi ;; hardware) [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)" [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg" ENCODER_KIND="hardware" ;; quality) [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg" ENCODER_KIND="quality" ;; compat) [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg" ENCODER_KIND="compat" ;; esac case "$ENCODER_KIND" in hardware) VIDEO_CODEC="hevc_videotoolbox" VIDEO_CRF="" ;; quality) VIDEO_CODEC="libx265" VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}" ;; compat) VIDEO_CODEC="libx264" VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}" ;; esac vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})" } probe_has_audio() { local input_file="$1" local out out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)" [[ -n "$out" ]] } print_verbose_probe() { local input_file="$1" ffprobe -v error \ -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 \ -of default=noprint_wrappers=1:nokey=0 "$input_file" || true } ffprobe_duration_or_empty() { local file_path="$1" ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1 } ffprobe_video_codec_or_empty() { local file_path="$1" 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 } source_video_is_readable() { local file_path="$1" REPACKED_SOURCE_PATH="" if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" >/dev/null 2>&1; then SOURCE_READABLE_MODE="normal" return 0 fi if [[ "$VERBOSE" == true ]]; then log_msg "WARN" "Source probe failed, trying tolerant mode: $file_path" fi 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 SOURCE_READABLE_MODE="tolerant" vlog_msg "INFO" "Source readable in tolerant mode: $file_path" return 0 fi if try_apple_repack_for_unreadable "$file_path"; then SOURCE_READABLE_MODE="repacked" log_msg "WARN" "Using avconvert repack fallback for unreadable source: $file_path" return 0 fi SOURCE_READABLE_MODE="normal" return 1 } source_error_from_ffmpeg_log() { local ffmpeg_log="$1" [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]] || return 1 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" } destination_cannot_accept_file() { local input_file="$1" local out_dir="$2" local ffmpeg_log="$3" local probe_path="" local avail_kb="" local avail_bytes=0 if [[ -n "$ffmpeg_log" && -f "$ffmpeg_log" ]]; then 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 return 0 fi fi probe_path="$(mktemp "$out_dir/.varia_write_probe.XXXXXX" 2>/dev/null || true)" if [[ -z "$probe_path" ]]; then return 0 fi rm -f -- "$probe_path" 2>/dev/null || true avail_kb="$(df -Pk "$out_dir" 2>/dev/null | awk 'NR==2 {print $4}' || true)" if [[ "$avail_kb" =~ ^[0-9]+$ ]]; then avail_bytes=$((avail_kb * 1024)) if [[ "$avail_bytes" -lt 67108864 ]]; then return 0 fi fi return 1 } validate_transcoded_output() { local input_file="$1" local output_file="$2" if [[ ! -f "$output_file" ]]; then log_msg "ERROR" "Validation failed: destination file missing: $output_file" return 1 fi local expected_codec actual_codec case "$ENCODER_KIND" in hardware|quality) expected_codec="hevc" ;; compat) expected_codec="h264" ;; *) log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'" return 1 ;; esac actual_codec="$(ffprobe_video_codec_or_empty "$output_file")" if [[ -z "$actual_codec" ]]; then log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file" return 1 fi if [[ "$actual_codec" != "$expected_codec" ]]; then log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)" return 1 fi local in_duration out_duration duration_delta in_duration="$(ffprobe_duration_or_empty "$input_file")" out_duration="$(ffprobe_duration_or_empty "$output_file")" if [[ -z "$in_duration" || -z "$out_duration" ]]; then log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')" return 1 fi duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')" if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then 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)" return 1 fi vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)" return 0 } build_video_args() { VIDEO_ARGS=() case "$ENCODER_KIND" in hardware) VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 ) ;; quality) VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 ) ;; compat) VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p ) ;; esac } normalize_source_dir() { SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")" [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR" SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)" } normalize_dest_dir() { DEST_DIR="$(to_abs_path "$DEST_DIR")" } # Sets globals: AUTO_CREATED_STAGING_RAMDISK, AUTO_CREATED_STAGING_PATH, # AUTO_CREATED_STAGING_DEV, STAGING_RAMDISK_CREATED_AT # Must be called directly (NOT inside $(...)) to preserve global assignments. create_missing_staging_ramdisk_if_needed() { local staging_path="$1" local ramdisk_name="" local sectors=0 local dev="" local mount_point="" staging_path="${staging_path%/}" if [[ -d "$staging_path" ]]; then # Already exists; nothing to create. return fi if [[ "$(uname -s)" != "Darwin" ]]; then return fi case "$staging_path" in /Volumes/*) ;; *) return ;; esac ramdisk_name="$(basename "$staging_path")" if [[ -z "$ramdisk_name" || "$ramdisk_name" == "." || "$ramdisk_name" == ".." ]]; then return fi if [[ "$staging_path" != "/Volumes/$ramdisk_name" ]]; then # Only auto-create for top-level /Volumes/, not nested paths. return fi [[ -x "/usr/bin/hdiutil" ]] || die "hdiutil not found; cannot auto-create RAM disk" [[ -x "/usr/sbin/diskutil" ]] || die "diskutil not found; cannot auto-create RAM disk" sectors=$((STAGING_RAMDISK_MB * 2048)) vlog_msg "INFO" "Creating RAM disk for staging: /Volumes/$ramdisk_name (${STAGING_RAMDISK_MB}MB)" dev="$(/usr/bin/hdiutil attach -nomount "ram://$sectors" 2>/dev/null | /usr/bin/awk 'NR==1 {print $1}' || true)" if [[ -z "$dev" ]]; then return fi if ! /usr/sbin/diskutil eraseVolume APFS "$ramdisk_name" "$dev" >/dev/null 2>&1; then /usr/bin/hdiutil detach "$dev" >/dev/null 2>&1 || true return fi mount_point="$(/usr/sbin/diskutil info "$dev" 2>/dev/null | /usr/bin/awk -F': *' '/Mount Point/ {print $2; exit}')" if [[ -z "$mount_point" ]]; then mount_point="$staging_path" fi if [[ -d "$mount_point" ]]; then AUTO_CREATED_STAGING_RAMDISK=true AUTO_CREATED_STAGING_PATH="$mount_point" AUTO_CREATED_STAGING_DEV="$dev" STAGING_RAMDISK_CREATED_AT="$(date +%s)" fi } attempt_unmount_auto_staging_ramdisk() { if [[ "$AUTO_CREATED_STAGING_RAMDISK" != true || -z "$AUTO_CREATED_STAGING_PATH" ]]; then return fi if [[ "$AUTO_STAGING_CLEANED_UP" == true ]]; then return fi AUTO_STAGING_CLEANED_UP=true if [[ "$(uname -s)" != "Darwin" ]]; then return fi local target="${AUTO_CREATED_STAGING_DEV:-$AUTO_CREATED_STAGING_PATH}" if /usr/bin/hdiutil detach "$target" >/dev/null 2>&1; then log_msg "INFO" "Auto-created staging RAM disk unmounted: $AUTO_CREATED_STAGING_PATH" return fi if [[ -d "$AUTO_CREATED_STAGING_PATH" ]]; then log_msg "WARN" "Could not unmount auto-created staging RAM disk; it remains mounted: $AUTO_CREATED_STAGING_PATH" else log_msg "INFO" "Auto-created staging RAM disk already gone: $AUTO_CREATED_STAGING_PATH" fi } cleanup_on_exit() { local rc=$? attempt_unmount_auto_staging_ramdisk return "$rc" } normalize_staging_dir() { if [[ "$STAGING_PROVIDED" != true ]]; then STAGING_DIR="" return fi STAGING_DIR="$(to_abs_path "$STAGING_DIR")" if [[ ! -d "$STAGING_DIR" ]]; then create_missing_staging_ramdisk_if_needed "$STAGING_DIR" if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && -d "$AUTO_CREATED_STAGING_PATH" ]]; then STAGING_DIR="$AUTO_CREATED_STAGING_PATH" log_msg "INFO" "Created staging RAM disk: $STAGING_DIR" else die "Staging directory not found: $STAGING_DIR" fi fi [[ -d "$STAGING_DIR" ]] || die "Staging directory not found: $STAGING_DIR" STAGING_DIR="$(cd "$STAGING_DIR" && pwd)" [[ -w "$STAGING_DIR" ]] || die "Staging directory not writable: $STAGING_DIR" if path_is_within "$STAGING_DIR" "$SOURCE_DIR"; then die "Staging directory must not be inside source: staging=$STAGING_DIR source=$SOURCE_DIR" fi } collect_extensions() { local raw="$EXTENSIONS_CSV" local token EXT_LIST=() IFS=',' read -r -a tokens <<< "$raw" for token in "${tokens[@]}"; do token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" token="${token#.}" token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')" [[ -n "$token" ]] && EXT_LIST+=("$token") done if [[ ${#EXT_LIST[@]} -eq 0 ]]; then die "No valid extensions after parsing --extensions" fi } build_find_expr_for_extensions() { FIND_EXT_EXPR=() local ext for ext in "${EXT_LIST[@]}"; do FIND_EXT_EXPR+=( -iname "*.${ext}" -o ) done if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]' fi } rel_path_from_source() { local abs_file="$1" if path_is_within "$abs_file" "$SOURCE_DIR"; then printf '%s\n' "${abs_file#$SOURCE_DIR/}" else printf '%s\n' "$(basename "$abs_file")" fi } collect_video_files() { VIDEO_FILES=() if [[ -n "$SINGLE_FILE" ]]; then local single_abs single_abs="$(to_abs_path "$SINGLE_FILE")" [[ -f "$single_abs" ]] || die "--single file not found: $single_abs" VIDEO_FILES+=("$single_abs") return fi build_find_expr_for_extensions while IFS= read -r -d '' file; do if is_apple_noise_file "$file"; then vlog_msg "SKIP" "Ignoring Apple artifact: $file" continue fi VIDEO_FILES+=("$file") done < <( if [[ "$RECURSIVE" == true ]]; then find "$SOURCE_DIR" \ -path "$DEST_DIR" -prune -o \ -type f \( "${FIND_EXT_EXPR[@]}" \) -print0 else find "$SOURCE_DIR" \ -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0 fi ) } process_video_file() { local input_file="$1" local rel_path output_file temp_output_file out_dir local display_path local encode_input_file local preferred_temp_dir="" local repacked_input_file="" local input_size_bytes=0 output_size_bytes=0 local file_started_at file_ended_at file_real_elapsed_sec local encode_started_at encode_ended_at encode_elapsed_sec=0 local post_elapsed_sec file_started_at="$(date +%s)" vlog_msg "CHECKPOINT" "file_start: $input_file" rel_path="$(rel_path_from_source "$input_file")" output_file="$DEST_DIR/${rel_path%.*}.mp4" out_dir="$(dirname "$output_file")" display_path="${input_file#$PWD/}" encode_input_file="$input_file" mkdir -p "$out_dir" if ! source_video_is_readable "$input_file"; then ERRORS=$((ERRORS + 1)) INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1)) if [[ "$VERBOSE" == true ]]; then log_msg "ERROR" "Skipping unreadable/corrupted source video: $input_file" else log_progress_start "$display_path" log_progress_skipped_unreadable "$display_path" fi return 2 fi if [[ "$SOURCE_READABLE_MODE" == "repacked" && -n "$REPACKED_SOURCE_PATH" ]]; then repacked_input_file="$REPACKED_SOURCE_PATH" encode_input_file="$REPACKED_SOURCE_PATH" vlog_msg "INFO" "Encoding from avconvert repacked source: $repacked_input_file" fi if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then vlog_msg "SKIP" "Video exists: $output_file" VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1)) cleanup_repacked_input "$repacked_input_file" return fi local has_audio=false if probe_has_audio "$encode_input_file"; then has_audio=true fi if [[ "$VERBOSE" == true ]]; then log_msg "INFO" "ffprobe summary: $input_file" print_verbose_probe "$input_file" log_msg "INFO" "Audio detected: $has_audio" fi build_video_args preferred_temp_dir="$STAGING_DIR" if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]]; then if ! staging_has_space_for_input "$STAGING_DIR" "$encode_input_file"; then preferred_temp_dir="" log_msg "WARN" "Insufficient staging space; using destination temp path for: $input_file" fi fi temp_output_file="$(make_temp_output_file "$output_file" "$preferred_temp_dir")" if [[ -z "$temp_output_file" ]]; then ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) log_msg "ERROR" "Could not create temporary output file: $output_file" return 3 fi if [[ "$STAGING_PROVIDED" == true && -n "$STAGING_DIR" ]] && ! path_is_within "$temp_output_file" "$STAGING_DIR"; then log_msg "WARN" "Staging unavailable for temp output; using destination directory: $temp_output_file" fi local cmd=(ffmpeg -hide_banner) if [[ "$SOURCE_READABLE_MODE" == "tolerant" ]]; then cmd+=( -fflags +genpts -err_detect ignore_err ) fi if [[ "$OVERWRITE" == true ]]; then cmd+=( -y ) else cmd+=( -n ) fi cmd+=( -i "$encode_input_file" -map 0:v:0 ) if [[ "$has_audio" == true ]]; then cmd+=( -map 0:a? -c:a aac -b:a 128k ) fi cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$temp_output_file" ) if [[ "$VERBOSE" == true ]]; then log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")" fi if [[ "$DRY_RUN" == true ]]; then log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file" return 0 fi if [[ "$VERBOSE" != true ]]; then log_progress_start "$display_path" fi vlog_msg "INFO" "Encoding: $input_file -> $output_file (temp: $temp_output_file)" if [[ "$FIRST_ENCODE_STARTED_AT" -eq 0 ]]; then local startup_warmup_sec=0 local warmup_elapsed_sec=0 FIRST_ENCODE_STARTED_AT="$(date +%s)" if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 ]]; then warmup_elapsed_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT)) if [[ "$warmup_elapsed_sec" -lt 0 ]]; then warmup_elapsed_sec=0 fi fi fi encode_started_at="$(date +%s)" vlog_msg "CHECKPOINT" "encode_start: $input_file" local ffmpeg_rc=0 local ffmpeg_log="" if [[ "$VERBOSE" == true ]]; then # Verbose: show ffmpeg output directly if run_ffmpeg_with_signal_guard "${cmd[@]}"; then : else ffmpeg_rc=$? fi else # Quiet (default): redirect ffmpeg output; keep log on failure ffmpeg_log="$(make_temp_log_file)" if [[ -z "$ffmpeg_log" ]]; then ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) log_msg "ERROR" "Could not create temporary ffmpeg log file" return 3 fi if run_ffmpeg_with_signal_guard "${cmd[@]}" >"$ffmpeg_log" 2>&1; then rm -f "$ffmpeg_log" else ffmpeg_rc=$? fi fi encode_ended_at="$(date +%s)" encode_elapsed_sec=$((encode_ended_at - encode_started_at)) vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)" if [[ "$ffmpeg_rc" -ne 0 ]]; then # Ctrl+C reached ffmpeg directly (same process group); it stopped mid-encode. # Treat any user-triggered abort as a clean stop — no error counters, no noise. if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" cleanup_repacked_input "$repacked_input_file" return 4 fi local failure_rc=1 if destination_cannot_accept_file "$input_file" "$out_dir" "$ffmpeg_log"; then failure_rc=3 elif source_error_from_ffmpeg_log "$ffmpeg_log"; then failure_rc=2 fi cleanup_transcode_artifacts "$temp_output_file" "$output_file" file_ended_at="$(date +%s)" file_real_elapsed_sec=$((file_ended_at - file_started_at)) local encode_elapsed_fmt local real_elapsed_fmt encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")" real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")" ERRORS=$((ERRORS + 1)) if [[ "$failure_rc" -eq 2 ]]; then INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1)) elif [[ "$failure_rc" -eq 3 ]]; then DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) fi if [[ "$VERBOSE" == true ]]; then 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)" else log_progress_failed "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" "$ffmpeg_rc" "$ffmpeg_log" fi if [[ "$failure_rc" -eq 3 ]]; then log_msg "ERROR" "Destination cannot accept more output data: $out_dir" fi cleanup_repacked_input "$repacked_input_file" return "$failure_rc" fi TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec)) if ! restore_metadata_with_exiftool "$input_file" "$temp_output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then cleanup_repacked_input "$repacked_input_file" return 4 fi ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) cleanup_repacked_input "$repacked_input_file" return 3 fi if ! map_garmin_model_to_standard_tags "$input_file" "$temp_output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then cleanup_repacked_input "$repacked_input_file" return 4 fi ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) cleanup_repacked_input "$repacked_input_file" return 3 fi if ! write_transcode_encoder_metadata "$temp_output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then cleanup_repacked_input "$repacked_input_file" return 4 fi ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) cleanup_repacked_input "$repacked_input_file" return 3 fi if [[ "$MOVE_SOURCE" == true ]]; then if ! validate_transcoded_output "$encode_input_file" "$temp_output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" ERRORS=$((ERRORS + 1)) if destination_cannot_accept_file "$input_file" "$out_dir" ""; then DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) cleanup_repacked_input "$repacked_input_file" return 3 fi # Validation failed but destination is healthy: the source or encoder produced # a corrupt/truncated output. Treat as source error so --keep-going can skip it. INVALID_SOURCES_SKIPPED=$((INVALID_SOURCES_SKIPPED + 1)) log_msg "ERROR" "Encode produced invalid output (source may be corrupt): $input_file" cleanup_repacked_input "$repacked_input_file" return 2 fi fi touch -r "$input_file" "$temp_output_file" || true if [[ "$OVERWRITE" == true ]]; then if ! mv -f "$temp_output_file" "$output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) log_msg "ERROR" "Failed to move completed output into place: $output_file" cleanup_repacked_input "$repacked_input_file" return 3 fi else if ! mv -n "$temp_output_file" "$output_file"; then cleanup_transcode_artifacts "$temp_output_file" "$output_file" ERRORS=$((ERRORS + 1)) DESTINATION_FAILURES=$((DESTINATION_FAILURES + 1)) log_msg "ERROR" "Failed to move completed output into place: $output_file" cleanup_repacked_input "$repacked_input_file" return 3 fi fi input_size_bytes="$(file_size_bytes_or_zero "$input_file")" output_size_bytes="$(file_size_bytes_or_zero "$output_file")" INPUT_BYTES_PROCESSED=$((INPUT_BYTES_PROCESSED + input_size_bytes)) OUTPUT_BYTES_PROCESSED=$((OUTPUT_BYTES_PROCESSED + output_size_bytes)) if [[ "$MOVE_SOURCE" == true ]]; then if rm -f "$input_file"; then vlog_msg "INFO" "Removed source after successful validation: $input_file" else ERRORS=$((ERRORS + 1)) log_msg "ERROR" "Failed to remove source after validation: $input_file" cleanup_repacked_input "$repacked_input_file" return 1 fi fi file_ended_at="$(date +%s)" file_real_elapsed_sec=$((file_ended_at - file_started_at)) post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec)) if [[ "$post_elapsed_sec" -lt 0 ]]; then post_elapsed_sec=0 fi TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec)) vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)" VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1)) if [[ "$VERBOSE" == true ]]; then 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"))" else log_progress_done "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" fi cleanup_repacked_input "$repacked_input_file" return 0 } main() { local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt total_started_at="$(date +%s)" RUN_STARTED_AT="$total_started_at" trap 'handle_interrupt' INT TERM trap 'cleanup_on_exit' EXIT parse_args "$@" check_tools # Auto single-file detection: if --source points to a file, treat it as single-file mode if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then SINGLE_FILE="$SOURCE_DIR" SOURCE_DIR="$(dirname "$SINGLE_FILE")" fi normalize_source_dir normalize_dest_dir normalize_staging_dir collect_extensions if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR" fi detect_encoders resolve_encoder collect_video_files if [[ -z "${VIDEO_FILES[*]-}" ]]; then log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV" fi local f for f in ${VIDEO_FILES+"${VIDEO_FILES[@]}"}; do check_for_quit_key if [[ "$STOP_AFTER_CURRENT" == true ]]; then log_msg "INFO" "Stop requested; ending before next file" break fi local process_rc=0 if process_video_file "$f"; then process_rc=0 else process_rc=$? fi if [[ "$DEBUG_TIMING_LIMIT" -gt 0 ]]; then DEBUG_TIMING_FILES=$((DEBUG_TIMING_FILES + 1)) if [[ "$DEBUG_TIMING_FILES" -ge "$DEBUG_TIMING_LIMIT" ]]; then DEBUG_TIMING_STOPPED=true fi fi if [[ "$DEBUG_TIMING_STOPPED" == true ]]; then log_msg "INFO" "Debug timing limit reached after $DEBUG_TIMING_FILES file(s); stopping before next file" break fi if [[ "$process_rc" -eq 0 ]]; then continue fi case "$process_rc" in 2) if [[ "$STOP_AFTER_CURRENT" == true && "$INTERRUPT_COUNT" -gt 0 ]]; then log_msg "INFO" "Stopped by user after current file" break fi log_msg "INFO" "Continuing after unreadable/corrupted source file" continue ;; 3) log_msg "ERROR" "Stopping encoding chain because destination is not writable or is out of space" break ;; 4) log_msg "INFO" "Stopped by user after current file" break ;; *) if [[ "$FAIL_FAST" == true ]]; then log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption" break fi log_msg "ERROR" "Continuing after ffmpeg failure because --keep-going is enabled" continue ;; esac done total_ended_at="$(date +%s)" total_elapsed_sec=$((total_ended_at - total_started_at)) total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")" local avg_video_time_sec=0 avg_video_time_fmt="00:00:00" local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00" local file_post_total_sec=0 file_post_total_fmt="00:00:00" local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00" local startup_warmup_sec=-1 startup_warmup_fmt="n/a" local staging_warmup_sec=-1 staging_warmup_fmt="n/a" if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED)) avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")" avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED)) avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")" fi file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC)) if [[ "$file_post_total_sec" -lt 0 ]]; then file_post_total_sec=0 fi file_post_total_fmt="$(format_seconds "$file_post_total_sec")" run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC)) if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then run_non_file_overhead_sec=0 fi run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")" if [[ "$RUN_STARTED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then startup_warmup_sec=$((FIRST_ENCODE_STARTED_AT - RUN_STARTED_AT)) if [[ "$startup_warmup_sec" -lt 0 ]]; then startup_warmup_sec=0 fi startup_warmup_fmt="$(format_seconds "$startup_warmup_sec")" fi if [[ "$AUTO_CREATED_STAGING_RAMDISK" == true && "$STAGING_RAMDISK_CREATED_AT" -gt 0 && "$FIRST_ENCODE_STARTED_AT" -gt 0 ]]; then staging_warmup_sec=$((FIRST_ENCODE_STARTED_AT - STAGING_RAMDISK_CREATED_AT)) if [[ "$staging_warmup_sec" -lt 0 ]]; then staging_warmup_sec=0 fi staging_warmup_fmt="$(format_seconds "$staging_warmup_sec")" fi print_final_report \ "$total_elapsed_sec" \ "$total_elapsed_fmt" \ "$file_post_total_sec" \ "$file_post_total_fmt" \ "$run_non_file_overhead_sec" \ "$run_non_file_overhead_fmt" \ "$avg_file_real_time_sec" \ "$avg_file_real_time_fmt" \ "$avg_video_time_sec" \ "$avg_video_time_fmt" \ "$startup_warmup_sec" \ "$startup_warmup_fmt" \ "$staging_warmup_sec" \ "$staging_warmup_fmt" \ "$INPUT_BYTES_PROCESSED" \ "$OUTPUT_BYTES_PROCESSED" if [[ "$ERRORS" -gt 0 ]]; then exit 1 fi } main "$@"