#!/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. SCRIPT_NAME="$(basename "$0")" DEFAULT_SOURCE="." DEFAULT_MODE="hardware" DEFAULT_EXTENSIONS="mp4,mov,avi,m4v" DEFAULT_CRF_HEVC=20 DEFAULT_CRF_H264=19 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 QUIET_FFMPEG=false MOVE_SOURCE=false SOURCE_PROVIDED=false DEST_PROVIDED=false HAS_VIDEOTOOLBOX=false HAS_LIBX265=false HAS_LIBX264=false ENCODER_KIND="" VIDEO_CODEC="" VIDEO_CRF="" VIDEO_ARGS=() FIND_EXT_EXPR=() VIDEOS_PROCESSED=0 VIDEOS_SKIPPED=0 JSON_COPIED=0 JSON_SKIPPED=0 ERRORS=0 TOTAL_VIDEO_TIME_SEC=0 DURATION_TOLERANCE_SEC=1.0 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 --move-source Remove source file only after strict post-encode validation -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 - 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 --move-source 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 line log_progress() { local input_file="$1" local elapsed_sec="$2" local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "$ts : Transcoding $input_file ... done in ${elapsed_sec}s" } die() { log_msg "ERROR" "$*" exit 1 } 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" } 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 ;; --quiet-ffmpeg) QUIET_FFMPEG=true shift ;; --move-source) MOVE_SOURCE=true 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" } 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 } 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 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 out_dir rel_path="$(rel_path_from_source "$input_file")" output_file="$DEST_DIR/${rel_path%.*}.mp4" out_dir="$(dirname "$output_file")" mkdir -p "$out_dir" if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then vlog_msg "SKIP" "Video exists: $output_file" VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1)) return fi local has_audio=false if probe_has_audio "$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 local cmd=(ffmpeg -hide_banner) if [[ "$OVERWRITE" == true ]]; then cmd+=( -y ) else cmd+=( -n ) fi cmd+=( -i "$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 "$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 local started_at ended_at elapsed_sec elapsed_fmt started_at="$(date +%s)" vlog_msg "INFO" "Encoding: $input_file -> $output_file" local ffmpeg_rc=0 if [[ "$VERBOSE" == true ]]; then # Verbose: show ffmpeg output directly if "${cmd[@]}"; then : else ffmpeg_rc=$? fi else # Quiet (default): redirect ffmpeg output; keep log on failure local ffmpeg_log ffmpeg_log="$(make_temp_log_file)" if [[ -z "$ffmpeg_log" ]]; then ERRORS=$((ERRORS + 1)) log_msg "ERROR" "Could not create temporary ffmpeg log file" return 1 fi if "${cmd[@]}" >"$ffmpeg_log" 2>&1; then rm -f "$ffmpeg_log" else ffmpeg_rc=$? fi fi ended_at="$(date +%s)" elapsed_sec=$((ended_at - started_at)) elapsed_fmt="$(format_seconds "$elapsed_sec")" if [[ "$ffmpeg_rc" -ne 0 ]]; then ERRORS=$((ERRORS + 1)) if [[ "$VERBOSE" == true ]]; then log_msg "ERROR" "ffmpeg failed: $input_file (elapsed=${elapsed_sec}s / $elapsed_fmt, rc=$ffmpeg_rc)" else local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "$ts : Transcoding $input_file ... FAILED (rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})" rm -f "${ffmpeg_log:-}" fi return 1 fi TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + elapsed_sec)) if [[ "$MOVE_SOURCE" == true ]]; then if ! validate_transcoded_output "$input_file" "$output_file"; then ERRORS=$((ERRORS + 1)) return 1 fi fi touch -r "$input_file" "$output_file" || true 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" return 1 fi fi VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1)) if [[ "$VERBOSE" == true ]]; then log_msg "INFO" "Transcoded: $output_file (elapsed=${elapsed_sec}s / $elapsed_fmt)" else local display_path="${input_file#$PWD/}" log_progress "$display_path" "$elapsed_sec" fi 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 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" <