Showing 5 changed files with 1043 additions and 0 deletions
+4 -0
.gitignore
@@ -0,0 +1,4 @@
1
+SampleFootage/
2
+Output/
3
+*.log
4
+.DS_Store
+37 -0
CHANGELOG.md
@@ -0,0 +1,37 @@
1
+# Changelog
2
+
3
+All notable changes to `garmin_varia_transcode.sh` are documented here.
4
+Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+---
7
+
8
+## [1.0.0] — 2026-05-04
9
+
10
+Initial release.
11
+
12
+### Added
13
+- Batch HEVC transcoding with `hevc_videotoolbox`, `libx265`, or `libx264`
14
+- Encoding modes: `hardware`, `auto`, `quality`, `compat`
15
+- Default mode: `hardware` (hevc_videotoolbox on macOS)
16
+- Recursive directory traversal (default on)
17
+- Directory structure preservation at destination
18
+- Per-file audio detection via ffprobe; AAC 128k output when audio present
19
+- HEVC outputs tagged `hvc1` for Apple Photos / QuickTime compatibility
20
+- `-map_metadata 0` and `-movflags +faststart`
21
+- Source file timestamp preservation via `touch -r`
22
+- `--move-source`: delete source only after codec + duration validation
23
+- `--no-overwrite`: skip existing output files
24
+- `--no-recursive`: restrict to top-level directory
25
+- `--dry-run`: print actions without writing files
26
+- `--verbose`: full per-operation logs and ffmpeg/ffprobe output
27
+- `--crf`: override CRF value for software modes
28
+- `--extensions`: configurable video extension list
29
+- Auto single-file mode when `--source` is a file path
30
+- Quiet default output: one progress line per file with elapsed seconds
31
+- Summary line with counts and timing after each run
32
+- JSON sidecar copy (preserves relative paths)
33
+- `telemetry_manifest.json` placeholder written to destination
34
+- Destination-inside-source guard (hard error)
35
+- At-least-one-of `--source`/`--destination` requirement
36
+- Fail-fast: encoding chain stops on first ffmpeg error
37
+- Bash 3.2 compatible (macOS system bash)
+95 -0
DEVLOG.md
@@ -0,0 +1,95 @@
1
+# Development Log
2
+
3
+Decisions, trade-offs, and rationale recorded during development of `garmin_varia_transcode.sh`.
4
+
5
+---
6
+
7
+## 2026-05-04 — Initial design and implementation
8
+
9
+### Language: Python → Bash
10
+
11
+Started with Python 3 for portability (macOS/Linux/Windows). Switched to Bash after evaluating
12
+supply-chain risk: a Python script tends to acquire `pip` dependencies over time, each one a
13
+potential attack surface. Bash with only `ffmpeg`/`ffprobe` as external deps has a minimal,
14
+auditable dependency surface.
15
+
16
+Windows support was dropped as a result (Bash is not a first-class environment there).
17
+Trade-off accepted: target platforms are macOS and Linux.
18
+
19
+### Encoding strategy
20
+
21
+Garmin Varia source clips are H.264, ~1080p, ~29.97fps, ~22 Mbps, ~30s duration, ~80-85 MB each.
22
+The high bitrate is due to inefficient compression, not high visual complexity. Re-encoding with
23
+CRF-based HEVC yields ~60-70% size reduction with no perceptible quality loss for this content type.
24
+
25
+Bitrate is never derived mechanically from source file size. CRF (software) and constrained VBR
26
+(hardware) are used instead, so the encoder allocates bits based on actual scene complexity.
27
+
28
+### Default mode evolution
29
+
30
+1. `auto` — initial default; cross-platform safe
31
+2. `quality` (libx265) — changed to maximize archival quality
32
+3. `hardware` (hevc_videotoolbox) — **final default**, after measuring on Apple Silicon MacBook Pro:
33
+   - hardware: ~4-5s per clip, ~35W (~0.049 Wh/clip)
34
+   - software: ~50s per clip, ~80W (~1.18 Wh/clip)
35
+   - Energy difference: ~96% less energy per clip with hardware encoding
36
+   - Quality difference for dashcam footage: negligible
37
+
38
+`auto` is still available as a cross-platform fallback.
39
+
40
+### Audio handling
41
+
42
+ffprobe is called per-file to detect audio streams before building the ffmpeg command.
43
+Source Garmin Varia clips contain PCM audio. Output is AAC 128k when audio is present;
44
+no audio track is added if none is detected.
45
+
46
+### Apple compatibility tag
47
+
48
+HEVC outputs use `-tag:v hvc1` (not the default `hev1`). This is required for reliable
49
+import and playback in Apple Photos and QuickTime Player.
50
+
51
+### `--move-source` validation
52
+
53
+Deleting originals is guarded by a three-step post-encode check:
54
+1. Output file exists on disk
55
+2. Codec matches expected (hevc or h264)
56
+3. Duration within 1.0s of source
57
+
58
+Source is only deleted if all three pass.
59
+
60
+### Destination safety guard
61
+
62
+If destination is inside source, the script exits with an error. This prevents `find` from
63
+recursing into partially-written output files during a run.
64
+
65
+### Bash 3.2 compatibility
66
+
67
+macOS ships with Bash 3.2 (GPL v2). The script avoids:
68
+- `local -n` (nameref, requires Bash 4.3+)
69
+- `${var,,}` (lowercase expansion, requires Bash 4+)
70
+- `extglob` patterns
71
+
72
+### CLI defaults (final state)
73
+
74
+| Flag | Default | Rationale |
75
+|------|---------|-----------|
76
+| `--recursive` | on | Tool is designed for large libraries with subdirectories |
77
+| `--overwrite` | on | Filenames are timestamp-based (unique); no accidental overwrite risk |
78
+| `--mode` | hardware | 96% less energy vs software on Apple Silicon |
79
+| `--quiet` | on (default) | One progress line per file; verbose available via `--verbose` |
80
+
81
+### Output verbosity
82
+
83
+Default output is one line per file:
84
+```
85
+2026-05-04 12:08:00 : Transcoding SampleFootage/Day/clip.mp4 ... done in 5s
86
+```
87
+Paths are displayed relative to the working directory, not absolute, to keep lines short.
88
+`--verbose` restores full per-operation logs and exposes ffmpeg/ffprobe output.
89
+
90
+### Sidecar and manifest
91
+
92
+JSON sidecars (Garmin activity metadata) are copied 1:1 alongside transcoded files.
93
+`telemetry_manifest.json` is written as a placeholder contract for a future pipeline that
94
+will parse Garmin FIT files and correlate telemetry (power, speed, HR, GPS) with video timestamps.
95
+FIT parsing is not implemented in this release.
+89 -0
README.md
@@ -0,0 +1,89 @@
1
+# VariaReEncoder
2
+
3
+Batch transcoder for Garmin Varia dashcam footage. Converts H.264 source clips to HEVC MP4,
4
+optimized for Apple Photos / QuickTime compatibility, with sidecar JSON copy and a telemetry
5
+manifest placeholder for a future FIT sync pipeline.
6
+
7
+## Requirements
8
+
9
+- macOS or Linux
10
+- `ffmpeg` and `ffprobe` in `PATH` (any recent version with libx265/libx264; videotoolbox on macOS)
11
+- Bash 3.2+
12
+
13
+## Quick Start
14
+
15
+```bash
16
+# Transcode everything in SampleFootage → Output (hardware encoder on macOS)
17
+./garmin_varia_transcode.sh -s SampleFootage -d Output
18
+
19
+# Preview what would happen without writing any files
20
+./garmin_varia_transcode.sh -s SampleFootage -d Output --dry-run
21
+
22
+# Single file, verbose output
23
+./garmin_varia_transcode.sh -s SampleFootage/Day/clip.mp4 -d Output --verbose
24
+
25
+# Transcode + delete originals after validation
26
+./garmin_varia_transcode.sh -s SampleFootage -d Output --move-source
27
+```
28
+
29
+## Encoding Modes
30
+
31
+| Mode       | Encoder            | Speed (30s clip) | Power\*   | Use when |
32
+|------------|--------------------|-----------------|-----------|----------|
33
+| `hardware` | hevc_videotoolbox  | ~4-5s           | ~35W      | Default — battery, large libraries, dashcam footage |
34
+| `auto`     | videotoolbox → x265 → x264 | varies | varies   | Cross-platform or unknown machine |
35
+| `quality`  | libx265 (CRF 20)   | ~50s            | ~80W      | Archival, cinema, complex sources |
36
+| `compat`   | libx264 (CRF 19)   | ~30s            | ~70W      | Older TVs, players without HEVC support |
37
+
38
+\* Measured on Apple Silicon MacBook Pro.
39
+
40
+## CLI Reference
41
+
42
+```
43
+Options:
44
+  -s, --source, --input DIR       Source directory or single file (default: current directory)
45
+  -d, --destination, --output DIR Destination directory (default: current directory)
46
+  --mode MODE                     hardware|auto|quality|compat (default: hardware)
47
+  --crf N                         CRF for quality/compat modes (lower = better; default: 20/19)
48
+  --no-overwrite                  Skip files that already exist at destination
49
+  --dry-run                       Print actions without writing files
50
+  --no-recursive                  Process only the top-level source directory
51
+  --extensions LIST               Comma-separated extensions (default: mp4,mov,avi,m4v)
52
+  --verbose                       Full per-operation logs + ffmpeg/ffprobe output
53
+  --move-source                   Delete source after strict post-encode validation
54
+  -h, --help                      Show full help with encoding mode details
55
+```
56
+
57
+## Output Conventions
58
+
59
+- Output is always `.mp4`
60
+- HEVC outputs tagged `hvc1` (required for Apple Photos / QuickTime)
61
+- Directory structure under source is preserved at destination
62
+- File timestamps copied from source (`touch -r`)
63
+- JSON sidecars copied 1:1 to destination
64
+- `telemetry_manifest.json` written to destination as a placeholder for a future FIT sync pipeline
65
+
66
+## Output Format (quiet mode, default)
67
+
68
+```
69
+2026-05-04 12:08:00 : Transcoding SampleFootage/Day/2026-04-04_18-10-36.mp4 ... done in 5s
70
+2026-05-04 12:08:04 : Transcoding SampleFootage/Day/2026-04-04_18-10-05.mp4 ... done in 4s
71
+[2026-05-04 12:08:35] [INFO] Summary: videos_processed=10 videos_skipped=0 errors=0
72
+[2026-05-04 12:08:35] [INFO] Timing: total_elapsed=44s, video_encode_avg=4s
73
+```
74
+
75
+## Safety
76
+
77
+- Destination inside source is rejected with an error
78
+- `--move-source` only deletes source after codec + duration validation passes
79
+- `--dry-run` never writes files
80
+
81
+## Source Files
82
+
83
+```
84
+SampleFootage/          Original clips (gitignored)
85
+Output/                 Transcoded output (gitignored)
86
+garmin_varia_transcode.sh   Main script
87
+CHANGELOG.md            Version history
88
+DEVLOG.md               Development decisions and rationale
89
+```
+818 -0
garmin_varia_transcode.sh
@@ -0,0 +1,818 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+# Garmin Varia batch transcoder (macOS/Linux)
5
+#
6
+# Apple compatibility note:
7
+# For HEVC outputs we explicitly set -tag:v hvc1 (not hev1), which is required
8
+# for reliable QuickTime / Photos compatibility.
9
+#
10
+# Encoding strategy note:
11
+# Do not derive output bitrate mechanically from fixed Garmin source sizes.
12
+# Prefer CRF (software) or constrained VBR (hardware), as used below.
13
+
14
+SCRIPT_NAME="$(basename "$0")"
15
+
16
+DEFAULT_SOURCE="."
17
+DEFAULT_MODE="hardware"
18
+DEFAULT_EXTENSIONS="mp4,mov,avi,m4v"
19
+DEFAULT_CRF_HEVC=20
20
+DEFAULT_CRF_H264=19
21
+
22
+SOURCE_DIR="$DEFAULT_SOURCE"
23
+DEST_DIR="$DEFAULT_SOURCE"
24
+MODE="$DEFAULT_MODE"
25
+EXTENSIONS_CSV="$DEFAULT_EXTENSIONS"
26
+CRF_OVERRIDE=""
27
+OVERWRITE=true
28
+DRY_RUN=false
29
+RECURSIVE=true
30
+SINGLE_FILE=""
31
+VERBOSE=false
32
+QUIET_FFMPEG=false
33
+MOVE_SOURCE=false
34
+SOURCE_PROVIDED=false
35
+DEST_PROVIDED=false
36
+
37
+HAS_VIDEOTOOLBOX=false
38
+HAS_LIBX265=false
39
+HAS_LIBX264=false
40
+
41
+ENCODER_KIND=""
42
+VIDEO_CODEC=""
43
+VIDEO_CRF=""
44
+VIDEO_ARGS=()
45
+FIND_EXT_EXPR=()
46
+
47
+VIDEOS_PROCESSED=0
48
+VIDEOS_SKIPPED=0
49
+JSON_COPIED=0
50
+JSON_SKIPPED=0
51
+ERRORS=0
52
+TOTAL_VIDEO_TIME_SEC=0
53
+
54
+DURATION_TOLERANCE_SEC=1.0
55
+
56
+usage() {
57
+  cat <<'EOF'
58
+Usage:
59
+  garmin_varia_transcode.sh [options]
60
+
61
+Options:
62
+  -s, --source, --input DIR       Source directory or single file (default: current directory)
63
+  -d, --destination, --output DIR Destination directory (default: current directory)
64
+  --mode MODE                     Encoding mode (default: hardware); see Encoding Modes below
65
+  --crf N                         CRF value for quality/compat modes (lower = better; default: 20/19)
66
+  --no-overwrite                  Skip files that already exist at destination (default: overwrite)
67
+  --dry-run                       Print actions without writing files
68
+  --no-recursive                  Process only the top-level source directory (default: recursive)
69
+  --extensions LIST               Comma-separated video extensions (default: mp4,mov,avi,m4v)
70
+  --verbose                       Log each operation with timestamp; show ffmpeg/ffprobe output
71
+  --move-source                   Remove source file only after strict post-encode validation
72
+  -h, --help                      Show this help
73
+
74
+Encoding Modes:
75
+  hardware   Uses hevc_videotoolbox (Apple Silicon / Intel Mac GPU). macOS only.
76
+             ~4-5s per 30s clip, ~35W (measured on Apple Silicon MacBook Pro).
77
+             Best choice when running on battery or transcoding large libraries.
78
+             Output quality is good for dashcam/action footage.
79
+             Falls back to an error on Linux or if videotoolbox is absent.
80
+
81
+  auto       Like hardware on macOS with videotoolbox, otherwise falls back to quality,
82
+             then compat. Safe cross-platform default when the machine is unknown.
83
+
84
+  quality    Uses libx265 (software HEVC, CRF 20). Platform-independent.
85
+             ~50s per 30s clip, ~80W (measured on Apple Silicon MacBook Pro).
86
+             Best compression ratio and quality for archival, high-resolution, or visually
87
+             complex sources (e.g. cinema, screen recordings).
88
+             Overkill for dashcam footage; prefer hardware or auto for those.
89
+
90
+  compat     Uses libx264 (software H.264, CRF 19). Maximum player compatibility.
91
+             Use when the destination player cannot decode HEVC (older TVs, Android 4.x,
92
+             web browsers without HEVC support). Larger files than HEVC modes.
93
+
94
+Behavior:
95
+  - Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible)
96
+  - HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility
97
+  - Original directory structure is preserved under destination
98
+  - When --source points to a file, only that file is processed
99
+  - JSON sidecar files found in source are copied 1:1 to destination
100
+  - telemetry_manifest.json is created in destination as a placeholder contract
101
+
102
+Examples:
103
+  ./garmin_varia_transcode.sh -s SampleFootage -d /Volumes/Archive
104
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode auto
105
+  ./garmin_varia_transcode.sh -s clip.mp4 -d encoded --dry-run --verbose
106
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode quality --crf 18
107
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --mode compat
108
+  ./garmin_varia_transcode.sh -s SampleFootage -d encoded --move-source
109
+EOF
110
+}
111
+
112
+log_msg() {
113
+  local level="$1"
114
+  shift
115
+  local ts
116
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
117
+  echo "[$ts] [$level] $*"
118
+}
119
+
120
+# Verbose-only log: suppressed in default quiet mode
121
+vlog_msg() {
122
+  [[ "$VERBOSE" == true ]] && log_msg "$@"
123
+  return 0
124
+}
125
+
126
+# Quiet-mode per-file progress line
127
+log_progress() {
128
+  local input_file="$1"
129
+  local elapsed_sec="$2"
130
+  local ts
131
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
132
+  echo "$ts : Transcoding $input_file ... done in ${elapsed_sec}s"
133
+}
134
+
135
+die() {
136
+  log_msg "ERROR" "$*"
137
+  exit 1
138
+}
139
+
140
+format_seconds() {
141
+  local sec="$1"
142
+  local h m s
143
+  h=$((sec / 3600))
144
+  m=$(((sec % 3600) / 60))
145
+  s=$((sec % 60))
146
+  printf '%02d:%02d:%02d' "$h" "$m" "$s"
147
+}
148
+
149
+make_temp_log_file() {
150
+  local temp_path
151
+  local base_tmp="${TMPDIR:-/tmp}"
152
+  base_tmp="${base_tmp%/}"
153
+
154
+  temp_path="$(mktemp "$base_tmp/varia_ffmpeg.XXXXXX.log" 2>/dev/null || true)"
155
+  if [[ -z "$temp_path" ]]; then
156
+    temp_path="$(mktemp -t varia_ffmpeg 2>/dev/null || true)"
157
+  fi
158
+
159
+  printf '%s\n' "$temp_path"
160
+}
161
+
162
+require_value() {
163
+  local flag="$1"
164
+  local value="${2:-}"
165
+  if [[ -z "$value" ]]; then
166
+    die "Missing value for $flag"
167
+  fi
168
+}
169
+
170
+to_abs_path() {
171
+  local p="$1"
172
+  if [[ "$p" = /* ]]; then
173
+    printf '%s\n' "$p"
174
+  else
175
+    printf '%s\n' "$PWD/$p"
176
+  fi
177
+}
178
+
179
+path_is_within() {
180
+  local child="$1"
181
+  local parent="$2"
182
+  [[ "$child" == "$parent" || "$child" == "$parent"/* ]]
183
+}
184
+
185
+join_cmd_for_log() {
186
+  local out=""
187
+  local arg
188
+  for arg in "$@"; do
189
+    out+="$(printf '%q' "$arg") "
190
+  done
191
+  printf '%s\n' "${out% }"
192
+}
193
+
194
+parse_args() {
195
+  while [[ $# -gt 0 ]]; do
196
+    case "$1" in
197
+      -s|--source|--input)
198
+        require_value "$1" "${2:-}"
199
+        SOURCE_DIR="$2"
200
+        SOURCE_PROVIDED=true
201
+        shift 2
202
+        ;;
203
+      -d|--destination|--output)
204
+        require_value "$1" "${2:-}"
205
+        DEST_DIR="$2"
206
+        DEST_PROVIDED=true
207
+        shift 2
208
+        ;;
209
+      --mode)
210
+        require_value "$1" "${2:-}"
211
+        MODE="$2"
212
+        shift 2
213
+        ;;
214
+      --crf)
215
+        require_value "$1" "${2:-}"
216
+        CRF_OVERRIDE="$2"
217
+        shift 2
218
+        ;;
219
+      --overwrite)
220
+        OVERWRITE=true
221
+        shift
222
+        ;;
223
+      --no-overwrite)
224
+        OVERWRITE=false
225
+        shift
226
+        ;;
227
+      --dry-run)
228
+        DRY_RUN=true
229
+        shift
230
+        ;;
231
+      --recursive)
232
+        RECURSIVE=true
233
+        shift
234
+        ;;
235
+      --no-recursive)
236
+        RECURSIVE=false
237
+        shift
238
+        ;;
239
+      --extensions)
240
+        require_value "$1" "${2:-}"
241
+        EXTENSIONS_CSV="$2"
242
+        shift 2
243
+        ;;
244
+      --single)
245
+        require_value "$1" "${2:-}"
246
+        SINGLE_FILE="$2"
247
+        shift 2
248
+        ;;
249
+      --verbose)
250
+        VERBOSE=true
251
+        shift
252
+        ;;
253
+      --quiet-ffmpeg)
254
+        QUIET_FFMPEG=true
255
+        shift
256
+        ;;
257
+      --move-source)
258
+        MOVE_SOURCE=true
259
+        shift
260
+        ;;
261
+      -h|--help)
262
+        usage
263
+        exit 0
264
+        ;;
265
+      *)
266
+        die "Unknown argument: $1"
267
+        ;;
268
+    esac
269
+  done
270
+
271
+  case "$MODE" in
272
+    auto|hardware|quality|compat) ;;
273
+    *) die "Invalid --mode '$MODE'. Use auto|hardware|quality|compat." ;;
274
+  esac
275
+
276
+  if [[ "$SOURCE_PROVIDED" != true && "$DEST_PROVIDED" != true ]]; then
277
+    die "At least one of --source or --destination must be provided"
278
+  fi
279
+
280
+  if [[ -n "$CRF_OVERRIDE" && ! "$CRF_OVERRIDE" =~ ^[0-9]+$ ]]; then
281
+    die "--crf must be an integer"
282
+  fi
283
+}
284
+
285
+check_tools() {
286
+  command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
287
+  command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH"
288
+}
289
+
290
+detect_encoders() {
291
+  local encoders
292
+  encoders="$(ffmpeg -hide_banner -encoders 2>&1 || true)"
293
+
294
+  if echo "$encoders" | grep -Eq '(^|[[:space:]])hevc_videotoolbox([[:space:]]|$)'; then
295
+    HAS_VIDEOTOOLBOX=true
296
+  fi
297
+  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx265([[:space:]]|$)'; then
298
+    HAS_LIBX265=true
299
+  fi
300
+  if echo "$encoders" | grep -Eq '(^|[[:space:]])libx264([[:space:]]|$)'; then
301
+    HAS_LIBX264=true
302
+  fi
303
+
304
+  if [[ "$VERBOSE" == true ]]; then
305
+    log_msg "INFO" "Encoders: hevc_videotoolbox=$HAS_VIDEOTOOLBOX libx265=$HAS_LIBX265 libx264=$HAS_LIBX264"
306
+  fi
307
+}
308
+
309
+resolve_encoder() {
310
+  local os_name
311
+  os_name="$(uname -s)"
312
+
313
+  case "$MODE" in
314
+    auto)
315
+      if [[ "$os_name" == "Darwin" && "$HAS_VIDEOTOOLBOX" == true ]]; then
316
+        ENCODER_KIND="hardware"
317
+      elif [[ "$HAS_LIBX265" == true ]]; then
318
+        ENCODER_KIND="quality"
319
+      elif [[ "$HAS_LIBX264" == true ]]; then
320
+        ENCODER_KIND="compat"
321
+      else
322
+        die "No suitable encoder found. Need one of hevc_videotoolbox, libx265, libx264."
323
+      fi
324
+      ;;
325
+    hardware)
326
+      [[ "$os_name" == "Darwin" ]] || die "--mode hardware is only supported on macOS (hevc_videotoolbox)"
327
+      [[ "$HAS_VIDEOTOOLBOX" == true ]] || die "hevc_videotoolbox not available in ffmpeg"
328
+      ENCODER_KIND="hardware"
329
+      ;;
330
+    quality)
331
+      [[ "$HAS_LIBX265" == true ]] || die "libx265 not available in ffmpeg"
332
+      ENCODER_KIND="quality"
333
+      ;;
334
+    compat)
335
+      [[ "$HAS_LIBX264" == true ]] || die "libx264 not available in ffmpeg"
336
+      ENCODER_KIND="compat"
337
+      ;;
338
+  esac
339
+
340
+  case "$ENCODER_KIND" in
341
+    hardware)
342
+      VIDEO_CODEC="hevc_videotoolbox"
343
+      VIDEO_CRF=""
344
+      ;;
345
+    quality)
346
+      VIDEO_CODEC="libx265"
347
+      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_HEVC}"
348
+      ;;
349
+    compat)
350
+      VIDEO_CODEC="libx264"
351
+      VIDEO_CRF="${CRF_OVERRIDE:-$DEFAULT_CRF_H264}"
352
+      ;;
353
+  esac
354
+
355
+  vlog_msg "INFO" "Selected mode: $MODE (effective: $ENCODER_KIND, codec: $VIDEO_CODEC${VIDEO_CRF:+, crf: $VIDEO_CRF})"
356
+}
357
+
358
+probe_has_audio() {
359
+  local input_file="$1"
360
+  local out
361
+  out="$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$input_file" || true)"
362
+  [[ -n "$out" ]]
363
+}
364
+
365
+print_verbose_probe() {
366
+  local input_file="$1"
367
+  ffprobe -v error \
368
+    -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 \
369
+    -of default=noprint_wrappers=1:nokey=0 "$input_file" || true
370
+}
371
+
372
+ffprobe_duration_or_empty() {
373
+  local file_path="$1"
374
+  ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_path" 2>/dev/null | head -n1
375
+}
376
+
377
+ffprobe_video_codec_or_empty() {
378
+  local file_path="$1"
379
+  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
380
+}
381
+
382
+validate_transcoded_output() {
383
+  local input_file="$1"
384
+  local output_file="$2"
385
+
386
+  if [[ ! -f "$output_file" ]]; then
387
+    log_msg "ERROR" "Validation failed: destination file missing: $output_file"
388
+    return 1
389
+  fi
390
+
391
+  local expected_codec actual_codec
392
+  case "$ENCODER_KIND" in
393
+    hardware|quality) expected_codec="hevc" ;;
394
+    compat) expected_codec="h264" ;;
395
+    *)
396
+      log_msg "ERROR" "Validation failed: unknown encoder kind '$ENCODER_KIND'"
397
+      return 1
398
+      ;;
399
+  esac
400
+
401
+  actual_codec="$(ffprobe_video_codec_or_empty "$output_file")"
402
+  if [[ -z "$actual_codec" ]]; then
403
+    log_msg "ERROR" "Validation failed: ffprobe could not read output codec: $output_file"
404
+    return 1
405
+  fi
406
+  if [[ "$actual_codec" != "$expected_codec" ]]; then
407
+    log_msg "ERROR" "Validation failed: codec mismatch for $output_file (expected=$expected_codec actual=$actual_codec)"
408
+    return 1
409
+  fi
410
+
411
+  local in_duration out_duration duration_delta
412
+  in_duration="$(ffprobe_duration_or_empty "$input_file")"
413
+  out_duration="$(ffprobe_duration_or_empty "$output_file")"
414
+
415
+  if [[ -z "$in_duration" || -z "$out_duration" ]]; then
416
+    log_msg "ERROR" "Validation failed: missing duration probe data (input='$in_duration' output='$out_duration')"
417
+    return 1
418
+  fi
419
+
420
+  duration_delta="$(awk -v a="$in_duration" -v b="$out_duration" 'BEGIN{d=a-b; if (d<0) d=-d; printf "%.3f", d}')"
421
+  if ! awk -v d="$duration_delta" -v t="$DURATION_TOLERANCE_SEC" 'BEGIN{exit !(d<=t)}'; then
422
+    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)"
423
+    return 1
424
+  fi
425
+
426
+  vlog_msg "INFO" "Validation OK: $output_file (codec=$actual_codec input_dur=${in_duration}s output_dur=${out_duration}s delta=${duration_delta}s)"
427
+  return 0
428
+}
429
+
430
+build_video_args() {
431
+  VIDEO_ARGS=()
432
+
433
+  case "$ENCODER_KIND" in
434
+    hardware)
435
+      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -b:v 8M -maxrate 16M -bufsize 24M -tag:v hvc1 )
436
+      ;;
437
+    quality)
438
+      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -tag:v hvc1 )
439
+      ;;
440
+    compat)
441
+      VIDEO_ARGS+=( -c:v "$VIDEO_CODEC" -crf "$VIDEO_CRF" -preset slow -pix_fmt yuv420p )
442
+      ;;
443
+  esac
444
+}
445
+
446
+normalize_source_dir() {
447
+  SOURCE_DIR="$(to_abs_path "$SOURCE_DIR")"
448
+  [[ -d "$SOURCE_DIR" ]] || die "Source directory not found: $SOURCE_DIR"
449
+  SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
450
+}
451
+
452
+normalize_dest_dir() {
453
+  DEST_DIR="$(to_abs_path "$DEST_DIR")"
454
+}
455
+
456
+collect_extensions() {
457
+  local raw="$EXTENSIONS_CSV"
458
+  local token
459
+  EXT_LIST=()
460
+
461
+  IFS=',' read -r -a tokens <<< "$raw"
462
+  for token in "${tokens[@]}"; do
463
+    token="$(printf '%s' "$token" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
464
+    token="${token#.}"
465
+    token="$(printf '%s' "$token" | tr '[:upper:]' '[:lower:]')"
466
+    [[ -n "$token" ]] && EXT_LIST+=("$token")
467
+  done
468
+
469
+  if [[ ${#EXT_LIST[@]} -eq 0 ]]; then
470
+    die "No valid extensions after parsing --extensions"
471
+  fi
472
+}
473
+
474
+build_find_expr_for_extensions() {
475
+  FIND_EXT_EXPR=()
476
+  local ext
477
+  for ext in "${EXT_LIST[@]}"; do
478
+    FIND_EXT_EXPR+=( -iname "*.${ext}" -o )
479
+  done
480
+  if [[ ${#FIND_EXT_EXPR[@]} -gt 0 ]]; then
481
+    unset 'FIND_EXT_EXPR[${#FIND_EXT_EXPR[@]}-1]'
482
+  fi
483
+}
484
+
485
+rel_path_from_source() {
486
+  local abs_file="$1"
487
+  if path_is_within "$abs_file" "$SOURCE_DIR"; then
488
+    printf '%s\n' "${abs_file#$SOURCE_DIR/}"
489
+  else
490
+    printf '%s\n' "$(basename "$abs_file")"
491
+  fi
492
+}
493
+
494
+collect_video_files() {
495
+  VIDEO_FILES=()
496
+
497
+  if [[ -n "$SINGLE_FILE" ]]; then
498
+    local single_abs
499
+    single_abs="$(to_abs_path "$SINGLE_FILE")"
500
+    [[ -f "$single_abs" ]] || die "--single file not found: $single_abs"
501
+    VIDEO_FILES+=("$single_abs")
502
+    return
503
+  fi
504
+
505
+  build_find_expr_for_extensions
506
+
507
+  while IFS= read -r -d '' file; do
508
+    VIDEO_FILES+=("$file")
509
+  done < <(
510
+    if [[ "$RECURSIVE" == true ]]; then
511
+      find "$SOURCE_DIR" \
512
+        -path "$DEST_DIR" -prune -o \
513
+        -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
514
+    else
515
+      find "$SOURCE_DIR" \
516
+        -maxdepth 1 -type f \( "${FIND_EXT_EXPR[@]}" \) -print0
517
+    fi
518
+  )
519
+}
520
+
521
+process_video_file() {
522
+  local input_file="$1"
523
+  local rel_path output_file out_dir
524
+
525
+  rel_path="$(rel_path_from_source "$input_file")"
526
+  output_file="$DEST_DIR/${rel_path%.*}.mp4"
527
+  out_dir="$(dirname "$output_file")"
528
+
529
+  mkdir -p "$out_dir"
530
+
531
+  if [[ -f "$output_file" && "$OVERWRITE" != true ]]; then
532
+    vlog_msg "SKIP" "Video exists: $output_file"
533
+    VIDEOS_SKIPPED=$((VIDEOS_SKIPPED + 1))
534
+    return
535
+  fi
536
+
537
+  local has_audio=false
538
+  if probe_has_audio "$input_file"; then
539
+    has_audio=true
540
+  fi
541
+
542
+  if [[ "$VERBOSE" == true ]]; then
543
+    log_msg "INFO" "ffprobe summary: $input_file"
544
+    print_verbose_probe "$input_file"
545
+    log_msg "INFO" "Audio detected: $has_audio"
546
+  fi
547
+
548
+  build_video_args
549
+
550
+  local cmd=(ffmpeg -hide_banner)
551
+  if [[ "$OVERWRITE" == true ]]; then
552
+    cmd+=( -y )
553
+  else
554
+    cmd+=( -n )
555
+  fi
556
+
557
+  cmd+=( -i "$input_file" -map 0:v:0 )
558
+
559
+  if [[ "$has_audio" == true ]]; then
560
+    cmd+=( -map 0:a? -c:a aac -b:a 128k )
561
+  fi
562
+
563
+  cmd+=( "${VIDEO_ARGS[@]}" -map_metadata 0 -movflags +faststart "$output_file" )
564
+
565
+  if [[ "$VERBOSE" == true ]]; then
566
+    log_msg "INFO" "Command: $(join_cmd_for_log "${cmd[@]}")"
567
+  fi
568
+
569
+  if [[ "$DRY_RUN" == true ]]; then
570
+    log_msg "DRY-RUN" "Would transcode: $input_file -> $output_file"
571
+    return 0
572
+  fi
573
+
574
+  local started_at ended_at elapsed_sec elapsed_fmt
575
+  started_at="$(date +%s)"
576
+
577
+  vlog_msg "INFO" "Encoding: $input_file -> $output_file"
578
+
579
+  local ffmpeg_rc=0
580
+  if [[ "$VERBOSE" == true ]]; then
581
+    # Verbose: show ffmpeg output directly
582
+    if "${cmd[@]}"; then
583
+      :
584
+    else
585
+      ffmpeg_rc=$?
586
+    fi
587
+  else
588
+    # Quiet (default): redirect ffmpeg output; keep log on failure
589
+    local ffmpeg_log
590
+    ffmpeg_log="$(make_temp_log_file)"
591
+    if [[ -z "$ffmpeg_log" ]]; then
592
+      ERRORS=$((ERRORS + 1))
593
+      log_msg "ERROR" "Could not create temporary ffmpeg log file"
594
+      return 1
595
+    fi
596
+    if "${cmd[@]}" >"$ffmpeg_log" 2>&1; then
597
+      rm -f "$ffmpeg_log"
598
+    else
599
+      ffmpeg_rc=$?
600
+    fi
601
+  fi
602
+
603
+  ended_at="$(date +%s)"
604
+  elapsed_sec=$((ended_at - started_at))
605
+  elapsed_fmt="$(format_seconds "$elapsed_sec")"
606
+
607
+  if [[ "$ffmpeg_rc" -ne 0 ]]; then
608
+    ERRORS=$((ERRORS + 1))
609
+    if [[ "$VERBOSE" == true ]]; then
610
+      log_msg "ERROR" "ffmpeg failed: $input_file (elapsed=${elapsed_sec}s / $elapsed_fmt, rc=$ffmpeg_rc)"
611
+    else
612
+      local ts
613
+      ts="$(date '+%Y-%m-%d %H:%M:%S')"
614
+      echo "$ts : Transcoding $input_file ... FAILED (rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
615
+      rm -f "${ffmpeg_log:-}"
616
+    fi
617
+    return 1
618
+  fi
619
+
620
+  TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + elapsed_sec))
621
+
622
+  if [[ "$MOVE_SOURCE" == true ]]; then
623
+    if ! validate_transcoded_output "$input_file" "$output_file"; then
624
+      ERRORS=$((ERRORS + 1))
625
+      return 1
626
+    fi
627
+  fi
628
+
629
+  touch -r "$input_file" "$output_file" || true
630
+
631
+  if [[ "$MOVE_SOURCE" == true ]]; then
632
+    if rm -f "$input_file"; then
633
+      vlog_msg "INFO" "Removed source after successful validation: $input_file"
634
+    else
635
+      ERRORS=$((ERRORS + 1))
636
+      log_msg "ERROR" "Failed to remove source after validation: $input_file"
637
+      return 1
638
+    fi
639
+  fi
640
+
641
+  VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1))
642
+
643
+  if [[ "$VERBOSE" == true ]]; then
644
+    log_msg "INFO" "Transcoded: $output_file (elapsed=${elapsed_sec}s / $elapsed_fmt)"
645
+  else
646
+    local display_path="${input_file#$PWD/}"
647
+    log_progress "$display_path" "$elapsed_sec"
648
+  fi
649
+
650
+  return 0
651
+}
652
+
653
+copy_one_json() {
654
+  local json_file="$1"
655
+  local rel_json dst_json dst_dir
656
+
657
+  rel_json="$(rel_path_from_source "$json_file")"
658
+  dst_json="$DEST_DIR/$rel_json"
659
+  dst_dir="$(dirname "$dst_json")"
660
+
661
+  mkdir -p "$dst_dir"
662
+
663
+  if [[ -f "$dst_json" && "$OVERWRITE" != true ]]; then
664
+    JSON_SKIPPED=$((JSON_SKIPPED + 1))
665
+    vlog_msg "SKIP" "JSON exists: $dst_json"
666
+    return
667
+  fi
668
+
669
+  if [[ "$DRY_RUN" == true ]]; then
670
+    JSON_COPIED=$((JSON_COPIED + 1))
671
+    log_msg "DRY-RUN" "Would copy JSON: $json_file -> $dst_json"
672
+    return
673
+  fi
674
+
675
+  if cp -f "$json_file" "$dst_json"; then
676
+    touch -r "$json_file" "$dst_json" || true
677
+    JSON_COPIED=$((JSON_COPIED + 1))
678
+    vlog_msg "INFO" "Copied JSON: $dst_json"
679
+  else
680
+    ERRORS=$((ERRORS + 1))
681
+    log_msg "ERROR" "Failed to copy JSON: $json_file"
682
+  fi
683
+}
684
+
685
+copy_sidecars_json() {
686
+  local json_files=()
687
+
688
+  if [[ -n "$SINGLE_FILE" ]]; then
689
+    local single_abs rel_path candidate
690
+    single_abs="$(to_abs_path "$SINGLE_FILE")"
691
+    rel_path="$(rel_path_from_source "$single_abs")"
692
+    candidate="$SOURCE_DIR/${rel_path%.*}.json"
693
+    if [[ -f "$candidate" ]]; then
694
+      json_files+=("$candidate")
695
+    fi
696
+  else
697
+    while IFS= read -r -d '' jf; do
698
+      json_files+=("$jf")
699
+    done < <(
700
+      if [[ "$RECURSIVE" == true ]]; then
701
+        find "$SOURCE_DIR" -path "$DEST_DIR" -prune -o -type f -iname '*.json' -print0
702
+      else
703
+        find "$SOURCE_DIR" -maxdepth 1 -type f -iname '*.json' -print0
704
+      fi
705
+    )
706
+  fi
707
+
708
+  if [[ ${#json_files[@]} -eq 0 ]]; then
709
+    vlog_msg "INFO" "No JSON sidecars found to copy"
710
+    return
711
+  fi
712
+
713
+  local jf
714
+  for jf in "${json_files[@]}"; do
715
+    copy_one_json "$jf"
716
+  done
717
+}
718
+
719
+write_manifest() {
720
+  local manifest_path="$DEST_DIR/telemetry_manifest.json"
721
+
722
+  if [[ -f "$manifest_path" && "$OVERWRITE" != true ]]; then
723
+    vlog_msg "SKIP" "Manifest exists: $manifest_path"
724
+    return
725
+  fi
726
+
727
+  if [[ "$DRY_RUN" == true ]]; then
728
+    log_msg "DRY-RUN" "Would write manifest: $manifest_path"
729
+    return
730
+  fi
731
+
732
+  mkdir -p "$DEST_DIR"
733
+
734
+  cat > "$manifest_path" <<EOF
735
+{
736
+  "schema_version": "0.1-draft",
737
+  "purpose": "placeholder contract for future FIT-to-sidecar sync pipeline",
738
+  "fields_target": [
739
+    "power_w",
740
+    "speed_kmh",
741
+    "heart_rate_bpm",
742
+    "cadence_rpm",
743
+    "gps"
744
+  ],
745
+  "sync_methods": [
746
+    "auto_timestamp_plus_offset",
747
+    "manual_offset_ms"
748
+  ],
749
+  "notes": "Current release copies existing JSON sidecars only; FIT parsing is not implemented yet."
750
+}
751
+EOF
752
+
753
+  vlog_msg "INFO" "Wrote manifest: $manifest_path"
754
+}
755
+
756
+main() {
757
+  local total_started_at total_ended_at total_elapsed_sec total_elapsed_fmt
758
+  total_started_at="$(date +%s)"
759
+
760
+  parse_args "$@"
761
+  check_tools
762
+
763
+  # Auto single-file detection: if --source points to a file, treat it as single-file mode
764
+  if [[ -z "$SINGLE_FILE" && "$SOURCE_PROVIDED" == true && -f "$SOURCE_DIR" ]]; then
765
+    SINGLE_FILE="$SOURCE_DIR"
766
+    SOURCE_DIR="$(dirname "$SINGLE_FILE")"
767
+  fi
768
+
769
+  normalize_source_dir
770
+  normalize_dest_dir
771
+  collect_extensions
772
+
773
+  if path_is_within "$DEST_DIR" "$SOURCE_DIR"; then
774
+    die "Destination must not be inside source. Choose a destination outside source: source=$SOURCE_DIR destination=$DEST_DIR"
775
+  fi
776
+
777
+  detect_encoders
778
+  resolve_encoder
779
+
780
+  collect_video_files
781
+  if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then
782
+    log_msg "INFO" "No video files found for extensions: $EXTENSIONS_CSV"
783
+  fi
784
+
785
+  local f
786
+  for f in "${VIDEO_FILES[@]}"; do
787
+    if ! process_video_file "$f"; then
788
+      log_msg "ERROR" "Stopping encoding chain due to ffmpeg failure/interruption"
789
+      break
790
+    fi
791
+  done
792
+
793
+  if [[ "$ERRORS" -eq 0 ]]; then
794
+    copy_sidecars_json
795
+    write_manifest
796
+  else
797
+    log_msg "INFO" "Skipping sidecar copy and manifest because encoding ended with errors"
798
+  fi
799
+
800
+  total_ended_at="$(date +%s)"
801
+  total_elapsed_sec=$((total_ended_at - total_started_at))
802
+  total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")"
803
+
804
+  local avg_video_time_sec=0 avg_video_time_fmt="00:00:00"
805
+  if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then
806
+    avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED))
807
+    avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")"
808
+  fi
809
+
810
+  log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS"
811
+  log_msg "INFO" "Timing: total_elapsed=${total_elapsed_sec}s / $total_elapsed_fmt, video_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), video_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
812
+
813
+  if [[ "$ERRORS" -gt 0 ]]; then
814
+    exit 1
815
+  fi
816
+}
817
+
818
+main "$@"