ffmpeg-skill 1.26.0 → 2.1.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 (49) hide show
  1. package/README.md +6 -6
  2. package/SKILL.md +8 -7
  3. package/docs/contract.md +55 -46
  4. package/package.json +1 -1
  5. package/references/gotchas.md +9 -6
  6. package/references/scripts.md +21 -7
  7. package/scripts/_common/__init__.py +6 -6
  8. package/scripts/_common/decision.py +2 -2
  9. package/scripts/_common/emit.py +2 -37
  10. package/scripts/_common/probe.py +10 -6
  11. package/scripts/_common/runner.py +13 -45
  12. package/scripts/_common/timeline.py +379 -0
  13. package/scripts/_contract.py +39 -33
  14. package/scripts/background.py +1 -1
  15. package/scripts/broll.py +2 -2
  16. package/scripts/caption.py +1 -1
  17. package/scripts/check.py +3 -3
  18. package/scripts/color.py +4 -4
  19. package/scripts/crop.py +1 -1
  20. package/scripts/cut.py +1 -1
  21. package/scripts/deinterlace.py +1 -1
  22. package/scripts/denoise.py +1 -1
  23. package/scripts/export.py +6 -3
  24. package/scripts/fit.py +2 -2
  25. package/scripts/freeze.py +1 -1
  26. package/scripts/graphics.py +1 -1
  27. package/scripts/grid.py +2 -2
  28. package/scripts/insert.py +1 -1
  29. package/scripts/join.py +2 -2
  30. package/scripts/look.py +1 -1
  31. package/scripts/loop.py +1 -1
  32. package/scripts/multicam.py +2 -2
  33. package/scripts/overlay.py +1 -1
  34. package/scripts/pad.py +1 -1
  35. package/scripts/probe.py +1 -1
  36. package/scripts/proxy.py +2 -2
  37. package/scripts/redact.py +1 -1
  38. package/scripts/render.py +49 -1
  39. package/scripts/report.py +1 -1
  40. package/scripts/reverse.py +1 -1
  41. package/scripts/sequence.py +1 -1
  42. package/scripts/silence.py +1 -1
  43. package/scripts/speedramp.py +1 -1
  44. package/scripts/sphere.py +1 -1
  45. package/scripts/stabilize.py +1 -1
  46. package/scripts/straighten.py +1 -1
  47. package/scripts/sync.py +1 -1
  48. package/scripts/verify.py +2 -2
  49. package/scripts/waveform.py +1 -1
package/README.md CHANGED
@@ -194,7 +194,7 @@ These are the rules the skill file gives the agent and the code enforces.
194
194
 
195
195
  | Tool | What it does |
196
196
  |---|---|
197
- | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision (`hdr` for BT.2020 or PQ/HLG, `hdr_signal` for a real PQ/HLG/DV transfer only), colour space, rotation, every audio stream; `--analyze` flags Log footage |
197
+ | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision (`hdr` for a real PQ/HLG/DV transfer only, `bt2020_or_hdr` also for BT.2020 SDR), colour space, rotation, every audio stream; `--analyze` flags Log footage |
198
198
  | `scenes.py` | Scene changes, audio peaks, highlight proposals (`--rank-by audio` loudest, or `--rank-by duration` longest — both proxies, not "best") and a per-scene sheet; cut list for `cut.py --segments`; `--beats` measures the music's beat grid (tempo, beat times, confidence); `--shots` classifies each shot static/pan/motion by measured optical flow; `--audio-peaks` lists per-second dBFS; `--speech` reports a speech-vs-music ratio, not a classification |
199
199
  | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made; `--safe NAME` shades the zones a platform's own UI covers |
200
200
 
@@ -249,7 +249,7 @@ These are the rules the skill file gives the agent and the code enforces.
249
249
  | Tool | What it does |
250
250
  |---|---|
251
251
  | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR; `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
252
- | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--crf` (deprecated alias of `--quality`), `--fps`, `--no-audio`; not a delivery preset |
252
+ | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
253
253
  | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row |
254
254
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
255
255
 
@@ -257,7 +257,7 @@ These are the rules the skill file gives the agent and the code enforces.
257
257
 
258
258
  | Tool | What it does |
259
259
  |---|---|
260
- | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table) |
260
+ | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
261
261
  | `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout` |
262
262
  | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest/talking instead of a hand-built list, with `--min-shot` (minimum shot length) and `--edl` (write the cut list) |
263
263
  | `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
@@ -343,7 +343,7 @@ On Windows, `python3` is only on PATH if Python was installed from the Microsoft
343
343
 
344
344
  `mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up, in contract order, with `inputSchema` translated from each tool's `input_schema`. By default it lists only the core 12 (`render`, `look`, `caption`, `export`, `check`, `fit`, `cut`, `audio`, `loudness`, `graphics`, `silence`, `probe`) so a client doesn't pay context for 30 schemas it rarely calls directly; set `FFMPEG_SKILL_MCP_FULL=1` to list all 42. Every tool, listed or not, is callable through `tools/call`, which maps structured arguments to argv and runs the named script; a raw `argv` form is accepted for compatibility and marked non-canonical. `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
345
345
 
346
- `FFMPEG_SKILL_MCP_LEAN=1` in the server's environment drops `json` and `progress` from every `inputSchema`: they are transport flags the server sets itself, not tool arguments, and 2.0 drops them unconditionally. It is opt-in, independent of `FFMPEG_SKILL_MCP_FULL`, and the default `tools/list` stays byte-identical (aside from the core-12 filter) to the CLI surface the contract promises.
346
+ No `inputSchema` lists `json` or `progress`: they are transport flags the server sets itself, not tool arguments (2.0; 1.x listed them unless `FFMPEG_SKILL_MCP_LEAN=1`). A client that still sends them is not refused.
347
347
 
348
348
  The server also advertises a `prompts` capability: five canned workflow recipes an agent can ask for by name instead of composing the individual tool calls itself — `reel`, `podcast`, `multicam`, `delivery_check`, `hdr`. `prompts/list` returns each one's name, description and arguments; `prompts/get {name, arguments}` fills the matching template and returns one text message naming the actual command lines to run and what to check afterward. These are recipes, not new tool calls — every command line a prompt names is one `tools/call` (or the CLI) can already run.
349
349
 
@@ -379,7 +379,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
379
379
  - **Non-Latin text picks a font by script (1.12).** Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (`kind: input`) instead of rendering boxes. `doctor --json`'s `fonts.scripts` says which languages this machine can render; `--lang ja|ko` disambiguates Han-only text; an explicit `--font`/`--font-file` is always kept.
380
380
  - **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
381
381
  - **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
382
- - **Outputs are never overwritten silently.** An existing output path is warned about today and refused from 2.0; set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) to get the refusal now and pass `--overwrite` where a replacement is intended.
382
+ - **Outputs are never overwritten silently.** An existing output path is refused (`kind: input`, before anything runs); pass `--overwrite` where a replacement is intended.
383
383
  - **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
384
384
 
385
385
  ## FFmpeg compatibility
@@ -494,7 +494,7 @@ FFmpeg itself:
494
494
 
495
495
  ## Stability
496
496
 
497
- 1.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 2.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-1x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. What 2.0 will remove is already announced: `contract --json` lists it under `deprecated`, `--crf` prints a one-line warning where `--quality` exists, and [docs/contract.md](docs/contract.md#what-20-changes) says what a caller does today to be ready.
497
+ 2.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 3.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-2x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality`, an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`.
498
498
 
499
499
  ## Development
500
500
 
package/SKILL.md CHANGED
@@ -20,7 +20,7 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
20
20
  4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
21
21
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
22
22
  6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
23
- 7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. Set `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment: an existing output path is then refused (`kind: input`) instead of warned about, `--overwrite` the one way to say "yes, replace it". Recommended agent setting, and 2.0's default.
23
+ 7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite` is the one way to say "yes, replace it".
24
24
  8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG; the full 4x3 sheet is for whole-clip layout jobs. Not finished until `Look:` names that PNG — a probe cannot see a caption on someone's face. Audio-only jobs write `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
25
25
  - **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
26
26
  - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, which belongs to the calling agent — say what you see in one line and let them judge it.
@@ -117,8 +117,8 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
117
117
  | "pull the audio out", "give me the sound as WAV" | `audio.py input.mp4 -o input.wav` (an audio extension drops the picture; `--audio-stream 1` picks a track) |
118
118
  | "compress the voice", "limit the peaks", "gate the room noise" | `audio.py input.mp4 --compress --comp-threshold -20 --comp-ratio 4` / `--limit --limit-ceiling -1` / `--gate --gate-threshold -45` |
119
119
  | "the audio drifts out of sync over the hour" | `sync.py camera.mp4 recorder.wav --fix-drift --replace-audio` |
120
- | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; give an image or colour, nothing is fetched |
121
- | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt] [--title "..."]` — frame, captions, loudness, export and check in one command (`--list-templates`, `--write-project` to edit first) |
120
+ | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; nothing is fetched |
121
+ | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt] [--title "..."]` — frame, captions, loudness, export, check in one command (`--list-templates`; `--write-project` to edit first) |
122
122
  | "post it everywhere", "one edit for every platform" | `render.py --template all input.mp4 --cues cues.txt` (or a comma list) → one file per destination plus `<name>_pack.md` (`report.py --pack` renders the HTML) |
123
123
  | "export for YouTube / Reels / X", "a ProRes master" | `export.py input.mp4 --preset youtube\|reels\|tiktok\|shorts\|linkedin\|facebook\|x\|prores\|h265` (`--normalize` hits the loudness spec in the same call; `youtube-hdr` keeps HDR, `youtube-av1` writes AV1) |
124
124
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
@@ -133,12 +133,13 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
133
133
  | "waveform/spectrum video for this track" | `waveform.py podcast.wav -o waveform.mp4` |
134
134
  | "add black at the start" | `pad.py clip.mp4 --start 1.5` |
135
135
  | "cut to the product shot 0:12-0:16", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at`; `--audio b\|mix`) |
136
- | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; a `render.py` project spells it `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts, titled `Chapter N`, renameable |
136
+ | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; in a project: `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts (`Chapter N`) |
137
137
  | "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
138
138
  | "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
139
139
  | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
140
- | "several changes to the same edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
- | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages come from the cache (`--from STAGE` starts there) |
140
+ | "several changes to one edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
+ | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages reused (`--from STAGE` starts there) |
142
+ | "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — the cut as a timeline, nothing rendered; read `not_exported` |
142
143
  | "do this to every file in the folder", "use all the cores" | `batch.py FOLDER --recipe batch.json --jobs auto` (steps or a render project; cached) |
143
144
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` (manual) or `--switch energy` (auto-cuts to the loudest camera, `--min-shot`, `--edl`) |
144
145
  | "what's in this file", "how long is it" | `probe.py input.mp4` |
@@ -203,7 +204,7 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
203
204
 
204
205
  One line each; open the linked `references/gotchas.md` section when the job is in that area.
205
206
 
206
- - HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR, `hdr: true` is wider than `hdr_signal: true` (a real PQ/HLG/DV transfer). -> [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
207
+ - HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR; `hdr: true` is a real PQ/HLG/DV signal, `bt2020_or_hdr: true` also BT.2020 SDR. -> [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
207
208
  - Log footage (S-Log/V-Log/C-Log) is tagged SDR and looks grey: `probe.py --analyze`, then `color.py --lut` first. -> [#log-footage](references/gotchas.md#log-footage)
208
209
  - A `-c copy` cut can start on a wrong or frozen frame; `cut.py` re-encodes past a 0.5 s snap, respect it. -> [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
209
210
  - VFR phone/screen recordings: re-encodes conform to CFR, `cut.py` switches to `--accurate`; pick the rate with `fit.py --fps` when odd. -> [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.26.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.1.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -35,11 +35,12 @@ from `scripts/` on every CI run instead — a stale count fails a test rather th
35
35
  silently. (`SKILL.md` was added to that check after its "the 28 scripts" sat stale through
36
36
  twelve tool additions while the other three files were correct.)
37
37
 
38
- ## Stability guarantee (1.x)
38
+ ## Stability guarantee (2.x)
39
39
 
40
40
  1.0.0 was published on 2026-09-11 (by accident: see CHANGELOG.md's 1.0.0 entry; the number
41
- is kept rather than burned). From 1.0.3 on, the number is treated as the promise it implies.
42
- For the whole of 1.x:
41
+ is kept rather than burned). From 1.0.3 on, the number is treated as the promise it implies;
42
+ 2.0.0 (2026-09-22) is the first deliberate break, listed under "What 2.0 changed" below.
43
+ The same promise holds for the whole of 2.x:
43
44
 
44
45
  | Surface | Promise |
45
46
  |---|---|
@@ -71,36 +72,54 @@ out to be misleading) is retired in three steps, never in one:
71
72
  naming the replacement is printed to stderr when it is used, the CHANGELOG entry says
72
73
  "deprecated", and `--help` marks it `(deprecated: use ...)`.
73
74
  2. **Keep** it for at least two further minor releases or 90 days, whichever is longer.
74
- 3. **Remove** it only in the next major (2.0.0), listed in that release's CHANGELOG under
75
+ 3. **Remove** it only in the next major, listed in that release's CHANGELOG under
75
76
  "Removed", together with the version that first deprecated it.
76
77
 
77
78
  A defect fix that changes behaviour is not a deprecation: it ships in a patch with a
78
79
  CHANGELOG line, and if the old behaviour was something a caller could reasonably have relied
79
80
  on, the line says so.
80
81
 
81
- ## What 2.0 changes
82
-
83
82
  `contract --json` carries a top-level `deprecated` list, next to `contract_version`: one entry per
84
- thing 2.0.0 removes, `{"what", "since", "replacement", "removed_in", "where"}` with `where` naming
85
- the surface (`cli`, `json`, `mcp`, `behaviour`). It is the machine-readable half of the policy
86
- above, and this section is written from it. Nothing below changes behaviour in 1.x -- every old
87
- spelling keeps working until 2.0.
83
+ thing the next major removes, `{"what", "since", "replacement", "removed_in", "where"}` with `where`
84
+ naming the surface (`cli`, `json`, `mcp`, `behaviour`). It is empty in 2.0.0. `tests/test_contract.py`
85
+ refuses a package.json major bump that still lists an entry that major should remove, or that
86
+ comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's is.
87
+
88
+ **2.0.0 waived the 90-day half of step 2.** Its deprecations date from 1.10.0 (2026-09-13), so
89
+ the policy's earliest 2.0.0 was 2026-12-12; on 2026-09-22 the maintainer decided not to wait.
90
+ The other half held with room to spare: sixteen minor releases (1.11-1.26) carried every one
91
+ of them, each already had a 1.x opt-in to the 2.0 behaviour (`--quality`,
92
+ `FFMPEG_SKILL_MCP_LEAN=1`, `hdr_signal`, `FFMPEG_SKILL_NO_OVERWRITE=1`), and the one change a
93
+ caller could not have prepared for mechanically -- promoting `result_v2` -- was withdrawn
94
+ rather than shipped early (below).
95
+
96
+ ## What 2.0 changed
88
97
 
89
- | What 2.0 removes | Since | Replacement | To be ready today |
98
+ `contract --json`'s `removed` list is the machine-readable form of this table, one entry per
99
+ change with the version that deprecated it.
100
+
101
+ | What changed in 2.0.0 | Deprecated in | 2.0 behaviour | Migrating from 1.x |
90
102
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.26.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.26.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.26.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.26.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.26.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
103
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.10.0 | removed; `--quality N` defaults to what `--crf` did (18, `proxy.py` 30) | Pass `--quality` where you passed `--crf`; `--crf` is now an unknown argument |
104
+ | `json` and `progress` in the MCP `inputSchema` | 1.10.0 | left out of every schema (as `FFMPEG_SKILL_MCP_LEAN=1` did); the transport sets them itself | Nothing: a client that still sends them is not refused |
105
+ | `hdr` in `probe` meaning "BT.2020 primaries *or* a PQ/HLG transfer" | 1.10.0 | `hdr` is true only for PQ / HLG / Dolby Vision, equal to `hdr_signal` (kept); `bt2020_or_hdr` carries the 1.x meaning, and every editing tool still routes on it | Key on `bt2020_or_hdr` where you meant the old `hdr` (e.g. "needs the 10-bit path"); `hdr_format` still names `BT.2020 SDR` |
106
+ | Overwriting an existing output with only a warning | 1.10.0 | refused (`kind: input`, before anything runs, dry runs included) unless `--overwrite` | Pass `--overwrite` where a replacement is intended; `FFMPEG_SKILL_NO_OVERWRITE` no longer does anything |
107
+ | The `result_v2` preview key and `FFMPEG_SKILL_RESULT_V2` | 1.10.0 (the v1 keys it was to replace) | removed; the top-level keys are the 2.0 shape, unchanged | Read the top-level keys; `--json-brief` for a short document |
108
+
109
+ The v1 success keys were deprecated in 1.10.0 in favour of `result_v2`. 2.0 withdrew that
110
+ deprecation instead: `result_v2` decided whether a key landed in `metrics` or in `details` by its
111
+ value (a number went to `metrics`, the same key holding `null` went to `details`), so a caller
112
+ could not know where to look without knowing the value first, and every consumer of every tool's
113
+ JSON would have been rewritten for that. The flat keys, typed per tool in `output_schema`, stay.
96
114
 
97
115
  ## Skill
98
116
 
99
117
  ```json
100
118
  {
101
119
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.26.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.26.0", "execution_mode": "local", "kind": "execution",
120
+ "deprecated": [],
121
+ "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.1.0", "execution_mode": "local", "kind": "execution",
104
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -120,7 +139,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
120
139
  | Field | Meaning |
121
140
  |---|---|
122
141
  | `id`, `name`, `version`, `executable` | `ffmpeg-skill/cut`, `cut`, skill version, `scripts/cut.py` |
123
- | `examples[]` | `{prompts[], command}` (1.26.0): SKILL.md's own "User says" / "Do" table rows for this tool, machine-readable — `prompts` is the row's phrasing(s), `command` the row's `Do` text with the markdown backticks stripped. Every tool has at least one; parsed from SKILL.md at read time, so it never drifts from the table a person reads |
142
+ | `examples[]` | `{prompts[], command}` (1.21.0): SKILL.md's own "User says" / "Do" table rows for this tool, machine-readable — `prompts` is the row's phrasing(s), `command` the row's `Do` text with the markdown backticks stripped. Every tool has at least one; parsed from SKILL.md at read time, so it never drifts from the table a person reads |
124
143
  | `role` | `analysis`, `analysis_and_execution`, `execution` or `verification` (see below) |
125
144
  | `capabilities.required` | ffmpeg components the tool always needs |
126
145
  | `capabilities.optional[]` | `{capability, when}`: needed only for that flag or input |
@@ -129,7 +148,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
129
148
  | `output_schema` | what `--json` prints on stdout |
130
149
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
131
150
  | `supports_json` | whether `--json` exists |
132
- | `supports_json_brief` | whether `--json-brief` exists (1.26.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
151
+ | `supports_json_brief` | whether `--json-brief` exists (1.11.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
133
152
  | `mutates_input` | always `false`: no tool overwrites its input |
134
153
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
135
154
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -181,12 +200,13 @@ tool's job; only the artifact is skipped, including side files such as `--edl`,
181
200
  generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
182
201
  `references/scripts.md` repeat the same list; the contract is the authority.
183
202
 
184
- `--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool whose schema has
185
- `crf` (the ones that re-encode), marked `common`. They are resolved in one place
203
+ `--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
204
+ marked `common`. They are resolved in one place
186
205
  (`_common.encoder_args()`): hevc keeps an HDR source Main10 with its tags and writes 8-bit
187
206
  BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and needs a
188
- `.mov`/`.mkv` output; h264 refuses an HDR source (`kind: input`). `--quality` is the CRF scale
189
- and overrides `--crf`. Without `--codec` the encoder is what it always was (x264 for SDR, x265
207
+ `.mov`/`.mkv` output; h264 refuses an HDR source (`kind: input`). `--quality` is the CRF scale;
208
+ its default is the tool's own (18, `proxy.py` 30). 1.x also accepted `--crf` as an alias; 2.0
209
+ removed it (`export.py` keeps its own `--crf`). Without `--codec` the encoder is what it always was (x264 for SDR, x265
190
210
  Main10 for HDR), so the flags add no behaviour to a caller that does not pass them. The
191
211
  encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
192
212
  so on). `export.py` refuses `--codec`: its presets decide the codec.
@@ -435,7 +455,7 @@ given a different type):
435
455
 
436
456
  | key | tool | what it holds |
437
457
  |---|---|---|
438
- | `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.26.0) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
458
+ | `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.18.4) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
439
459
  | `tracks`, `subtitle_tracks` | `caption.py --mode mux` | one entry per subtitle stream in the output — `{index, file, language, title, codec, default, cues, kept_from_input}`; a stream the input already carried has `file: null` and `kept_from_input: true`. `subtitle_tracks` is the total. Every field describes the file as written, not as asked for: an MPEG-4 output reports `title: null` (the muxer stores none) and `default: true` on its first track (the muxer always enables it), each with a `notes` line |
440
460
  | `auto_chapters` | `metadata.py --auto-chapters` | `{source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}`. `titles` is always `"placeholder"`: the machine-readable form of "the skill did not name these". Each chapter carries its `evidence` (`start`, `silence`, `scene`, or `silence+scene` with the span, its length and the cut time) |
441
461
  | `audiogram` | `waveform.py` (every run) | `{style, background, image, position, vis_height, platform, captions, title, stages, verified}`. `background` is `"image"` or `"color"`; `verified` is true when the render probes at the asked-for frame size, frame rate and within 0.05 s of the source audio, and is `false` under `--dry-run`, where nothing was rendered to verify |
@@ -449,9 +469,10 @@ Per-tool keys added in 1.17, all additive:
449
469
  | `snap` | `cut.py --snap beats`, `render.py` | `{mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}`. `grid` is `"supported"`: points are moved only onto grid points a measured onset marks, never onto the regular grid's continuation through a silent passage. `moved` has exactly one row per in/out point given (`from`, `to`, `delta`, `snapped`, `beat_index`) — a point is never added or dropped, and `to` is always either a measured beat or the caller's own value |
450
470
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
451
471
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
472
+ | `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
452
473
  | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
453
474
 
454
- Per-tool keys added in 1.26.0, all additive:
475
+ Per-tool keys added in 1.17.1, all additive:
455
476
 
456
477
  | key | tool | what it holds |
457
478
  |---|---|---|
@@ -459,7 +480,7 @@ Per-tool keys added in 1.26.0, all additive:
459
480
  | `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
460
481
 
461
482
 
462
- Per-tool keys added in 1.26.0, all additive:
483
+ Per-tool keys added in 1.18.0, all additive:
463
484
 
464
485
  | key | tool | what it holds |
465
486
  |---|---|---|
@@ -494,15 +515,6 @@ listed step met its target. A `--dry-run` document has `verified: false` and an
494
515
  `export.py` whose written file misses the platform's loudness spec stays `completed` (the
495
516
  file is valid) with `verified: false` and the fix in `notes`, so a caller keys on one field.
496
517
 
497
- With `FFMPEG_SKILL_RESULT_V2=1` in the environment, every writing tool's success document
498
- also carries `result_v2`: a preview of the one shape 2.0 will use for every tool
499
- (issue #189). `{"schema": 2, "output", "probe", "commands", "metrics", "notes", "dropped":
500
- {"non_av_streams"}, "verified", "verification", "details"}` -- `metrics` holds the numbers a caller keys on (loudness's
501
- measurement dicts flattened, plus any numeric top-level key such as `expected_duration` or
502
- `offset_seconds`), `notes` the free text, `details` the tool's remaining keys unchanged. The
503
- 1.x keys are not moved; the environment variable only adds the key, and its absence is the
504
- default until 2.0.
505
-
506
518
  Success is decided by `verify_output` in `_common` (`scripts/_common/probe.py`), not by the
507
519
  ffmpeg exit code alone:
508
520
  the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
@@ -574,14 +586,11 @@ already applied when the ToolSpec is built.
574
586
  The `tools/list` document is deterministic (byte-identical across processes and
575
587
  identical to the translation of `contract --json`), which the tests check.
576
588
 
577
- `FFMPEG_SKILL_MCP_LEAN=1` (anything but "" or `0`) in the server's environment removes `json` and
578
- `progress` from every `inputSchema` (from `properties`, and from `required` if a tool ever made
579
- them required). They are transport flags `mcp/server.py` sets itself -- it appends `--json` for
580
- every tool but `look` and `probe` -- rather than arguments a caller chooses, and 2.0 drops them
581
- for good (see "What 2.0 changes"). The flag is opt-in and changes nothing else: without it
582
- `tools/list` carries the tool names, argument names and `required` lists the frozen 1.x snapshot
583
- pins -- descriptions may change between releases (the `--crf` deprecation mark did) -- so a lean
584
- client and a default client see the same tools with the same names.
589
+ No `inputSchema` lists `json` or `progress`: they are transport flags `mcp/server.py` sets
590
+ itself -- it appends `--json` for every tool but `look` and `probe` -- rather than arguments a
591
+ caller chooses. 1.10.0-1.26 listed them unless `FFMPEG_SKILL_MCP_LEAN=1`; 2.0 leaves them out for
592
+ good (see "What 2.0 changed"). A client that still sends either is not refused: the server maps
593
+ it like any other key.
585
594
 
586
595
  ### Default `tools/list` surface: core 12, opt-in for all 42
587
596
 
@@ -593,7 +602,7 @@ most often across the corpus in `evals/agent_prompts*.json`. Every tool -- inclu
593
602
  -- is still callable by name through `tools/call` regardless of what `tools/list` advertised; the
594
603
  contract (`ffmpeg-skill contract --json`) still describes all 42 unconditionally. Set
595
604
  `FFMPEG_SKILL_MCP_FULL=1` (anything but "" or `0`) in the server's environment to make `tools/list`
596
- return all 42, as every version before 1.26.0 did.
605
+ return all 42, as every version before 1.18.3 did.
597
606
 
598
607
  ### `prompts` capability: five workflow recipes
599
608
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.26.0",
3
+ "version": "2.1.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -9,14 +9,17 @@ detail, not an aside.
9
9
 
10
10
  ### HDR and colour
11
11
  SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py` reports
12
- `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing script keeps
13
- the output HDR (HEVC Main10, source colour tags) so nothing is silently
12
+ `bt2020_or_hdr: true` (HDR10/PQ, HLG, Dolby Vision, or BT.2020 SDR), every editing script keeps
13
+ the output on the 10-bit path (HEVC Main10, source colour tags) so nothing is silently
14
14
  flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
15
15
  `color.py --to-sdr` first for SDR-only destinations, LUT work or H.264
16
- deliverables. `hdr: true` counts BT.2020 primaries too, so it is also true for a
17
- wide-gamut SDR file; `hdr_signal: true` is the narrower fact — a real PQ / HLG /
18
- Dolby Vision transfer — and `hdr_format` names the in-between case
19
- (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR input.
16
+ deliverables. Since 2.0 `hdr: true` (and its 1.9 twin `hdr_signal`) is the narrow
17
+ fact — a real PQ / HLG / Dolby Vision transfer; `bt2020_or_hdr` also counts a
18
+ wide-gamut SDR file (1.x's `hdr`), and `hdr_format` names that in-between case
19
+ (`BT.2020 SDR`). "Not HDR" is not "ready for a BT.709 deliverable": a BT.2020 SDR file
20
+ still carries wide-gamut primaries, so an SDR-only client or platform expecting BT.709 needs
21
+ `color.py --to-sdr` first -- `export.py` only retags BT.709, it does not convert (eval 24 h1).
22
+ `export.py` platform presets are SDR and warn on HDR input.
20
23
  iPhone `.mov` files also carry timecode/metadata tracks; scripts map only the
21
24
  first audio track, so extra tracks are dropped on re-encode. Keep ProRes masters
22
25
  at source colour: `export.py --preset prores` does not retag.
@@ -1,6 +1,6 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, overrides `--crf`; up to 63 for av1; ignored by prores). `--crf` is deprecated since 1.10.0 (it warns on stderr and is removed in 2.0): use `--quality`, except on `export.py`, whose `--crf` is not an alias and stays. With `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment, any tool refuses (`kind: input`) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither flag; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Time grammar (every time-taking flag, 1.9)
6
6
 
@@ -56,9 +56,9 @@ between tools.
56
56
  ```
57
57
  probe.py INPUT... [--compact] [--field duration|video.fps|...]
58
58
  ```
59
- JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,hdr_format}`
60
- (`hdr_signal` is true only for a PQ / HLG transfer or Dolby Vision; `hdr` also counts
61
- BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR" -- 2.0 renames),
59
+ JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,bt2020_or_hdr,hdr_format}`
60
+ (`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
61
+ `bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
62
62
  `audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
63
63
 
64
64
  ### cut.py — cut / join segments
@@ -569,7 +569,21 @@ render.py --init project.json # starter file
569
569
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
570
570
  render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
571
571
  render.py plan.json # execute a plan written by <tool> --plan plan.json
572
- ```
572
+ render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
573
+ ```
574
+ `--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
575
+ rendering it: `.fcpxml` (FCPXML 1.10: Final Cut Pro, DaVinci Resolve), `.edl` (CMX 3600:
576
+ Premiere, Resolve, Avid) or `.otio` (OpenTimelineIO JSON: Resolve natively, others through
577
+ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
578
+ `LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
579
+ timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
580
+ track; inline `chapters` are markers. Everything else in the project -- captions, graphics,
581
+ overlays, the silence cut, fit, audio processing, loudness, export -- is listed in the result's
582
+ `timeline.not_exported` and on stderr, never dropped silently. Media paths are absolute file
583
+ URLs to the sources, nothing is copied or encoded, and the existing-output rule applies
584
+ (`--overwrite` to replace). The timeline follows the project's numbers exactly; a render of
585
+ the same project can come out a few frames longer, because `join.py` offsets its crossfades
586
+ by each part's container duration, audio included.
573
587
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
574
588
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
575
589
  head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
@@ -1408,10 +1422,10 @@ neither.
1408
1422
 
1409
1423
  ### proxy.py — low-bitrate proxy for analysis/preview
1410
1424
  ```
1411
- proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
1425
+ proxy.py INPUT [--width W | --scale F] [--quality N] [--fps N] [--no-audio] [-o OUT]
1412
1426
  ```
1413
1427
  Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
1414
- factor, re-encodes at a proxy-grade `--crf` (default 30) with the fastest
1428
+ factor, re-encodes at a proxy-grade `--quality` (default 30) with the fastest
1415
1429
  x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
1416
1430
  run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
1417
1431
  track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
@@ -53,7 +53,7 @@ for _stream in (sys.stdout, sys.stderr):
53
53
 
54
54
  from _common.runner import (
55
55
  add_common, apply_common, _check_existing_output, _check_no_overwrite_input, _check_output_path, child_args,
56
- child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, _CRF_DEFAULT, DEFAULT_TIMEOUT,
56
+ child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
57
57
  _DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
58
58
  ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, _FFMPEG_VERSION,
59
59
  ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
@@ -64,7 +64,7 @@ from _common.runner import (
64
64
  )
65
65
  from _common.emit import (
66
66
  _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, _plan_at_exit, _plan_inputs, _PLAN_STRIP,
67
- PLAN_VERSION, print_json, _result_v2, _set_current_ctx, _V2_HANDLED, write_plan
67
+ PLAN_VERSION, print_json, _set_current_ctx, write_plan
68
68
  )
69
69
  from _common.probe import (
70
70
  analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
@@ -144,7 +144,7 @@ class _Facade(_types.ModuleType):
144
144
  # own copy is the import-time binding and would read stale. Reads of these names go to the
145
145
  # defining module (audit 14, P1-1); every other name is a plain re-export of the same object.
146
146
  _LIVE = {
147
- "_FFMPEG_VERSION": "runner", "_CRF_DEFAULT": "runner", "_SIGNALS_INSTALLED": "runner",
147
+ "_FFMPEG_VERSION": "runner", "_SIGNALS_INSTALLED": "runner",
148
148
  "_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_CURRENT_CTX": "emit",
149
149
  }
150
150
 
@@ -181,7 +181,7 @@ __all__ = [
181
181
  "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
182
182
  "char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
183
183
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
184
- "concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
184
+ "concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
185
185
  "filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
186
186
  "FILLER_MAX_WORD", "FILLER_MIN_GAP", "FILLER_PAD", "normalise_filler_token",
187
187
  "beat_grid", "snap_points", "BEAT_MIN_CONFIDENCE", "BEAT_ONSET_K", "BEAT_OCTAVE_MARGIN",
@@ -205,13 +205,13 @@ __all__ = [
205
205
  "_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
206
206
  "place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
207
207
  "print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
208
- "_remember_output", "require_tool", "resolve_emoji_assets", "_result_v2", "rms_envelope", "run", "run_analysis",
208
+ "_remember_output", "require_tool", "resolve_emoji_assets", "rms_envelope", "run", "run_analysis",
209
209
  "decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
210
210
  "_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
211
211
  "_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
212
212
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote",
213
213
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
214
- "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "_V2_HANDLED", "validate_color", "verify_output",
214
+ "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
215
215
  "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
216
216
  "_x264_raw", "_ZWJ",
217
217
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
@@ -230,7 +230,7 @@ def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any
230
230
  it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
231
231
  """
232
232
  v = (meta or {}).get("video") or {}
233
- hdr = bool(v.get("hdr"))
233
+ hdr = bool(v.get("bt2020_or_hdr"))
234
234
  cs = v.get("color_space") or "bt2020nc"
235
235
  prim = v.get("color_primaries") or "bt2020"
236
236
  trc = v.get("color_transfer") or "arib-std-b67"
@@ -298,7 +298,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
298
298
  if STATE.codec:
299
299
  return encoder_args(STATE.codec, crf, preset, meta)
300
300
  v = (meta or {}).get("video") or {}
301
- if not v.get("hdr"):
301
+ if not v.get("bt2020_or_hdr"):
302
302
  return x264_args(crf, preset)
303
303
  cs = v.get("color_space") or "bt2020nc"
304
304
  prim = v.get("color_primaries") or "bt2020"
@@ -89,8 +89,6 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
89
89
  doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
90
90
  doc["verification"] = steps
91
91
  doc.update(extra)
92
- if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
93
- doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
94
92
  if ctx.plan:
95
93
  doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
96
94
  print_json(_brief(doc, meta) if ctx.json_brief else doc)
@@ -101,8 +99,8 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
101
99
 
102
100
 
103
101
  # Keys the brief document replaces or drops: the full probe (summarised), the command lines
104
- # (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
105
- _BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
102
+ # (counted) and the per-step verification list (its verdict stays as `verified`).
103
+ _BRIEF_DROP = ("probe", "commands", "verification")
106
104
 
107
105
 
108
106
  def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
@@ -245,39 +243,6 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
245
243
  return path
246
244
 
247
245
 
248
- _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
249
-
250
-
251
- def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
252
- """The 2.0 success-document shape, previewed in 1.x as a parallel `result_v2` key when
253
- FFMPEG_SKILL_RESULT_V2=1 (issue #189 B). Every tool gets the same six slots: `output`,
254
- `probe`, `commands`, `metrics` (numbers a caller keys on: loudness's `result`/`measured`
255
- dicts flattened, plus every top-level numeric extra such as `expected_duration` or
256
- `offset_seconds`), `notes` (free text), `dropped` (what did not make it into the output),
257
- and `details` (the tool's remaining extras, unchanged). The 1.x keys stay where they are;
258
- this key is additive and its shape is what 2.0 promotes to the top level."""
259
- metrics: Dict[str, Any] = {}
260
- for key in ("measured", "result"):
261
- if isinstance(extra.get(key), dict):
262
- metrics.update(extra[key])
263
- for key, value in extra.items():
264
- if key not in _V2_HANDLED and isinstance(value, (int, float)) and not isinstance(value, bool):
265
- metrics[key] = value
266
- notes = extra.get("notes")
267
- return {
268
- "schema": 2,
269
- "output": output,
270
- "probe": meta or None,
271
- "commands": list(STATE.commands),
272
- "metrics": metrics,
273
- "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
274
- "dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
275
- "verified": bool(extra.get("verified", False)),
276
- "verification": list(extra.get("verification") or []),
277
- "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
278
- }
279
-
280
-
281
246
  def print_json(obj: Any) -> None:
282
247
  sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
283
248