VariaReEncoder / garmin_varia_transcode.sh
1 contributor
987 lines | 29.764kb
#!/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")"
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
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
TOTAL_FILE_REAL_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
  - 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 --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 real_elapsed_sec="$2"
  local encode_elapsed_sec="$3"
  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)"
}

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"
  command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH"
}

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

  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" \
      -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" \
      -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
}

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
  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")"

  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

  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
  encode_started_at="$(date +%s)"
  vlog_msg "CHECKPOINT" "encode_start: $input_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

  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
    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 [[ "$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
      local ts
      ts="$(date '+%Y-%m-%d %H:%M:%S')"
      echo "$ts : Transcoding $input_file ... FAILED after ${file_real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
      rm -f "${ffmpeg_log:-}"
    fi
    return 1
  fi

  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec))

  if ! restore_metadata_with_exiftool "$input_file" "$output_file"; then
    ERRORS=$((ERRORS + 1))
    return 1
  fi

  if ! map_garmin_model_to_standard_tags "$input_file" "$output_file"; then
    ERRORS=$((ERRORS + 1))
    return 1
  fi

  if ! write_transcode_encoder_metadata "$output_file"; then
    ERRORS=$((ERRORS + 1))
    return 1
  fi

  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

  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
    local display_path="${input_file#$PWD/}"
    log_progress "$display_path" "$file_real_elapsed_sec" "$encode_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" <<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)"

  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 [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
  fi

  local f
  for f in "${VIDEO_FILES[@]}"; do
    if ! process_video_file "$f"; then
      log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
      break
    fi
  done

  if [[ "$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 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 "$@"