1 contributor
#!/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
# 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"
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
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
JSON_COPIED=0
JSON_SKIPPED=0
ERRORS=0
INVALID_SOURCES_SKIPPED=0
DESTINATION_FAILURES=0
TOTAL_VIDEO_TIME_SEC=0
TOTAL_FILE_REAL_TIME_SEC=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)
--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
- JSON sidecar files found in source are copied 1:1 to destination
- telemetry_manifest.json is created in destination as a placeholder contract
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 : 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 : 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 : 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 : 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 [[ "$INTERRUPT_COUNT" -eq 1 ]]; then
if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
log_msg "WARN" "Stop requested. Will stop after current file. Press Ctrl+C again to abort current encode immediately."
else
log_msg "WARN" "Stop requested. Exiting before next file."
fi
return
fi
if [[ -n "$CURRENT_FFMPEG_PID" ]]; then
log_msg "WARN" "Force-stopping current encode (pid=$CURRENT_FFMPEG_PID)."
kill -INT "$CURRENT_FFMPEG_PID" 2>/dev/null || true
fi
}
run_ffmpeg_with_signal_guard() {
(
trap '' INT TERM
exec "$@"
) &
CURRENT_FFMPEG_PID=$!
if wait "$CURRENT_FFMPEG_PID"; then
CURRENT_FFMPEG_PID=""
return 0
fi
local rc=$?
CURRENT_FFMPEG_PID=""
return "$rc"
}
format_seconds() {
local sec="$1"
local h m s
h=$((sec / 3600))
m=$(((sec % 3600) / 60))
s=$((sec % 60))
printf '%02d:%02d:%02d' "$h" "$m" "$s"
}
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 target_dir target_base target_noext temp_path seed_path
target_dir="$(dirname "$target_file")"
target_base="$(basename "$target_file")"
target_noext="${target_base%.*}"
seed_path="$(mktemp "$target_dir/.varia_tmp.${target_noext}.XXXXXX" 2>/dev/null || true)"
if [[ -n "$seed_path" ]]; then
rm -f -- "$seed_path" 2>/dev/null || true
temp_path="${seed_path}.mp4"
else
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
fi
printf '%s\n' "$temp_path"
}
cleanup_transcode_artifacts() {
local temp_output="$1"
local final_output="$2"
rm -f -- "$temp_output" 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"/* ]]
}
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
;;
--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
}
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
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")"
}
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 repacked_input_file=""
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
temp_output_file="$(make_temp_output_file "$output_file")"
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
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)"
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
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"
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
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
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
}
copy_one_json() {
local json_file="$1"
local rel_json dst_json dst_dir
rel_json="$(rel_path_from_source "$json_file")"
dst_json="$DEST_DIR/$rel_json"
dst_dir="$(dirname "$dst_json")"
mkdir -p "$dst_dir"
if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
JSON_SKIPPED=$((JSON_SKIPPED + 1))
vlog_msg "SKIP" "JSON exists: $dst_json"
return
fi
if [[ "$DRY_RUN" == true ]]; then
JSON_COPIED=$((JSON_COPIED + 1))
log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
return
fi
if cp -f "$json_file" "$dst_json"; then
touch -r "$json_file" "$dst_json" || true
JSON_COPIED=$((JSON_COPIED + 1))
vlog_msg "INFO" "Copied JSON: $dst_json"
else
ERRORS=$((ERRORS + 1))
log_msg "ERROR" "Failed to copy JSON: $json_file"
fi
}
copy_sidecars_json() {
local json_files=()
if [[ -n "$SINGLE_FILE" ]]; then
local single_abs rel_path candidate
single_abs="$(to_abs_path "$SINGLE_FILE")"
rel_path="$(rel_path_from_source "$single_abs")"
candidate="$SOURCE_DIR/${rel_path%.*}.json"
if [[ -f "$candidate" ]]; then
json_files+=("$candidate")
fi
else
while IFS= read -r -d '' jf; do
json_files+=("$jf")
done < <(
if [[ "$RECURSIVE" == true ]]; then
find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
else
find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
fi
)
fi
if [[ ${#json_files[@]} -eq 0 ]]; then
vlog_msg "INFO" "No JSON sidecars found to copy"
return
fi
local jf
for jf in "${json_files[@]}"; do
if is_apple_noise_file "$jf"; then
vlog_msg "SKIP" "Ignoring Apple artifact JSON: $jf"
continue
fi
copy_one_json "$jf"
done
}
write_manifest() {
local manifest_path="$DEST_DIR/telemetry_manifest.json"
if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
vlog_msg "SKIP" "Manifest exists: $manifest_path"
return
fi
if [[ "$DRY_RUN" == true ]]; then
log_msg "DRY-RUN" "Would write manifest: $manifest_path"
return
fi
mkdir -p "$DEST_DIR"
cat > "$manifest_path" <<EOF
{
"schema_version": "0.1-draft",
"purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
"fields_target": [
"power_w",
"speed_kmh",
"heart_rate_bpm",
"cadence_rpm",
"gps"
],
"sync_methods": [
"auto_timestamp_plus_offset",
"manual_offset_ms"
],
"notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
}
EOF
vlog_msg "INFO" "Wrote manifest: $manifest_path"
}
main() {
local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
total_started_at="$(date +%s)"
trap 'handle_interrupt' INT TERM
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
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
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
continue
else
process_rc=$?
fi
case "$process_rc" in
2)
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
if [[ "$STOP_AFTER_CURRENT" == true ]]; then
log_msg "INFO" "Skipping sidecar copy and manifest because run was stopped by user"
elif [[ "$ERRORS" -eq 0 ]]; then
copy_sidecars_json
write_manifest
else
log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
fi
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"
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")"
log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED invalid_sources_skipped=$INVALID_SOURCES_SKIPPED destination_failures=$DESTINATION_FAILURES json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
log_msg "INFO" "Timing: run_real=${total_elapsed_sec}s / $total_elapsed_fmt, files_real_total=${TOTAL_FILE_REAL_TIME_SEC}s / $(format_seconds "$TOTAL_FILE_REAL_TIME_SEC"), files_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), files_post_total=${file_post_total_sec}s / $file_post_total_fmt, run_non_file_overhead=${run_non_file_overhead_sec}s / $run_non_file_overhead_fmt"
log_msg "INFO" "Timing(avg): file_real_avg=${avg_file_real_time_sec}s / $avg_file_real_time_fmt, file_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
if [[ "$ERRORS" -gt 0 ]]; then
exit 1
fi
}
main "$@"