ffmpeg-skill 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,15 +17,17 @@ npx ffmpeg-skill
17
17
  - **Probe first, verify last** — the skill forces the agent to read real duration/fps/resolution before editing and to check the result after, so you get "final.mp4: 59.98 s, 1080×1920, 30 fps" instead of guesses.
18
18
  - **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
19
19
  - **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
20
+ - **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
21
+ - **Real-footage verification kit** — run the whole toolchain on your own device files and get a PASS/FAIL report.
20
22
  - **Silence removal / jump cuts** — detect dead air, keep a margin around speech, render frame-accurate in one pass; export the cut list for hand editing.
21
23
  - **Join with transitions** — crossfade, wipes, fade-to-black between mismatched clips (any size, fps, audio layout).
22
24
  - **Agent eyes** — contact sheets, single frames and before/after comparisons as PNG so the agent verifies caption placement, crops and colour visually.
23
- - **Plan before render** — every script has `--dry-run` (print the ffmpeg commands) and `--json` (structured result with a probe of the output).
24
- - **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight styles for short-form video.
25
+ - **Plan before render** — every script has `--dry-run` (print the ffmpeg commands), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent / ETA).
26
+ - **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight timed to the speech energy in the audio.
25
27
  - **Fit** to an exact duration (pitch-preserving speed change or trim) and to 16:9 / 9:16 / 1:1 / 4:5 by padding or cropping; motion-interpolated or blended slow motion.
26
28
  - **Real-world footage handling** — variable-frame-rate phone clips are conformed to constant fps automatically, rotation metadata is honoured, 10-bit HEVC and 5.1 sources are handled.
27
29
  - **Multicam / external-audio sync** — offset detection by cross-correlation implemented in pure Python (no numpy), 1 ms resolution, plus clock-drift correction for long takes.
28
- - **Colour management** — real HDR10/HLG → SDR BT.709 tone mapping, 3D LUT (.cube) for Log footage and looks, and metadata-only retagging.
30
+ - **Colour management** — HDR10 / HLG / Dolby Vision (iPhone) → SDR BT.709 tone mapping, Dolby Vision layer stripping, 3D LUT (.cube) for Log footage and looks, Log-footage detection, metadata-only retagging.
29
31
  - **Audio post** — voice clean-up chain (highpass, de-esser, FFT denoise, compressor), background music with sidechain ducking, fades, 5.1 → stereo downmix, track replacement.
30
32
  - **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
31
33
  - **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
@@ -88,15 +90,17 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
88
90
 
89
91
  | Script | What it does |
90
92
  |--------|--------------|
91
- | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format, colour space, rotation, audio channels as JSON |
93
+ | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision, colour space, rotation, audio channels as JSON; `--analyze` flags Log footage |
92
94
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
95
+ | `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
96
+ | `verify.py` | Run the toolchain on real device files and report PASS/FAIL per step |
93
97
  | `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
94
98
  | `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio |
95
99
  | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG for visual checks |
96
100
  | `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
97
101
  | `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
98
102
  | `sync.py` | Detect offset between two recordings by audio cross-correlation (1 ms), correct clock drift; output aligned video/audio |
99
- | `color.py` | HDR10/HLG → SDR BT.709 tone mapping, 3D LUT application, colour-tag rewriting |
103
+ | `color.py` | HDR10/HLG/Dolby Vision → SDR tone mapping, DV layer stripping, 3D LUT application, colour-tag rewriting |
100
104
  | `audio.py` | Denoise / voice chain, music bed with auto-ducking, fades, downmix, replace track |
101
105
  | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
102
106
  | `overlay.py` | Composite image/logo or drawtext title with position, time range, opacity, fade |
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, caption (animated/karaoke), fit to duration/aspect, sync multicam audio with drift correction, HDR-to-SDR and LUT colour, denoise/duck/mix audio, normalise loudness, overlay logos, export platform presets, and inspect frames to verify the result; Python stdlib scripts, no cloud or API keys.
3
+ description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, multicam switching, caption (animated/karaoke timed to speech), fit to duration/aspect, sync audio with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs and Log detection, denoise/duck/mix audio, loudness, overlays, platform exports, frame inspection and a real-footage verification kit; Python stdlib scripts, no cloud or API keys.
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
@@ -27,6 +27,8 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
27
27
  `--dry-run` (prints the ffmpeg commands, runs nothing) and `--json`
28
28
  (structured result: output path, probe of the output, commands run). Use
29
29
  them to confirm a plan before long encodes and to report exact facts.
30
+ `--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
31
+ prints percent and ETA on stderr for long encodes.
30
32
  4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
31
33
  fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
32
34
  text so captions are sized for the final frame. Re-encode as few times as
@@ -59,6 +61,11 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
59
61
  | "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
60
62
  | "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
61
63
  | "what would you run?", "don't render yet" | any script with `--dry-run` |
64
+ | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
65
+ | "it's an iPhone Dolby Vision clip and players show it wrong" | `color.py clip.mov --to-sdr` or `color.py clip.mov --strip-dovi` (keep HDR, drop the DV layer) |
66
+ | "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
67
+ | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
68
+ | "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
62
69
  | "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
63
70
  | "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
64
71
  | "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
@@ -125,6 +132,29 @@ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
125
132
  `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
126
133
  longer than 2 × the transition. Use `--transition none` for a plain cut.
127
134
 
135
+ ### multicam.py — align several cameras and switch between them
136
+ ```
137
+ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
138
+ [--offsets-only] [--width W --height H --fps N] [-o OUT]
139
+ ```
140
+ All inputs are aligned to the first one by audio (same engine as `sync.py`,
141
+ `--fix-drift` for long takes). `--switch` names which camera is on screen for
142
+ each range of the reference timeline (gaps fall back to camera 0), `--auto N`
143
+ simply alternates every N seconds. Audio comes from the reference unless
144
+ `--audio` picks another input, e.g. an external recorder that has no video.
145
+ `--offsets-only` reports offsets and confidence without rendering.
146
+
147
+ ### verify.py — real-footage verification kit
148
+ ```
149
+ verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
150
+ ```
151
+ Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
152
+ and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
153
+ caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
154
+ HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
155
+ Run this first when a user hands over footage from a device you have not
156
+ seen before, and fix or report what fails.
157
+
128
158
  ### look.py — see the result
129
159
  ```
130
160
  look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
@@ -173,12 +203,17 @@ take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
173
203
  Use it whenever the recording is longer than ~10 minutes. Check `confidence`
174
204
  (0–1); below ~0.3 the match is doubtful — use a window with a clear event.
175
205
 
176
- ### color.py — HDR to SDR, LUTs, colour tags
206
+ ### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
177
207
  ```
178
208
  color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
179
209
  color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
180
210
  color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
211
+ color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
181
212
  ```
213
+ iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
214
+ `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
215
+ tone-maps it from the HLG base layer. When the user wants to keep HDR but
216
+ players mis-render the DV layer, `--strip-dovi` removes it losslessly.
182
217
  `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
183
218
  (default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
184
219
  broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
@@ -228,12 +263,17 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
228
263
  After `sync.py`, verify by running it again on the output: offset (and drift
229
264
  ppm with `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from
230
265
  separate devices: always use `--fix-drift`.
231
- - **Colour.** All H.264/H.265 outputs are tagged BT.709 and `yuv420p`. When
232
- `probe.py` reports `hdr: true` (`hdr_format` HDR10/PQ, HLG or BT.2020), run
233
- `color.py --to-sdr` **first**; other scripts would tag the HDR picture as
234
- BT.709 and it would look flat and desaturated (`export.py` warns about this).
266
+ - **Colour.** SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py`
267
+ reports `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing
268
+ script keeps the output HDR (HEVC Main10, source colour tags) so nothing is
269
+ silently flattened. Decide with the user: keep HDR (fine for YouTube/phones)
270
+ or run `color.py --to-sdr` first for SDR-only destinations, LUT work or
271
+ H.264 deliverables. `export.py` platform presets are SDR and warn on HDR
272
+ input. iPhone `.mov` files also carry timecode/metadata tracks; scripts map
273
+ only the first audio track, so extra tracks are dropped on re-encode.
235
274
  For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
236
- tagged SDR) apply the manufacturer's `.cube` with `color.py --lut`. Keep
275
+ tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
276
+ manufacturer's `.cube` with `color.py --lut` before anything else. Keep
237
277
  ProRes masters at source colour: `export.py --preset prores` does not retag.
238
278
  - **CJK and other non-Latin text.** libass and drawtext need a font that has
239
279
  the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.3.0",
4
- "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: cut, silence removal, transitions, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
3
+ "version": "0.4.1",
4
+ "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
5
5
  "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
6
6
  "license": "MIT",
7
7
  "author": "kajisho5",
@@ -55,7 +55,7 @@ def require_tool(name: str) -> str:
55
55
  return "" # unreachable
56
56
 
57
57
 
58
- STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": []}
58
+ STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
59
59
 
60
60
 
61
61
  def add_common(ap: "argparse.ArgumentParser") -> None:
@@ -63,11 +63,17 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
63
63
  g = ap.add_argument_group("agent options")
64
64
  g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
65
65
  g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
66
+ g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
67
+ g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
66
68
 
67
69
 
68
70
  def apply_common(args: "argparse.Namespace") -> None:
69
71
  STATE["dry_run"] = bool(getattr(args, "dry_run", False))
70
72
  STATE["json"] = bool(getattr(args, "json", False))
73
+ STATE["progress"] = bool(getattr(args, "progress", False))
74
+ STATE["fast"] = bool(getattr(args, "fast", False))
75
+ if STATE["fast"] and hasattr(args, "preset"):
76
+ args.preset = "veryfast"
71
77
 
72
78
 
73
79
  def emit(output: Optional[str], **extra: Any) -> None:
@@ -95,6 +101,8 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
95
101
  info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
96
102
  if STATE["dry_run"] and is_ffmpeg:
97
103
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
104
+ if STATE["progress"] and is_ffmpeg and cmd[-1] != "-":
105
+ return _run_with_progress(list(cmd), check)
98
106
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
99
107
  if check and proc.returncode != 0:
100
108
  tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
@@ -102,6 +110,42 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
102
110
  return proc
103
111
 
104
112
 
113
+ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
114
+ """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
115
+ import time
116
+ total = STATE.get("duration_hint") or 0.0
117
+ full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
118
+ t0 = time.time()
119
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
120
+ last = ""
121
+ assert proc.stdout is not None
122
+ for line in proc.stdout:
123
+ if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
124
+ try:
125
+ done = int(line.split("=")[1]) / 1_000_000
126
+ except ValueError:
127
+ continue
128
+ if total > 0:
129
+ pct = min(99.9, done / total * 100)
130
+ elapsed = time.time() - t0
131
+ eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
132
+ msg = f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
133
+ else:
134
+ msg = f"\r {done:7.1f}s encoded"
135
+ if msg != last:
136
+ sys.stderr.write(msg)
137
+ sys.stderr.flush()
138
+ last = msg
139
+ _, err = proc.communicate()
140
+ if last:
141
+ sys.stderr.write("\r" + " " * len(last) + "\r")
142
+ result = subprocess.CompletedProcess(full, proc.returncode, "", err)
143
+ if check and proc.returncode != 0:
144
+ tail = "\n".join(err.strip().splitlines()[-15:])
145
+ die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
146
+ return result
147
+
148
+
105
149
  def shell_quote(s: str) -> str:
106
150
  if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
107
151
  return "'" + s.replace("'", "'\\''") + "'"
@@ -143,6 +187,8 @@ def probe(path: str) -> Dict[str, Any]:
143
187
  duration = _to_float(video.get("duration"))
144
188
  if duration is None and audio:
145
189
  duration = _to_float(audio.get("duration"))
190
+ if duration and STATE.get("duration_hint") is None:
191
+ STATE["duration_hint"] = duration
146
192
 
147
193
  out: Dict[str, Any] = {
148
194
  "file": path,
@@ -173,6 +219,12 @@ def probe(path: str) -> Dict[str, Any]:
173
219
  trc = video.get("color_transfer") or ""
174
220
  prim = video.get("color_primaries") or ""
175
221
  hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
222
+ dovi = None
223
+ for sd in video.get("side_data_list", []) or []:
224
+ if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
225
+ dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
226
+ if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
227
+ hdr = True
176
228
  out["video"] = {
177
229
  "codec": video.get("codec_name"),
178
230
  "profile": video.get("profile"),
@@ -186,7 +238,9 @@ def probe(path: str) -> Dict[str, Any]:
186
238
  "pix_fmt": video.get("pix_fmt"),
187
239
  "bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
188
240
  "hdr": hdr,
189
- "hdr_format": ("HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
241
+ "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
242
+ "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
243
+ "dolby_vision": dovi,
190
244
  "color_space": video.get("color_space"),
191
245
  "color_primaries": video.get("color_primaries"),
192
246
  "color_transfer": video.get("color_transfer"),
@@ -277,6 +331,25 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
277
331
  return args
278
332
 
279
333
 
334
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
335
+ """Encoder args that preserve what the source is.
336
+
337
+ SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
338
+ Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
339
+ so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
340
+ washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
341
+ """
342
+ v = (meta or {}).get("video") or {}
343
+ if not v.get("hdr"):
344
+ return x264_args(crf, preset)
345
+ cs = v.get("color_space") or "bt2020nc"
346
+ prim = v.get("color_primaries") or "bt2020"
347
+ trc = v.get("color_transfer") or "arib-std-b67"
348
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
349
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
350
+ "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
351
+
352
+
280
353
  def aac_args(bitrate: str = "192k") -> List[str]:
281
354
  return ["-c:a", "aac", "-b:a", bitrate]
282
355
 
@@ -298,6 +371,51 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
298
371
  return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
299
372
 
300
373
 
374
+ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
375
+ """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
376
+
377
+ Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
378
+ white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
379
+ """
380
+ ffmpeg = require_tool("ffmpeg")
381
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
382
+ "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
383
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
384
+ vals: Dict[str, List[float]] = {}
385
+ for line in proc.stdout.splitlines():
386
+ if "lavfi.signalstats." in line and "=" in line:
387
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
388
+ try:
389
+ vals.setdefault(key, []).append(float(val))
390
+ except ValueError:
391
+ pass
392
+ if not vals.get("YAVG"):
393
+ return {"error": "no frames analysed"}
394
+ def mean(k: str) -> float:
395
+ v = vals.get(k) or [0.0]
396
+ return sum(v) / len(v)
397
+ ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
398
+ # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
399
+ scale = 1.0
400
+ if ymax > 255 or yavg > 255:
401
+ scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
402
+ ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
403
+ # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
404
+ lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
405
+ highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
406
+ p_low = lows[len(lows) // 20]
407
+ p_high = highs[-1 - len(highs) // 20]
408
+ looks_log = p_low >= 64 and p_high <= 235 and sat < 40
409
+ return {
410
+ "scale": "8-bit equivalent",
411
+ "y_min": round(ymin, 1), "y_max": round(ymax, 1), "y_avg": round(yavg, 1), "y_low_p5": round(p_low, 1), "y_high_p95": round(p_high, 1),
412
+ "saturation_avg": round(sat, 1),
413
+ "looks_like_log": looks_log,
414
+ "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
415
+ "Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
416
+ }
417
+
418
+
301
419
  def print_json(obj: Any) -> None:
302
420
  sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
303
421
 
@@ -22,7 +22,7 @@ import re
22
22
  import sys
23
23
  from typing import List, Tuple
24
24
 
25
- from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
25
+ from _common import video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
26
26
 
27
27
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
28
28
 
@@ -86,7 +86,65 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
86
86
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
87
87
 
88
88
 
89
- def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int) -> None:
89
+ def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
90
+ """Split a cue's time across n_words in proportion to speech energy (centiseconds each).
91
+
92
+ Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
93
+ and cuts at equal cumulative-energy quantiles: pauses get no words, loud stretches get more time.
94
+ Falls back to an even split when the window is silent or too short.
95
+ """
96
+ import struct
97
+ import subprocess as sp
98
+ from _common import require_tool
99
+ total_cs = max(1, int(round((end - start) * 100)))
100
+ if n_words <= 1:
101
+ return [total_cs]
102
+ ffmpeg = require_tool("ffmpeg")
103
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
104
+ "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
105
+ proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
106
+ n = len(proc.stdout) // 2
107
+ if proc.returncode != 0 or n < 800:
108
+ per = total_cs // n_words
109
+ return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
110
+ samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
111
+ step = 80 # 10 ms
112
+ env = []
113
+ for i in range(0, n - step + 1, step):
114
+ block = samples[i:i + step]
115
+ env.append((sum(x * x for x in block) / step) ** 0.5)
116
+ floor = sorted(env)[len(env) // 5] # 20th percentile ~ noise floor
117
+ energy = [max(0.0, e - floor) for e in env]
118
+ total_e = sum(energy)
119
+ if total_e <= 0:
120
+ per = total_cs // n_words
121
+ return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
122
+ # boundaries at cumulative-energy quantiles 1/n .. (n-1)/n
123
+ bounds = []
124
+ acc = 0.0
125
+ k = 1
126
+ for idx, e in enumerate(energy):
127
+ acc += e
128
+ while k < n_words and acc >= total_e * k / n_words:
129
+ bounds.append(idx + 1)
130
+ k += 1
131
+ while len(bounds) < n_words - 1:
132
+ bounds.append(len(energy))
133
+ prev = 0
134
+ out = []
135
+ for b in bounds:
136
+ cs = max(5, int(round((b - prev) * 1.0))) # 10 ms blocks -> centiseconds
137
+ out.append(cs)
138
+ prev = b
139
+ out.append(max(5, total_cs - sum(out)))
140
+ # normalise to the exact cue length
141
+ scale = total_cs / max(1, sum(out))
142
+ out = [max(5, int(round(x * scale))) for x in out]
143
+ out[-1] += total_cs - sum(out)
144
+ return out
145
+
146
+
147
+ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
90
148
  """Write a styled ASS file with optional animation and word-by-word highlight."""
91
149
  def t(sec: float) -> str:
92
150
  cs = int(round(sec * 100))
@@ -126,11 +184,16 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
126
184
  dur_cs = max(1, int(round((end - start) * 100)))
127
185
  segments = body.split("\\N")
128
186
  words = [w for seg in segments for w in seg.split(" ") if w]
129
- per = max(1, dur_cs // max(1, len(words)))
187
+ if getattr(args, "karaoke_timing", "even") == "energy" and video:
188
+ durs = word_durations_from_audio(video, start, end, len(words))
189
+ else:
190
+ per = max(1, dur_cs // max(1, len(words)))
191
+ durs = [per] * len(words)
192
+ it = iter(durs)
130
193
  out_segments = []
131
194
  for seg in segments:
132
195
  ws = [w for w in seg.split(" ") if w]
133
- out_segments.append(" ".join(f"{{\\kf{per}}}{w}" for w in ws))
196
+ out_segments.append(" ".join(f"{{\\kf{next(it)}}}{w}" for w in ws))
134
197
  body = "\\N".join(out_segments)
135
198
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
136
199
  with open(path, "w", encoding="utf-8-sig") as fh:
@@ -172,6 +235,8 @@ def main() -> int:
172
235
  anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
173
236
  anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
174
237
  anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
238
+ anim.add_argument("--karaoke-timing", choices=["even", "energy"], default="energy",
239
+ help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
175
240
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
176
241
  enc = ap.add_argument_group("encoding")
177
242
  enc.add_argument("--crf", type=int, default=18)
@@ -206,7 +271,7 @@ def main() -> int:
206
271
  w, h = meta["video"]["width"], meta["video"]["height"]
207
272
  if meta["video"].get("rotation") in (90, -90, 270, -270):
208
273
  w, h = h, w
209
- write_ass(cues_for_ass, ass_path, args, w, h)
274
+ write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
210
275
  info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
211
276
  args.ass = ass_path
212
277
 
@@ -237,7 +302,7 @@ def main() -> int:
237
302
  if args.fonts_dir:
238
303
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
239
304
 
240
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
305
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
241
306
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
242
307
  run(cmd)
243
308
  result = probe(output)
package/scripts/color.py CHANGED
@@ -9,6 +9,8 @@ Examples:
9
9
  python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
10
10
  python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
11
11
  python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
12
+ python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
13
+ python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
12
14
  """
13
15
  import argparse
14
16
  import os
@@ -45,6 +47,7 @@ def main() -> int:
45
47
  mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
46
48
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
47
49
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
50
+ mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
48
51
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
49
52
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
50
53
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
@@ -62,6 +65,22 @@ def main() -> int:
62
65
  v = meta["video"]
63
66
  has_audio = bool(meta.get("audio"))
64
67
 
68
+ if args.strip_dovi:
69
+ output = args.output or default_output(args.input, "nodv")
70
+ if v.get("codec") != "hevc":
71
+ die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
72
+ if not v.get("dolby_vision"):
73
+ info("note: no Dolby Vision metadata detected; removing unregistered SEI anyway")
74
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
75
+ if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
76
+ cmd += ["-movflags", "+faststart"]
77
+ cmd.append(output)
78
+ run(cmd)
79
+ r = probe(output)
80
+ info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
81
+ emit(output)
82
+ return 0
83
+
65
84
  if args.retag:
66
85
  tags = {
67
86
  "bt709": ["bt709", "bt709", "bt709"],
@@ -80,7 +99,7 @@ def main() -> int:
80
99
  if proc.returncode != 0:
81
100
  # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
82
101
  info("stream copy could not rewrite tags, re-encoding")
83
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a?"] + x264_args(args.crf, args.preset, keep_bt709=False)
102
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a:0?"] + x264_args(args.crf, args.preset, keep_bt709=False)
84
103
  cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
85
104
  run(cmd)
86
105
  info(f"wrote {output} (tags -> {args.retag})")
@@ -105,7 +124,7 @@ def main() -> int:
105
124
  output = args.output or default_output(args.input, "lut")
106
125
  tag = "lut"
107
126
 
108
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a?"]
127
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:0?"]
109
128
  cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
110
129
  run(cmd)
111
130
  r = probe(output)
package/scripts/cut.py CHANGED
@@ -16,7 +16,7 @@ import sys
16
16
  import tempfile
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
19
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
20
20
 
21
21
 
22
22
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -43,7 +43,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
43
43
  meta = meta or probe(src)
44
44
  if reencode:
45
45
  cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
46
- cmd += x264_args(crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
46
+ cmd += video_args(meta, crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
47
47
  else:
48
48
  cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
49
49
  proc = run(cmd, check=False)
@@ -126,7 +126,7 @@ def main() -> int:
126
126
  proc = run(cmd, check=False)
127
127
  if proc.returncode != 0:
128
128
  info("concat with stream copy failed, re-encoding the join")
129
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
129
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + video_args(meta, args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
130
130
  run(cmd)
131
131
 
132
132
  result = probe(output)
package/scripts/fit.py CHANGED
@@ -19,7 +19,7 @@ import sys
19
19
  from fractions import Fraction
20
20
  from typing import List
21
21
 
22
- from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
22
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
23
23
 
24
24
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
25
25
 
@@ -116,6 +116,7 @@ def main() -> int:
116
116
  if has_audio:
117
117
  af.append(atempo_chain(factor))
118
118
  post += ["-t", f"{target:.3f}"]
119
+ STATE["duration_hint"] = target
119
120
  else:
120
121
  if target < src_dur:
121
122
  start = (src_dur - target) / 2 if args.from_center else 0.0
@@ -152,7 +153,7 @@ def main() -> int:
152
153
  cmd += ["-vf", ",".join(vf)]
153
154
  if af:
154
155
  cmd += ["-af", ",".join(af)]
155
- cmd += x264_args(args.crf, args.preset)
156
+ cmd += video_args(meta, args.crf, args.preset)
156
157
  cmd += cfr_args(meta, args.fps) if not args.fps else []
157
158
  if has_audio:
158
159
  cmd += aac_args()
package/scripts/join.py CHANGED
@@ -14,7 +14,7 @@ import argparse
14
14
  import sys
15
15
  from typing import List
16
16
 
17
- from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
17
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
18
18
 
19
19
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
20
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -78,8 +78,9 @@ def main() -> int:
78
78
  geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
79
79
  else:
80
80
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
81
+ pixfmt = "yuv420p10le" if (metas[0].get("video") or {}).get("hdr") else "yuv420p"
81
82
  for i in range(n):
82
- parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
83
+ parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
83
84
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
84
85
 
85
86
  if args.transition == "none":
@@ -98,7 +99,7 @@ def main() -> int:
98
99
 
99
100
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
100
101
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
101
- cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
102
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
102
103
  run(cmd)
103
104
  expected = sum(durs) - d * (n - 1)
104
105
  r = probe(output)
package/scripts/look.py CHANGED
@@ -44,6 +44,13 @@ def main() -> int:
44
44
  stem = Path(args.input).stem
45
45
  outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
46
46
  tc = "" if args.no_timecode else "," + timecode_filter()
47
+ # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
48
+ if meta["video"].get("hdr"):
49
+ v = meta["video"]
50
+ tm = (f"zscale=tin={v.get('color_transfer') or 'arib-std-b67'}:pin={v.get('color_primaries') or 'bt2020'}:min={v.get('color_space') or 'bt2020nc'}:rin=tv:t=linear:npl=1000,"
51
+ "format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
52
+ tc = "," + tm.rstrip(",") + tc
53
+ info("HDR source: frames are tone-mapped to SDR for display")
47
54
  outputs: List[str] = []
48
55
 
49
56
  if args.compare:
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ """Multicam: align two or more cameras (and an optional external recorder) by
3
+ audio, then cut between them from a simple switch list.
4
+
5
+ All sources are aligned to the FIRST input (the reference) using the same
6
+ cross-correlation as sync.py. The output takes video from whichever camera the
7
+ switch list names for each time range (reference timeline), and audio from the
8
+ reference unless --audio picks another source.
9
+
10
+ Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
11
+ reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
12
+ Gaps fall back to camera 0.
13
+
14
+ Examples:
15
+ python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
16
+ python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
17
+ python3 multicam.py camA.mp4 camB.mp4 recorder.wav --audio 2 --switch "0-20:0,20-40:1" --fix-drift
18
+ python3 multicam.py camA.mp4 camB.mp4 --auto 8 -o edit.mp4 # alternate cameras every 8 s
19
+ """
20
+ import argparse
21
+ import sys
22
+ from typing import List, Tuple
23
+
24
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
25
+ from sync import measure_offset
26
+
27
+
28
+ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
29
+ out = []
30
+ for raw in spec.split(","):
31
+ raw = raw.strip()
32
+ if not raw:
33
+ continue
34
+ try:
35
+ rng, cam = raw.rsplit(":", 1)
36
+ a, b = rng.rsplit("-", 1)
37
+ s, e, c = parse_time(a), parse_time(b), int(cam)
38
+ except ValueError:
39
+ die(f"bad switch entry '{raw}' (want START-END:CAM)")
40
+ if not 0 <= c < n:
41
+ die(f"camera {c} does not exist (inputs are 0..{n - 1})")
42
+ if e <= s:
43
+ die(f"switch entry '{raw}': end must be after start")
44
+ out.append((s, e, c))
45
+ out.sort()
46
+ return out
47
+
48
+
49
+ def main() -> int:
50
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
51
+ ap.add_argument("inputs", nargs="+", help="reference camera first, then other cameras / recorders")
52
+ ap.add_argument("-o", "--output", help="output file (default: <reference>_multicam.mp4)")
53
+ ap.add_argument("--switch", help="switch list START-END:CAM,... on the reference timeline")
54
+ ap.add_argument("--auto", type=float, help="no switch list: alternate through the cameras every N seconds")
55
+ ap.add_argument("--audio", type=int, default=0, help="input index to take audio from (default 0 = reference)")
56
+ ap.add_argument("--offsets-only", action="store_true", help="print the measured offsets and exit")
57
+ ap.add_argument("--max-offset", type=float, default=30.0)
58
+ ap.add_argument("--analyze-seconds", type=float, default=120.0)
59
+ ap.add_argument("--fix-drift", action="store_true", help="also correct clock drift of each source (long recordings)")
60
+ ap.add_argument("--width", type=int, help="output width (default: reference)")
61
+ ap.add_argument("--height", type=int, help="output height (default: reference)")
62
+ ap.add_argument("--fps", type=float, help="output fps (default: reference)")
63
+ ap.add_argument("--crf", type=int, default=18)
64
+ ap.add_argument("--preset", default="medium")
65
+ add_common(ap)
66
+ args = ap.parse_args()
67
+ apply_common(args)
68
+
69
+ n = len(args.inputs)
70
+ if n < 2:
71
+ die("give at least two inputs")
72
+ metas = [probe(p) for p in args.inputs]
73
+ for p, m in zip(args.inputs, metas):
74
+ if not m.get("audio"):
75
+ die(f"{p} has no audio to align with")
76
+ if not metas[0].get("video"):
77
+ die("the reference (first input) must have video")
78
+
79
+ offsets = [0.0]
80
+ ratios = [1.0]
81
+ conf = [1.0]
82
+ for p in args.inputs[1:]:
83
+ off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
84
+ ratio = 1.0
85
+ if args.fix_drift:
86
+ ref_dur = metas[0]["duration"] or 0.0
87
+ sec_dur = probe(p)["duration"] or 0.0
88
+ overlap_end = min(ref_dur, sec_dur + off)
89
+ window = 60.0
90
+ head_len = min(args.analyze_seconds, overlap_end)
91
+ tail_start = overlap_end - window
92
+ if tail_start > head_len / 2 + 5:
93
+ ref_start, sec_start = tail_start, tail_start - off
94
+ if sec_start < 0:
95
+ ref_start -= sec_start
96
+ sec_start = 0.0
97
+ from sync import decode_mono, envelope, cross_correlate, refine, SR
98
+ ref_s = decode_mono(args.inputs[0], window, ref_start)
99
+ oth_s = decode_mono(p, window, sec_start)
100
+ step = int(SR * 0.02)
101
+ lag, sc = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
102
+ residual = refine(ref_s, oth_s, lag * step / SR, int(SR * 0.001), 0.04)
103
+ elapsed = (ref_start + window / 2) - head_len / 2
104
+ if elapsed > 0 and sc > 0.1:
105
+ ratio = 1.0 - residual / elapsed
106
+ off = off + (ratio - 1.0) * (head_len / 2)
107
+ offsets.append(off)
108
+ ratios.append(ratio)
109
+ conf.append(score)
110
+ info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
111
+
112
+ report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
113
+ "confidence": [round(c, 3) for c in conf]}
114
+ if args.fix_drift:
115
+ report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
116
+ if args.offsets_only:
117
+ emit(None, **report)
118
+ if not args.json:
119
+ for p, o, c in zip(args.inputs, offsets, conf):
120
+ print(f"{p}: {o:+.3f}s (confidence {c:.2f})")
121
+ return 0
122
+
123
+ ref_dur = metas[0]["duration"] or 0.0
124
+ if args.switch:
125
+ cuts = parse_switch(args.switch, n)
126
+ elif args.auto:
127
+ cams = [i for i, m in enumerate(metas) if m.get("video")]
128
+ cuts, t, k = [], 0.0, 0
129
+ while t < ref_dur:
130
+ cuts.append((t, min(ref_dur, t + args.auto), cams[k % len(cams)]))
131
+ t += args.auto
132
+ k += 1
133
+ else:
134
+ die("give --switch or --auto (or --offsets-only)")
135
+ # fill gaps with camera 0 and clip to the reference length
136
+ filled: List[Tuple[float, float, int]] = []
137
+ cursor = 0.0
138
+ for s, e, c in cuts:
139
+ s, e = max(0.0, s), min(ref_dur, e)
140
+ if s > cursor:
141
+ filled.append((cursor, s, 0))
142
+ if e > s:
143
+ filled.append((s, e, c))
144
+ cursor = max(cursor, e)
145
+ if cursor < ref_dur:
146
+ filled.append((cursor, ref_dur, 0))
147
+ for s, e, c in filled:
148
+ if not metas[c].get("video"):
149
+ die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
150
+
151
+ v0 = metas[0]["video"]
152
+ w, h = args.width or v0["width"], args.height or v0["height"]
153
+ if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
154
+ w, h = h, w
155
+ fps = args.fps or v0.get("fps") or 30.0
156
+ fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
157
+ pixfmt = "yuv420p10le" if v0.get("hdr") else "yuv420p"
158
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
159
+
160
+ cmd = ffmpeg_base()
161
+ for p in args.inputs:
162
+ cmd += ["-i", p]
163
+ parts: List[str] = []
164
+ labels: List[str] = []
165
+ for i, (s, e, c) in enumerate(filled):
166
+ # reference time t maps to source time (t - offset_c) * ratio_c
167
+ src_s = (s - offsets[c]) * ratios[c]
168
+ src_e = (e - offsets[c]) * ratios[c]
169
+ if src_s < 0:
170
+ info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
171
+ c, src_s, src_e = 0, s, e
172
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{geo}[v{i}]")
173
+ labels.append(f"[v{i}]")
174
+ parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
175
+ a = args.audio
176
+ a_start = -offsets[a] if offsets[a] < 0 else 0.0
177
+ afx = []
178
+ if abs(ratios[a] - 1.0) > 1e-7:
179
+ sr = metas[a]["audio"].get("sample_rate") or 48000
180
+ afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
181
+ if offsets[a] > 0:
182
+ afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
183
+ afx += [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS", f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
184
+ parts.append(f"[{a}:a]{','.join(afx)}[aout]")
185
+
186
+ output = args.output or default_output(args.inputs[0], "multicam", "mp4")
187
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
188
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
189
+ run(cmd)
190
+ r = probe(output)
191
+ info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
192
+ emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
193
+ return 0
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main())
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List, Optional
17
17
 
18
- from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
18
+ from _common import video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
19
19
 
20
20
  POS = {
21
21
  "top-left": ("{m}", "{m}"),
@@ -140,7 +140,7 @@ def main() -> int:
140
140
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
141
141
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
142
142
  fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
143
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a?", "-shortest"]
143
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:0?", "-shortest"]
144
144
  else:
145
145
  x, y = position_exprs(args.position, args.margin, text_mode=True)
146
146
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
@@ -159,7 +159,7 @@ def main() -> int:
159
159
  opts.append(f"enable='{enable}'")
160
160
  cmd += ["-vf", "drawtext=" + ":".join(opts)]
161
161
 
162
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta)
162
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
163
163
  cmd += aac_args() if meta.get("audio") else ["-an"]
164
164
  cmd.append(output)
165
165
  run(cmd)
package/scripts/probe.py CHANGED
@@ -12,7 +12,7 @@ Examples:
12
12
  import argparse
13
13
  import sys
14
14
 
15
- from _common import print_json, probe
15
+ from _common import analyze_levels, print_json, probe
16
16
 
17
17
 
18
18
  def main() -> int:
@@ -20,9 +20,14 @@ def main() -> int:
20
20
  ap.add_argument("inputs", nargs="+", help="media file(s) to inspect")
21
21
  ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
22
22
  ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
23
+ ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
23
24
  args = ap.parse_args()
24
25
 
25
26
  results = [probe(p) for p in args.inputs]
27
+ if args.analyze:
28
+ for r in results:
29
+ if r.get("video"):
30
+ r["levels"] = analyze_levels(r["file"])
26
31
 
27
32
  if args.field:
28
33
  for r in results:
@@ -41,6 +46,10 @@ def main() -> int:
41
46
  line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
42
47
  if v.get("variable_frame_rate_suspected"):
43
48
  line += " (VFR?)"
49
+ if v.get("hdr"):
50
+ line += f" [{v.get('hdr_format')}]"
51
+ if r.get("levels", {}).get("looks_like_log"):
52
+ line += " [Log?]"
44
53
  else:
45
54
  line += " | no video"
46
55
  if a:
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
19
+ from _common import video_args, aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -110,7 +110,7 @@ def main() -> int:
110
110
  af = f"aselect='{expr}',asetpts=N/SR/TB"
111
111
  cmd = ffmpeg_base() + ["-i", args.input]
112
112
  if meta.get("video"):
113
- cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
113
+ cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
114
114
  cmd += ["-af", af] + aac_args() + [output]
115
115
  run(cmd)
116
116
  r = probe(output)
package/scripts/sync.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import List
28
28
 
29
- from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
29
+ from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
30
30
 
31
31
  SR = 8000 # decode sample rate
32
32
 
@@ -253,14 +253,14 @@ def main() -> int:
253
253
  if proc.returncode != 0:
254
254
  cmd = [c for c in cmd if c != "copy"]
255
255
  idx = cmd.index("-c:v"); del cmd[idx]
256
- cmd = cmd[:-1] + x264_args(args.crf) + [output]
256
+ cmd = cmd[:-1] + video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) + [output]
257
257
  run(cmd)
258
258
  else:
259
259
  if head_trim > 0 and not drift_af:
260
260
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
261
261
  proc = run(cmd, check=False)
262
262
  if proc.returncode != 0:
263
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (x264_args(args.crf) if has_video else []) + audio_codec_for(output) + [output]
263
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
264
264
  run(cmd)
265
265
  else:
266
266
  cmd = ffmpeg_base()
@@ -278,7 +278,7 @@ def main() -> int:
278
278
  vf.append(f"setpts=PTS/{drift_ratio:.9f}")
279
279
  if vf:
280
280
  cmd += ["-vf", ",".join(vf)]
281
- cmd += x264_args(args.crf)
281
+ cmd += video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf)
282
282
  if af_parts:
283
283
  cmd += ["-af", ",".join(af_parts)]
284
284
  cmd += audio_codec_for(output) + [output]
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Real-footage verification kit: run the whole toolchain against your own
3
+ files (phone HDR, GoPro, OBS screen captures, Log footage, Zoom recordings)
4
+ and get a pass/fail table. Synthetic test media never shows what a real
5
+ container does; this does.
6
+
7
+ Every file gets: probe, lossless cut, accurate cut, fit (9:16 pad), caption
8
+ burn, overlay text, loudness measurement, silence listing, export (x preset),
9
+ look (contact sheet), plus color --to-sdr when the file is HDR and
10
+ audio --downmix when it has more than 2 channels.
11
+
12
+ Examples:
13
+ python3 verify.py ~/Footage/*.MOV ~/Footage/*.mp4
14
+ python3 verify.py fixtures/ --quick --report verify.md
15
+ python3 verify.py clip.mov --keep --out ./verify_out
16
+ """
17
+ import argparse
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Dict, List
26
+
27
+ from _common import add_common, apply_common, die, emit, info, probe
28
+
29
+ HERE = Path(__file__).resolve().parent
30
+ MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
31
+
32
+
33
+ def collect(paths: List[str]) -> List[Path]:
34
+ files: List[Path] = []
35
+ for p in paths:
36
+ pp = Path(p)
37
+ if pp.is_dir():
38
+ files += sorted(x for x in pp.rglob("*") if x.suffix.lower() in MEDIA_EXT and x.is_file())
39
+ elif pp.is_file():
40
+ files.append(pp)
41
+ else:
42
+ die(f"not found: {p}")
43
+ if not files:
44
+ die("no media files found")
45
+ return files
46
+
47
+
48
+ def step(name: str, argv: List[str], timeout: float) -> Dict:
49
+ t0 = time.time()
50
+ if argv[0] == "__check_hdr__":
51
+ try:
52
+ v = probe(argv[1]).get("video") or {}
53
+ ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
54
+ err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
55
+ except SystemExit:
56
+ ok, err = False, "output missing"
57
+ return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
58
+ try:
59
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
60
+ ok = proc.returncode == 0
61
+ err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
62
+ except subprocess.TimeoutExpired:
63
+ ok, err = False, f"timeout after {timeout:.0f}s"
64
+ return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
65
+
66
+
67
+ def main() -> int:
68
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
69
+ ap.add_argument("paths", nargs="+", help="media files and/or folders")
70
+ ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
71
+ ap.add_argument("--keep", action="store_true", help="keep the outputs")
72
+ ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
73
+ ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
74
+ ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
75
+ ap.add_argument("--report", help="write a Markdown report here")
76
+ add_common(ap)
77
+ args = ap.parse_args()
78
+ apply_common(args)
79
+
80
+ files = collect(args.paths)
81
+ tmp = None
82
+ if args.out:
83
+ outdir = Path(args.out)
84
+ outdir.mkdir(parents=True, exist_ok=True)
85
+ else:
86
+ tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
87
+ outdir = Path(tmp.name)
88
+
89
+ results = []
90
+ for f in files:
91
+ info(f"=== {f}")
92
+ entry: Dict = {"file": str(f), "steps": []}
93
+ try:
94
+ meta = probe(str(f))
95
+ except SystemExit:
96
+ entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
97
+ results.append(entry)
98
+ continue
99
+ entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
100
+ entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
101
+ entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
102
+ entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
103
+ dur = meta.get("duration") or 0.0
104
+ has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
105
+ stem = outdir / f.stem
106
+ cut = f"{stem}_cut.mp4"
107
+ seg_end = min(dur, args.seconds) if dur else args.seconds
108
+ fast = ["--fast"]
109
+ plan = []
110
+ plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
111
+ if has_v:
112
+ plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
113
+ plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
114
+ cues = outdir / f"{f.stem}_cues.txt"
115
+ cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
116
+ plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
117
+ if not args.quick:
118
+ plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
119
+ plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
120
+ if (meta.get("video") or {}).get("hdr"):
121
+ plan.append(("color to-sdr", ["color.py", str(f), "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
122
+ plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
123
+ plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
124
+ plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
125
+ if has_a and not args.quick:
126
+ plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
127
+ plan.append(("silence list", ["silence.py", cut, "--list"]))
128
+ if (meta.get("audio") or {}).get("channels", 0) > 2:
129
+ plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
130
+ for name, argv in plan:
131
+ r = step(name, argv, args.timeout)
132
+ entry["steps"].append(r)
133
+ info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
134
+ results.append(entry)
135
+
136
+ total = sum(len(e["steps"]) for e in results)
137
+ failed = sum(1 for e in results for s in e["steps"] if not s["ok"])
138
+ lines = ["# ffmpeg-skill verification", "", f"{len(files)} files, {total} steps, {failed} failed", ""]
139
+ for e in results:
140
+ p = e.get("probe", {})
141
+ v, a = p.get("video", {}), p.get("audio", {})
142
+ lines.append(f"## {e['file']}")
143
+ lines.append(f"{p.get('duration')}s, {v.get('codec')} {v.get('width')}x{v.get('height')} @ {v.get('fps')} {v.get('pix_fmt')}"
144
+ + (f" [{v.get('hdr_format')}]" if v.get("hdr_format") else "") + (" [VFR?]" if v.get("variable_frame_rate_suspected") else "")
145
+ + (f" rot {v.get('rotation')}" if v.get("rotation") else "") + f", audio {a.get('codec')} {a.get('channels')}ch")
146
+ lines.append("")
147
+ lines.append("| step | result | time | error |")
148
+ lines.append("|---|---|---|---|")
149
+ for s in e["steps"]:
150
+ lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
151
+ lines.append("")
152
+ report = "\n".join(lines)
153
+ if args.report:
154
+ Path(args.report).write_text(report, encoding="utf-8")
155
+ info(f"wrote {args.report}")
156
+ if args.json:
157
+ emit(None, report=args.report, files=results, failed=failed, total=total)
158
+ else:
159
+ print(report)
160
+ if tmp and not args.keep:
161
+ tmp.cleanup()
162
+ return 1 if failed else 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())