- Batch HEVC transcoder for Garmin Varia footage - Modes: hardware (default), auto, quality, compat - Recursive, quiet by default, move-source with validation - JSON sidecar copy, telemetry manifest placeholder - Bash 3.2 compatible
@@ -0,0 +1,4 @@ |
||
| 1 |
+SampleFootage/ |
|
| 2 |
+Output/ |
|
| 3 |
+*.log |
|
| 4 |
+.DS_Store |
|
@@ -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) |
|
@@ -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. |
|
@@ -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 |
+``` |
|
@@ -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 "$@" |
|