ffmpeg-skill 0.4.0 → 0.5.0

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.
Files changed (38) hide show
  1. package/README.md +7 -0
  2. package/SKILL.md +56 -11
  3. package/package.json +2 -2
  4. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  5. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  6. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  7. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  8. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  9. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  10. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  22. package/scripts/_common.py +31 -5
  23. package/scripts/caption.py +2 -2
  24. package/scripts/check.py +163 -0
  25. package/scripts/color.py +2 -2
  26. package/scripts/cut.py +3 -3
  27. package/scripts/export.py +3 -1
  28. package/scripts/fit.py +4 -2
  29. package/scripts/join.py +16 -8
  30. package/scripts/look.py +7 -0
  31. package/scripts/loudness.py +3 -1
  32. package/scripts/multicam.py +4 -3
  33. package/scripts/overlay.py +3 -3
  34. package/scripts/render.py +333 -0
  35. package/scripts/scenes.py +155 -0
  36. package/scripts/silence.py +2 -2
  37. package/scripts/sync.py +4 -4
  38. package/scripts/verify.py +10 -1
package/README.md CHANGED
@@ -17,6 +17,9 @@ 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
+ - **Declarative edits** — describe the whole edit in a `project.json` (clips, transitions, captions, overlays, music, loudness, export, check) and re-render after every tweak.
21
+ - **Scene detection and highlight picks** — find cuts and loud moments, get a 60-second digest proposal as a cut list.
22
+ - **Delivery checks** — PASS/FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, broadcast and podcast specs, with the fix for each failure.
20
23
  - **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
21
24
  - **Real-footage verification kit** — run the whole toolchain on your own device files and get a PASS/FAIL report.
22
25
  - **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.
@@ -92,6 +95,9 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
92
95
  |--------|--------------|
93
96
  | `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 |
94
97
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
98
+ | `render.py` | Render a whole edit from `project.json`; `--init`, `--dry-run`, `--stop-after` |
99
+ | `scenes.py` | Scene changes, audio peaks, highlight proposals and per-scene sheet |
100
+ | `check.py` | Pre-delivery compliance per platform (duration, aspect, codec, colour, loudness, size) |
95
101
  | `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
96
102
  | `verify.py` | Run the toolchain on real device files and report PASS/FAIL per step |
97
103
  | `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
@@ -119,6 +125,7 @@ All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stder
119
125
  ```bash
120
126
  bash examples/make_demo.sh # generates footage, runs every script, rebuilds assets/demo.gif
121
127
  python3 tests/test_all.py # end-to-end tests incl. VFR, rotated, 5.1, 10-bit HDR10 and drifting sources (needs ffmpeg)
128
+ python3 evals/run.py --list # routing eval prompts (see evals/)
122
129
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
123
130
  ```
124
131
 
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, 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.
3
+ description: Professional video editing with local FFmpeg — declarative project rendering, scene detection and highlight picks, delivery compliance checks, cut, silence removal, transitions, multicam, captions (animated/karaoke timed to speech), fit to duration/aspect, audio sync with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs, audio clean-up and ducking, 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
@@ -30,15 +30,23 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
30
30
  `--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
31
31
  prints percent and ETA on stderr for long encodes.
32
32
  4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
33
- fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
34
- text so captions are sized for the final frame. Re-encode as few times as
35
- possible: if several re-encoding steps are needed, keep intermediates at
36
- CRF 18 (the default) and only use `export.py` for the last step.
37
- 5. **Verify the output.** Run `probe.py` on each result and confirm duration,
33
+ join → silence → fit → caption/overlay → sync → audio → loudness → export.
34
+ Do frame changes (fit/crop) before captions and overlays so text is sized
35
+ for the final frame. Re-encode as few times as possible: keep intermediates
36
+ at CRF 18 (the default) and only use `export.py` for the last step; for
37
+ anything with more than two steps use `render.py` with a project.json.
38
+ 5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
39
+ for the destination the user named; fix FAILs, mention WARNs.
40
+ 6. **Verify the output.** Run `probe.py` on each result and confirm duration,
38
41
  resolution, fps and audio match what was requested. Report those numbers to
39
42
  the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
40
- 6. **Keep the user's originals.** Never overwrite the source file. Write new
43
+ 7. **Keep the user's originals.** Never overwrite the source file. Write new
41
44
  files next to the input or where the user asked.
45
+ 8. **Look at the picture.** After captioning, overlaying, cropping or colour
46
+ work run `look.py OUTPUT` (contact sheet) or `look.py OUTPUT --at T` and
47
+ view the PNG: text inside the frame and not over faces, logos where asked,
48
+ crops keeping the subject, colours not washed out. Fix and re-run before
49
+ reporting. Numbers from probe are not enough.
42
50
 
43
51
  ## Request → script
44
52
 
@@ -61,6 +69,9 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
61
69
  | "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
62
70
  | "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
63
71
  | "what would you run?", "don't render yet" | any script with `--dry-run` |
72
+ | "make a 60 s highlight from this hour", "find the good bits" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` → `cut.py --segments` |
73
+ | "is this OK to upload?", "check it meets the Reels spec" | `check.py final.mp4 --platform reels` |
74
+ | "set it up so I can tweak and re-render", "several changes to the same edit" | `render.py --init project.json`, edit, `render.py project.json` |
64
75
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
65
76
  | "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
77
  | "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
@@ -132,6 +143,36 @@ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
132
143
  `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
133
144
  longer than 2 × the transition. Use `--transition none` for a plain cut.
134
145
 
146
+ ### render.py — the whole edit in one project.json
147
+ ```
148
+ render.py --init project.json # starter file
149
+ render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
150
+ ```
151
+ Stages: clips (cut, optional speed) → join (transition) → silence → fit →
152
+ captions → overlays → audio → loudness → export → check. Keys mirror the
153
+ CLI flags of each script (see the docstring). Use it whenever an edit has
154
+ more than two steps or the user is likely to ask for changes: edit the JSON,
155
+ re-render, and the result is reproducible. `--dry-run --json` prints the
156
+ complete command plan for review.
157
+
158
+ ### scenes.py — scene changes and highlight candidates
159
+ ```
160
+ scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
161
+ ```
162
+ Lists scenes with audio energy, the loudest moments, and (with
163
+ `--highlights`) proposes N ranges that add up to `--target` seconds, biased to
164
+ the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
165
+ `cut.py --segments`. It is a proposal engine, not a judgement of content:
166
+ tell the user what it picked and why (energy, scene length).
167
+
168
+ ### check.py — pre-delivery compliance
169
+ ```
170
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
171
+ [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
172
+ ```
173
+ PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
174
+ step before reporting a deliverable; fix FAILs, mention WARNs.
175
+
135
176
  ### multicam.py — align several cameras and switch between them
136
177
  ```
137
178
  multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
@@ -263,10 +304,14 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
263
304
  After `sync.py`, verify by running it again on the output: offset (and drift
264
305
  ppm with `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from
265
306
  separate devices: always use `--fix-drift`.
266
- - **Colour.** All H.264/H.265 outputs are tagged BT.709 and `yuv420p`. When
267
- `probe.py` reports `hdr: true` (`hdr_format` HDR10/PQ, HLG or BT.2020), run
268
- `color.py --to-sdr` **first**; other scripts would tag the HDR picture as
269
- BT.709 and it would look flat and desaturated (`export.py` warns about this).
307
+ - **Colour.** SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py`
308
+ reports `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing
309
+ script keeps the output HDR (HEVC Main10, source colour tags) so nothing is
310
+ silently flattened. Decide with the user: keep HDR (fine for YouTube/phones)
311
+ or run `color.py --to-sdr` first for SDR-only destinations, LUT work or
312
+ H.264 deliverables. `export.py` platform presets are SDR and warn on HDR
313
+ input. iPhone `.mov` files also carry timecode/metadata tracks; scripts map
314
+ only the first audio track, so extra tracks are dropped on re-encode.
270
315
  For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
271
316
  tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
272
317
  manufacturer's `.cube` with `color.py --lut` before anything else. Keep
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.4.0",
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.",
3
+ "version": "0.5.0",
4
+ "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: declarative project rendering, scene detection, delivery checks, 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,6 +55,7 @@ def require_tool(name: str) -> str:
55
55
  return "" # unreachable
56
56
 
57
57
 
58
+ X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
58
59
  STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
59
60
 
60
61
 
@@ -72,7 +73,7 @@ def apply_common(args: "argparse.Namespace") -> None:
72
73
  STATE["json"] = bool(getattr(args, "json", False))
73
74
  STATE["progress"] = bool(getattr(args, "progress", False))
74
75
  STATE["fast"] = bool(getattr(args, "fast", False))
75
- if STATE["fast"] and hasattr(args, "preset"):
76
+ if STATE["fast"] and getattr(args, "preset", None) in X264_PRESETS:
76
77
  args.preset = "veryfast"
77
78
 
78
79
 
@@ -163,7 +164,7 @@ def probe(path: str) -> Dict[str, Any]:
163
164
  if not os.path.exists(path):
164
165
  if STATE["dry_run"]:
165
166
  return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
166
- "video": {"codec": None, "width": 0, "height": 0, "fps": None, "pix_fmt": None, "hdr": False,
167
+ "video": {"codec": None, "width": 1920, "height": 1080, "fps": 30.0, "pix_fmt": None, "hdr": False,
167
168
  "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
168
169
  "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
169
170
  die(f"input not found: {path}")
@@ -331,6 +332,25 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
331
332
  return args
332
333
 
333
334
 
335
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
336
+ """Encoder args that preserve what the source is.
337
+
338
+ SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
339
+ Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
340
+ so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
341
+ washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
342
+ """
343
+ v = (meta or {}).get("video") or {}
344
+ if not v.get("hdr"):
345
+ return x264_args(crf, preset)
346
+ cs = v.get("color_space") or "bt2020nc"
347
+ prim = v.get("color_primaries") or "bt2020"
348
+ trc = v.get("color_transfer") or "arib-std-b67"
349
+ 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}"
350
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
351
+ "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
352
+
353
+
334
354
  def aac_args(bitrate: str = "192k") -> List[str]:
335
355
  return ["-c:a", "aac", "-b:a", bitrate]
336
356
 
@@ -376,14 +396,20 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
376
396
  v = vals.get(k) or [0.0]
377
397
  return sum(v) / len(v)
378
398
  ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
399
+ # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
400
+ scale = 1.0
401
+ if ymax > 255 or yavg > 255:
402
+ scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
403
+ ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
379
404
  # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
380
- lows = sorted(vals.get("YLOW") or vals.get("YMIN") or [0])
381
- highs = sorted(vals.get("YHIGH") or vals.get("YMAX") or [255])
405
+ lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
406
+ highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
382
407
  p_low = lows[len(lows) // 20]
383
408
  p_high = highs[-1 - len(highs) // 20]
384
409
  looks_log = p_low >= 64 and p_high <= 235 and sat < 40
385
410
  return {
386
- "y_min": ymin, "y_max": ymax, "y_avg": round(yavg, 1), "y_low_p5": p_low, "y_high_p95": p_high,
411
+ "scale": "8-bit equivalent",
412
+ "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),
387
413
  "saturation_avg": round(sat, 1),
388
414
  "looks_like_log": looks_log,
389
415
  "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
@@ -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
 
@@ -302,7 +302,7 @@ def main() -> int:
302
302
  if args.fonts_dir:
303
303
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
304
304
 
305
- 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)
306
306
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
307
307
  run(cmd)
308
308
  result = probe(output)
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-delivery compliance check: does this file meet the platform's spec?
3
+
4
+ Checks duration, frame size / aspect, fps, codec, pixel format, colour tags,
5
+ file size, integrated loudness and true peak against the chosen platform
6
+ and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs.
7
+
8
+ Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
9
+
10
+ Examples:
11
+ python3 check.py final.mp4 --platform youtube
12
+ python3 check.py reel.mp4 --platform reels --json
13
+ python3 check.py spot.mov --platform broadcast
14
+ python3 check.py clip.mp4 --platform custom --max-duration 30 --aspect 1:1 --lufs -16
15
+ """
16
+ import argparse
17
+ import json
18
+ import re
19
+ import sys
20
+ from fractions import Fraction
21
+ from typing import Any, Dict, List
22
+
23
+ from _common import add_common, apply_common, die, emit, info, probe, require_tool, run
24
+
25
+ SPECS: Dict[str, Dict[str, Any]] = {
26
+ "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
27
+ "shorts": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
28
+ "reels": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
29
+ "tiktok": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
30
+ "x": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
31
+ "linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
32
+ "broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
33
+ "podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
34
+ "custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
35
+ }
36
+
37
+
38
+ def measure_loudness(path: str) -> Dict[str, float]:
39
+ ffmpeg = require_tool("ffmpeg")
40
+ proc = run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], quiet=True, check=False)
41
+ m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
42
+ if not m:
43
+ return {}
44
+ d = json.loads(m.group(0))
45
+ try:
46
+ return {"lufs": float(d["input_i"]), "tp": float(d["input_tp"]), "lra": float(d["input_lra"])}
47
+ except (KeyError, ValueError):
48
+ return {}
49
+
50
+
51
+ def aspect_name(w: int, h: int) -> str:
52
+ f = Fraction(w, h)
53
+ for name, target in (("16:9", Fraction(16, 9)), ("9:16", Fraction(9, 16)), ("1:1", Fraction(1)), ("4:5", Fraction(4, 5)), ("4:3", Fraction(4, 3)), ("21:9", Fraction(21, 9))):
54
+ if abs(float(f) - float(target)) < 0.02:
55
+ return name
56
+ return f"{f.numerator}:{f.denominator}"
57
+
58
+
59
+ def main() -> int:
60
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
61
+ ap.add_argument("input")
62
+ ap.add_argument("--platform", choices=sorted(SPECS), default="youtube")
63
+ ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
64
+ ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
65
+ ap.add_argument("--lufs", type=float, help="override loudness target")
66
+ ap.add_argument("--tp", type=float, help="override true-peak ceiling")
67
+ ap.add_argument("--max-mb", type=float, help="override max file size in MB")
68
+ ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
69
+ add_common(ap)
70
+ args = ap.parse_args()
71
+ apply_common(args)
72
+
73
+ spec = dict(SPECS[args.platform])
74
+ if args.max_duration is not None:
75
+ spec["max_duration"] = args.max_duration
76
+ if args.aspect:
77
+ spec["aspects"] = [a.strip() for a in args.aspect.split(",")]
78
+ if args.lufs is not None:
79
+ spec["lufs"] = args.lufs
80
+ if args.tp is not None:
81
+ spec["tp"] = args.tp
82
+ if args.max_mb is not None:
83
+ spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
84
+
85
+ meta = probe(args.input)
86
+ v, a = meta.get("video") or {}, meta.get("audio") or {}
87
+ rows: List[Dict[str, Any]] = []
88
+
89
+ def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
90
+ rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix})
91
+
92
+ dur = meta.get("duration") or 0.0
93
+ if spec["max_duration"]:
94
+ row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "fit.py --duration N or cut.py")
95
+ else:
96
+ row("duration", "PASS", f"{dur:.2f}s", "any")
97
+
98
+ if v:
99
+ w, h = v["width"], v["height"]
100
+ if v.get("rotation") in (90, -90, 270, -270):
101
+ w, h = h, w
102
+ asp = aspect_name(w, h)
103
+ if spec["aspects"]:
104
+ row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad|crop")
105
+ else:
106
+ row("aspect", "PASS", asp, "any")
107
+ short = min(w, h)
108
+ if spec["min_height"]:
109
+ row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
110
+ fps = v.get("fps") or 0
111
+ if spec["fps_max"]:
112
+ row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30")
113
+ row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
114
+ if spec["codecs"]:
115
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"))
116
+ pf = v.get("pix_fmt") or ""
117
+ if args.platform in ("reels", "tiktok", "x", "linkedin"):
118
+ row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p")
119
+ if spec["sdr_only"] and v.get("hdr"):
120
+ row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr")
121
+ else:
122
+ tags = (v.get("color_primaries"), v.get("color_transfer"))
123
+ ok = v.get("hdr") or tags == ("bt709", "bt709") or (args.platform in ("podcast", "custom"))
124
+ row("colour", "PASS" if ok else "WARN", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709")
125
+ elif args.platform not in ("podcast", "custom"):
126
+ row("video", "FAIL", "none", "video stream", "")
127
+
128
+ size = meta.get("size_bytes") or 0
129
+ if spec["max_bytes"]:
130
+ row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
131
+
132
+ if a:
133
+ row("audio", "PASS", f"{a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz", "present")
134
+ if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
135
+ row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
136
+ if not args.no_loudness and spec["lufs"] is not None:
137
+ lm = measure_loudness(args.input)
138
+ if lm:
139
+ diff = abs(lm["lufs"] - spec["lufs"])
140
+ row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g}")
141
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
142
+ elif args.platform in ("podcast",):
143
+ row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
144
+ else:
145
+ row("audio", "WARN", "none", "audio stream", "audio.py --replace (silent uploads are often rejected)")
146
+
147
+ failed = [r for r in rows if r["status"] == "FAIL"]
148
+ warned = [r for r in rows if r["status"] == "WARN"]
149
+ if not args.json:
150
+ width = max(len(r["check"]) for r in rows)
151
+ print(f"{args.input} — {args.platform}")
152
+ for r in rows:
153
+ line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
154
+ if r["status"] != "PASS" and r["fix"]:
155
+ line += f" -> {r['fix']}"
156
+ print(line)
157
+ print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
158
+ emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=not failed)
159
+ return 1 if failed else 0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ sys.exit(main())
package/scripts/color.py CHANGED
@@ -99,7 +99,7 @@ def main() -> int:
99
99
  if proc.returncode != 0:
100
100
  # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
101
101
  info("stream copy could not rewrite tags, re-encoding")
102
- 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)
103
103
  cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
104
104
  run(cmd)
105
105
  info(f"wrote {output} (tags -> {args.retag})")
@@ -124,7 +124,7 @@ def main() -> int:
124
124
  output = args.output or default_output(args.input, "lut")
125
125
  tag = "lut"
126
126
 
127
- 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?"]
128
128
  cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
129
129
  run(cmd)
130
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/export.py CHANGED
@@ -22,7 +22,7 @@ import argparse
22
22
  import sys
23
23
  from typing import Dict, List
24
24
 
25
- from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
25
+ from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
26
26
 
27
27
  PRESETS: Dict[str, Dict] = {
28
28
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -94,6 +94,8 @@ def main() -> int:
94
94
  video = list(p["video"])
95
95
  if args.crf is not None and "-crf" in video:
96
96
  video[video.index("-crf") + 1] = str(args.crf)
97
+ if STATE["fast"] and "-preset" in video:
98
+ video[video.index("-preset") + 1] = "veryfast"
97
99
  cmd += video
98
100
  if "-r" not in video:
99
101
  cmd += cfr_args(meta)
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 STATE, 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
 
@@ -102,6 +102,8 @@ def main() -> int:
102
102
  if target <= 0:
103
103
  die("target duration must be > 0")
104
104
  if args.method == "speed":
105
+ if src_dur <= 0 and STATE["dry_run"]:
106
+ src_dur = target # planning against an intermediate that does not exist yet
105
107
  factor = src_dur / target # >1 = speed up
106
108
  if factor > args.max_speed or factor < 1 / args.max_speed:
107
109
  die(f"required speed factor {factor:.2f}x exceeds --max-speed {args.max_speed}x; use --method trim or raise the limit")
@@ -153,7 +155,7 @@ def main() -> int:
153
155
  cmd += ["-vf", ",".join(vf)]
154
156
  if af:
155
157
  cmd += ["-af", ",".join(af)]
156
- cmd += x264_args(args.crf, args.preset)
158
+ cmd += video_args(meta, args.crf, args.preset)
157
159
  cmd += cfr_args(meta, args.fps) if not args.fps else []
158
160
  if has_audio:
159
161
  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 STATE, 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"]
@@ -44,17 +44,24 @@ def main() -> int:
44
44
  if not m.get("video"):
45
45
  die(f"{p} has no video stream")
46
46
  first = metas[0]["video"]
47
- w = args.width or first["width"]
48
- h = args.height or first["height"]
49
- if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
50
- w, h = h, w
47
+ fw, fh = first["width"], first["height"]
48
+ if first.get("rotation") in (90, -90, 270, -270):
49
+ fw, fh = fh, fw
50
+ if args.width and args.height:
51
+ w, h = args.width, args.height
52
+ elif args.width:
53
+ w, h = args.width, int(round(args.width * fh / fw))
54
+ elif args.height:
55
+ w, h = int(round(args.height * fw / fh)), args.height
56
+ else:
57
+ w, h = fw, fh
51
58
  fps = args.fps or first.get("fps") or 30.0
52
59
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
53
60
  w, h = w - (w % 2), h - (h % 2)
54
61
  durs = [m.get("duration") or 0.0 for m in metas]
55
62
  d = args.duration if args.transition != "none" else 0.0
56
63
  for p, dur in zip(args.inputs, durs):
57
- if d and dur <= d * 2:
64
+ if d and dur <= d * 2 and not STATE["dry_run"]:
58
65
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
59
66
 
60
67
  cmd = ffmpeg_base()
@@ -78,8 +85,9 @@ def main() -> int:
78
85
  geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
79
86
  else:
80
87
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
88
+ pixfmt = "yuv420p10le" if (metas[0].get("video") or {}).get("hdr") else "yuv420p"
81
89
  for i in range(n):
82
- parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
90
+ parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
83
91
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
84
92
 
85
93
  if args.transition == "none":
@@ -98,7 +106,7 @@ def main() -> int:
98
106
 
99
107
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
100
108
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
101
- cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
109
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
102
110
  run(cmd)
103
111
  expected = sum(durs) - d * (n - 1)
104
112
  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: