ffmpeg-skill 1.9.1 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,13 +153,13 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
153
153
 
154
154
  ## Tools
155
155
 
156
- 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
156
+ 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, `--plan FILE` (a dry run written as a plan `render.py` executes later), non-zero exit and a reason on stderr on failure. Every re-encoding tool takes `--codec h264|hevc|av1|prores` and `--quality N` (1.8), and every time flag takes seconds, `mm:ss`, `hh:mm:ss.fff` or SMPTE `hh:mm:ss:ff` with an optional `@fps` suffix (1.9).
157
157
 
158
158
  **Analysis and inspection**
159
159
 
160
160
  | Tool | What it does |
161
161
  |---|---|
162
- | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision, colour space, rotation, every audio stream; `--analyze` flags Log footage |
162
+ | `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 |
163
163
  | `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` |
164
164
  | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made |
165
165
 
@@ -213,8 +213,8 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
213
213
 
214
214
  | Tool | What it does |
215
215
  |---|---|
216
- | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `x`, `prores`, `h265`, `gif`, all tagged BT.709 |
217
- | `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--crf`, `--fps`, `--no-audio`; not a delivery preset |
216
+ | `export.py` | Presets `youtube`, `youtube4k`, `reels`, `x`, `prores`, `h265`, `gif`, all tagged BT.709; `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
217
+ | `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 |
218
218
  | `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, broadcast and podcast specs, with the fix for each failure and a `format` / `judgement` kind per row |
219
219
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run |
220
220
 
@@ -293,6 +293,8 @@ The contract is generated from the code that runs, not maintained beside it. For
293
293
  | `mutates_input` | always `false` |
294
294
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
295
295
 
296
+ Next to the tool list the document carries a top-level `deprecated` list (1.10): what 2.0.0 removes, since when, the replacement and the surface it lives on. `docs/contract.md` "What 2.0 changes" is written from it.
297
+
296
298
  `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
297
299
 
298
300
  ### MCP
@@ -305,6 +307,8 @@ On Windows, `python3` is only on PATH if Python was installed from the Microsoft
305
307
 
306
308
  `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: the same 42 names, the same order, and `inputSchema` translated from each tool's `input_schema`. `tools/call` 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.
307
309
 
310
+ `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, so the default `tools/list` stays byte-identical to the CLI surface the contract promises.
311
+
308
312
  ### Capability detection
309
313
 
310
314
  ```bash
@@ -329,6 +333,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
329
333
  - **Non-Latin captions need a font with the glyphs.** Without one you get boxes, not an error. Name it (`caption.py --font "Noto Sans CJK JP"`) or point at the file (`overlay.py --font-file /path/to/NotoSansCJK-Regular.ttc`).
330
334
  - **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`.
331
335
  - **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`.
336
+ - **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.
332
337
  - **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.
333
338
 
334
339
  ## FFmpeg compatibility
@@ -355,6 +360,8 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
355
360
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
356
361
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
357
362
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
363
+ | **108 / 108** | 1.10.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + focused Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, report format 108/108 by both graders (the harness now names the five labels), user's language 105/108 (every Japanese request in Japanese; 3 English requests drifted to Spanish or Portuguese), visual check 21/24, trigger set 22/22; real-device corpus 101/101 steps PASS. Details in `evals/results/iteration-10.json` |
364
+ | **108 / 108** | 1.9.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + independent Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 23/26, user's language 105/108 (every Japanese request answered in Japanese; 3 English requests drifted to Spanish), report format 108/108 by regex (65/108 by the stricter grader, which now counts any missing label), trigger set 22/22; 12 of 15 platform jobs were one encode and `render.py` rendered once in 3/3 (was 1/3). The 1.9.0 time grammar was not used by any agent. Details in `evals/results/iteration-9.json` |
358
365
  | **108 / 108** | 1.8.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 22 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 22/24, report format 108/108 by regex (91/108 by the stricter grader: 'What/how:' in place of Steps:), user's language 98/108 by the stricter grader (Japanese labels-only reports counted), trigger set 22/22; 13 of 14 platform exports used `--normalize` and platform jobs went from three encodes to one; r04/f01 now answered in the request's language 5/6 (was 0/6). Details in `evals/results/iteration-8.json` |
359
366
  | **108 / 108** | 1.7.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 24 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 25/25, report format 108/108 by regex (104/108 by the stricter grader), user's language 101/108 (six English refusals answered in Spanish or Portuguese, one Japanese request in English); trigger set 22/22. Iteration-6 fixes held (fade-in only, Japanese audio trims, music no longer shortens the video). Details in `evals/results/iteration-7.json` |
360
367
  | **36 / 36** | 1.4.15 re-run (2026-09-12, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, report format 25/25, user's language 9/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3, one Japanese report with English labels; trigger set 22/22. Both iteration-5 defects gone (no raw ffmpeg fallback, music no longer shortens the video). Details in `evals/results/iteration-6.json` |
@@ -417,7 +424,7 @@ FFmpeg itself:
417
424
 
418
425
  ## Stability
419
426
 
420
- 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.
427
+ 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.
421
428
 
422
429
  ## Development
423
430
 
package/SKILL.md CHANGED
@@ -65,9 +65,16 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
65
65
  failure, and the report says so with the script's error message.
66
66
  7. **Keep the user's originals.** Never overwrite the source file. Write new
67
67
  files next to the input or where the user asked.
68
+ Set `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment you run these scripts
69
+ in: an output path that already exists is then refused (`kind: input`)
70
+ instead of warned about, and `--overwrite` stays the one way to say "yes,
71
+ replace it". It is the recommended agent setting because an agent picking
72
+ output names cannot see which files the user already cares about — and it
73
+ is what 2.0 does by default.
68
74
  8. **Look at the picture.** Whenever the picture changed (captions, overlays,
69
75
  graphics, crop/pad, resize, colour, transitions, a `join.py` that scaled or
70
- padded a clip to the first clip's frame) run `look.py OUTPUT`
76
+ padded a clip to the first clip's frame, a `color.py --to-sdr` that tone-maps
77
+ an HDR source) run `look.py OUTPUT`
71
78
  (contact sheet) or `look.py OUTPUT --at T`, view the PNG. The job is not
72
79
  finished until the report's `Look:` line names that PNG; a probe alone
73
80
  cannot see a caption sitting on someone's face. Audio-only jobs (sync,
@@ -123,6 +130,10 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
123
130
 
124
131
  ## Request → script
125
132
 
133
+ This table and `doctor --json`'s `tools` list are the source of truth for what exists: name only a script you have seen in one of them, never a plausible-sounding one (there is no `doctor.py`, no `trim.py`, no `subtitle.py`).
134
+
135
+ Timestamp flags -- `--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files -- take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff`, with `@fps` naming the rate (`00:01:02:15@29.97`); tolerance-style flags that are a length rather than a point in time (`--min-silence`, `--margin`, `--min-keep`, `--fade`) are plain seconds. Use the timecode forms when the user pastes an editor's timecode list or an NLE cue sheet, so nothing is converted by hand on the way in.
136
+
126
137
  | User says | Do |
127
138
  |-----------|----|
128
139
  | "what's in this file", "how long is it", "is it 4K" | `probe.py input.mp4` |
@@ -243,7 +254,7 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
243
254
 
244
255
  ## Report format
245
256
 
246
- Reply in the language the request itself is written in: the language of the user's own sentences, not a language the request talks about (an English request for Spanish subtitles gets an English report) and not the language of a tool's error text or of the file names. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English, and never drift into another language because the job is short or the report is a failure: a one-line "file does not exist" is written in the request's language too. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
257
+ Reply in the language the request itself is written in: the language of the user's own sentences, not a language the request talks about (a request asking for subtitles in some other language is still answered in the language it was written in) and not the language of a tool's error text or of the file names. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English, and never drift into another language because the job is short or the report is a failure: a one-line "file does not exist" is written in the request's language too. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
247
258
 
248
259
  Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
249
260
 
@@ -319,7 +330,10 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
319
330
  script keeps the output HDR (HEVC Main10, source colour tags) so nothing is
320
331
  silently flattened. Decide with the user: keep HDR (fine for YouTube/phones)
321
332
  or run `color.py --to-sdr` first for SDR-only destinations, LUT work or
322
- H.264 deliverables. `export.py` platform presets are SDR and warn on HDR
333
+ H.264 deliverables. `hdr: true` counts BT.2020 primaries too, so it is also
334
+ true for a wide-gamut SDR file; `hdr_signal: true` is the narrower fact —
335
+ a real PQ / HLG / Dolby Vision transfer — and `hdr_format` names the
336
+ in-between case (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR
323
337
  input. iPhone `.mov` files also carry timecode/metadata tracks; scripts map
324
338
  only the first audio track, so extra tracks are dropped on re-encode.
325
339
  For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
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.9.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.10.1`) | 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`
@@ -78,12 +78,29 @@ A defect fix that changes behaviour is not a deprecation: it ships in a patch wi
78
78
  CHANGELOG line, and if the old behaviour was something a caller could reasonably have relied
79
79
  on, the line says so.
80
80
 
81
+ ## What 2.0 changes
82
+
83
+ `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.
88
+
89
+ | What 2.0 removes | Since | Replacement | To be ready today |
90
+ |---|---|---|---|
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.10.1 | `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.10.1 | `--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.10.1 | 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.10.1 | `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.10.1 | `--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 |
96
+
81
97
  ## Skill
82
98
 
83
99
  ```json
84
100
  {
85
101
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.9.1", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.10.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.10.1", "execution_mode": "local", "kind": "execution",
87
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -404,6 +421,15 @@ already applied when the ToolSpec is built.
404
421
  The `tools/list` document is deterministic (byte-identical across processes and
405
422
  identical to the translation of `contract --json`), which the tests check.
406
423
 
424
+ `FFMPEG_SKILL_MCP_LEAN=1` (anything but "" or `0`) in the server's environment removes `json` and
425
+ `progress` from every `inputSchema` (from `properties`, and from `required` if a tool ever made
426
+ them required). They are transport flags `mcp/server.py` sets itself -- it appends `--json` for
427
+ every tool but `look` and `probe` -- rather than arguments a caller chooses, and 2.0 drops them
428
+ for good (see "What 2.0 changes"). The flag is opt-in and changes nothing else: without it
429
+ `tools/list` carries the tool names, argument names and `required` lists the frozen 1.x snapshot
430
+ pins -- descriptions may change between releases (the `--crf` deprecation mark did) -- so a lean
431
+ client and a default client see the same tools with the same names.
432
+
407
433
  ## Consuming the contract from an agent
408
434
 
409
435
  A planning agent (for example video-production-agent's SkillRegistry) can:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.9.1",
3
+ "version": "1.10.1",
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",
@@ -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`, `--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). `--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`, `--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`).
4
4
 
5
5
  ## Time grammar (every time-taking flag, 1.9)
6
6
 
@@ -406,7 +406,9 @@ and the project has no `loudness` stage; `"normalize": false` opts out.
406
406
 
407
407
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
408
408
  captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
409
- CLI flags of each script (see the docstring). Use it whenever an edit has
409
+ CLI flags of each script (see the docstring); a key `render.py` does not read -- at
410
+ the top level or in any stage/clip object -- is refused (`kind: input`) naming the
411
+ key and the nearest valid one, never silently ignored. Use it whenever an edit has
410
412
  more than two steps or the user is likely to ask for changes: edit the JSON,
411
413
  re-render, and the result is reproducible. `--dry-run --json` prints the
412
414
  complete command plan for review.
@@ -166,7 +166,7 @@ def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
166
166
  parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
167
167
 
168
168
 
169
- def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
169
+ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None, **extra: Any) -> "None":
170
170
  """Exit with a message. Under --json also print a machine-readable failure document
171
171
  (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
172
172
 
@@ -176,9 +176,12 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
176
176
  `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
177
177
  read a failed delivery as a success."""
178
178
  hint = extra.pop("hint", None)
179
- STATE.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
179
+ ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
180
+ _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
181
+ ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
182
+ STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
180
183
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
181
- if STATE.json:
184
+ if ctx.json:
182
185
  doc: Dict[str, Any] = {
183
186
  "status": "failed", "exit_code": code,
184
187
  "error": {
@@ -186,7 +189,7 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
186
189
  "code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
187
190
  "retryable": ERROR_RETRYABLE,
188
191
  },
189
- "commands": list(STATE.commands),
192
+ "commands": list(ctx.commands),
190
193
  }
191
194
  if hint:
192
195
  doc["error"]["hint"] = hint
@@ -195,9 +198,10 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
195
198
  sys.exit(code)
196
199
 
197
200
 
198
- def info(msg: str) -> None:
201
+ def info(msg: str, ctx: "Optional[Context]" = None) -> None:
199
202
  # under --dry-run nothing is written; do not let scripts claim otherwise
200
- if msg.startswith("wrote ") and STATE.dry_run:
203
+ ctx = ctx or STATE
204
+ if msg.startswith("wrote ") and ctx.dry_run:
201
205
  msg = "[dry-run] would write " + msg[len("wrote "):]
202
206
  sys.stderr.write(f"{msg}\n")
203
207
 
@@ -257,6 +261,15 @@ class Context:
257
261
 
258
262
  STATE = Context()
259
263
 
264
+ # The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
265
+ # here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
266
+ _CURRENT_CTX: "Optional[Context]" = None
267
+
268
+
269
+ def _set_current_ctx(ctx: "Context") -> None:
270
+ global _CURRENT_CTX
271
+ _CURRENT_CTX = ctx
272
+
260
273
 
261
274
  def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
262
275
  """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
@@ -274,6 +287,21 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
274
287
  g.add_argument("--plan", metavar="FILE",
275
288
  help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
276
289
  if codec and "--crf" in ap._option_string_actions:
290
+ # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
291
+ # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
292
+ # here, once, rather than in each re-encoding tool's own parser.
293
+ crf = ap._option_string_actions["--crf"]
294
+ # The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
295
+ # spelling argparse accepts, including the --cr / --c abbreviations) from the default;
296
+ # apply_common() puts _CRF_DEFAULT back when the flag was absent.
297
+ global _CRF_DEFAULT
298
+ _CRF_DEFAULT = crf.default
299
+ crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
300
+ crf.default = None
301
+ if "deprecated" not in (crf.help or ""):
302
+ # the nine tools that declare --crf with no help string used to fall through this and
303
+ # never show the mark at all (review 9)
304
+ crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
277
305
  # only the tools that re-encode (they declare --crf before add_common): one encoder choice
278
306
  # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
279
307
  g.add_argument("--codec", choices=CODECS, default=None,
@@ -282,7 +310,18 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
282
310
  help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
283
311
 
284
312
 
313
+ # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
314
+ _CRF_DEFAULT: Optional[int] = None
315
+
316
+
285
317
  def apply_common(args: "argparse.Namespace") -> None:
318
+ # Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
319
+ # whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
320
+ # an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
321
+ # prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
322
+ crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
323
+ if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
324
+ args.crf = _CRF_DEFAULT
286
325
  STATE.plan = getattr(args, "plan", None) or None
287
326
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
288
327
  if STATE.plan:
@@ -316,6 +355,11 @@ def apply_common(args: "argparse.Namespace") -> None:
316
355
  die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
317
356
  hint="give -o NAME.mov")
318
357
  crf = getattr(args, "crf", None)
358
+ # The warning the deprecation policy asks for, only when the caller typed the flag (see
359
+ # crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
360
+ # --crf is not an alias and is not deprecated: warn only where --quality exists.
361
+ if crf is not None and crf_explicit:
362
+ info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
319
363
  top = 63 if STATE.codec == "av1" else 51
320
364
  if crf is not None and not 0 <= int(crf) <= top:
321
365
  die(f"--crf must be between 0 and {top} ({'SVT-AV1' if STATE.codec == 'av1' else 'x264/x265'} scale; 18 is visually lossless), got {crf}")
@@ -381,13 +425,18 @@ def _unwatch(proc: subprocess.Popen) -> None:
381
425
  _CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
382
426
 
383
427
 
384
- def emit(output: Optional[str], **extra: Any) -> None:
385
- """Final stdout line: the output path, or a JSON document with --json."""
428
+ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
429
+ """Final stdout line: the output path, or a JSON document with --json.
430
+
431
+ `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
432
+ omitted, every read falls back to the process-global STATE as before."""
433
+ ctx = ctx or STATE
434
+ _set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
386
435
  meta: Dict[str, Any] = {}
387
- if output and not STATE.dry_run:
436
+ if output and not ctx.dry_run:
388
437
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
389
- if STATE.json:
390
- doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
438
+ if ctx.json:
439
+ doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
391
440
  if meta:
392
441
  doc["probe"] = meta
393
442
  # What this tool itself verified about its artifact (issue #189 C, "verify as part of the
@@ -397,18 +446,18 @@ def emit(output: Optional[str], **extra: Any) -> None:
397
446
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
398
447
  # keep status completed and say verified: false, so a caller keys on one field.
399
448
  steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
400
- if output and not STATE.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
449
+ if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
401
450
  steps.insert(0, {"step": "exists", "ok": True})
402
- doc["verified"] = not STATE.dry_run and bool(steps) and all(s.get("ok") for s in steps)
451
+ doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
403
452
  doc["verification"] = steps
404
453
  doc.update(extra)
405
454
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
406
455
  doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
407
- if STATE.plan:
408
- doc["plan"] = write_plan(STATE.plan, output, extra)
456
+ if ctx.plan:
457
+ doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
409
458
  print_json(doc)
410
- elif STATE.plan:
411
- print(write_plan(STATE.plan, output, extra))
459
+ elif ctx.plan:
460
+ print(write_plan(ctx.plan, output, extra, ctx=ctx))
412
461
  elif output:
413
462
  print(output)
414
463
 
@@ -418,9 +467,10 @@ _PLAN_STRIP = ("--plan", "--dry-run", "--json")
418
467
 
419
468
 
420
469
  def _plan_at_exit() -> None:
421
- if STATE.plan and not STATE.plan_written:
470
+ ctx = _CURRENT_CTX or STATE
471
+ if ctx.plan and not ctx.plan_written:
422
472
  try:
423
- write_plan(STATE.plan, None, {})
473
+ write_plan(ctx.plan, None, {}, ctx=ctx)
424
474
  except SystemExit:
425
475
  pass
426
476
 
@@ -443,13 +493,13 @@ def fingerprint(path: str) -> Dict[str, Any]:
443
493
  return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
444
494
 
445
495
 
446
- def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
496
+ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
447
497
  """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
448
498
  existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
449
499
  inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
450
500
  import shlex
451
501
  seen: List[str] = []
452
- for a in list(argv) + list(STATE.plan_inputs):
502
+ for a in list(argv) + list((ctx or STATE).plan_inputs):
453
503
  if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
454
504
  seen.append(a)
455
505
  for line in commands:
@@ -463,10 +513,14 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]
463
513
  return seen
464
514
 
465
515
 
466
- def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
516
+ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
467
517
  """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
468
- how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C)."""
518
+ how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
519
+
520
+ `ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
521
+ they were given); omitted, it is the process-global STATE as before."""
469
522
  import datetime
523
+ ctx = ctx or STATE
470
524
  argv = [a for a in sys.argv[1:]]
471
525
  cleaned: List[str] = []
472
526
  skip = False
@@ -495,8 +549,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
495
549
  "tool": tool,
496
550
  "argv": cleaned,
497
551
  "cwd": os.getcwd(),
498
- "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
499
- "commands": list(STATE.commands),
552
+ "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
553
+ "commands": list(ctx.commands),
500
554
  "output": os.path.abspath(output) if output else None,
501
555
  "verify": verify,
502
556
  "notes": list(extra.get("notes") or []),
@@ -509,8 +563,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
509
563
  os.replace(tmp, path)
510
564
  except OSError as exc:
511
565
  die(f"cannot write plan {path}: {exc}", kind="output")
512
- STATE.plan_written = True
513
- info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
566
+ ctx.plan_written = True
567
+ info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
514
568
  return path
515
569
 
516
570
 
@@ -840,7 +894,7 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
840
894
  return list(cmd[:-1]) + [tmp], output, tmp
841
895
 
842
896
 
843
- def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
897
+ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
844
898
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
845
899
 
846
900
  ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
@@ -848,16 +902,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
848
902
  with a progress readout under --progress. ffprobe and other tools always run. An output
849
903
  path that already exists is written through a temp file and replaced only on success
850
904
  (see _stage_existing_output), so a failed run never costs the caller the file that was there.
905
+
906
+ `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
907
+ omitted, the commands and flags are read from the process-global STATE as before.
851
908
  """
909
+ ctx = ctx or STATE
852
910
  is_ffmpeg = _is_ffmpeg(cmd)
853
911
  if is_ffmpeg:
854
912
  _check_no_overwrite_input(cmd)
855
913
  _check_output_path(cmd)
856
914
  _check_existing_output(cmd)
857
- STATE.commands.append(_cmdline(cmd))
915
+ ctx.commands.append(_cmdline(cmd))
858
916
  if not quiet:
859
- info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
860
- if STATE.dry_run and is_ffmpeg:
917
+ info(("[dry-run] $ " if ctx.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd), ctx=ctx)
918
+ if ctx.dry_run and is_ffmpeg:
861
919
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
862
920
  with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
863
921
  exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
@@ -866,7 +924,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
866
924
  retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
867
925
  if retry is not None:
868
926
  info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
869
- STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
927
+ ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
870
928
  proc = _execute(retry)
871
929
  elif "not divisible by 2" in (proc.stderr or ""):
872
930
  die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
@@ -1468,7 +1526,11 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
1468
1526
  raise ValueError(f"bad time: {value}")
1469
1527
  total = 0.0
1470
1528
  for part in parts:
1471
- total = total * 60 + float(part)
1529
+ try:
1530
+ total = total * 60 + float(part)
1531
+ except ValueError:
1532
+ # not the interpreter's "could not convert string to float: 'zz'" (review 9)
1533
+ raise ValueError(f"'{value}': not a time")
1472
1534
  return total
1473
1535
 
1474
1536
 
@@ -342,12 +342,15 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
342
342
  if isinstance(action, argparse._HelpAction):
343
343
  continue
344
344
  prop: Dict[str, Any] = _json_type(action)
345
+ # add_common() parks a deprecated --crf's default aside (so an explicit flag is
346
+ # distinguishable from the default); the schema still advertises the real one
347
+ default = getattr(action, "deprecated_default", action.default)
345
348
  if action.help and action.help != argparse.SUPPRESS:
346
- prop["description"] = action.help % {"default": action.default} if "%(default)" in action.help else action.help
349
+ prop["description"] = action.help % {"default": default} if "%(default)" in action.help else action.help
347
350
  if action.choices:
348
351
  prop["enum"] = list(action.choices)
349
- if action.default not in (None, False, argparse.SUPPRESS):
350
- prop["default"] = action.default
352
+ if default not in (None, False, argparse.SUPPRESS):
353
+ prop["default"] = default
351
354
  if action.option_strings:
352
355
  prop["cli"] = list(action.option_strings)
353
356
  if action.required:
@@ -947,7 +950,35 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
947
950
  # ----------------------------------------------------------------------------- MCP derivation
948
951
  # tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
949
952
  # does not append --json for them (stated in invocation.structured.argument_mapping.json)
953
+ # ----------------------------------------------------------------------------- deprecations
954
+ # What 2.0.0 removes, announced here per docs/contract.md's three-step deprecation policy:
955
+ # step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
956
+ # removes it in the major. `where` says which surface a caller sees it on. docs/contract.md's
957
+ # "What 2.0 changes" section is written from this list.
958
+ DEPRECATED: List[Dict[str, str]] = [
959
+ {"what": "top-level per-tool keys next to result_v2 in a success document (output, probe, commands, verified, verification and each tool's own keys)",
960
+ "since": "1.10.0", "replacement": "result_v2 (FFMPEG_SKILL_RESULT_V2=1 today; the only shape in 2.0)",
961
+ "removed_in": "2.0.0", "where": "json"},
962
+ {"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
963
+ "since": "1.10.0", "replacement": "--quality N (same CRF scale, codec-neutral)",
964
+ "removed_in": "2.0.0", "where": "cli"},
965
+ {"what": "json and progress in the MCP inputSchema (they are CLI transport flags, not tool arguments)",
966
+ "since": "1.10.0", "replacement": "nothing: the MCP transport sets them itself (FFMPEG_SKILL_MCP_LEAN=1 drops them today)",
967
+ "removed_in": "2.0.0", "where": "mcp"},
968
+ {"what": "probe's hdr meaning BT.2020 primaries or a PQ/HLG transfer",
969
+ "since": "1.10.0", "replacement": "hdr_signal (true only for PQ / HLG / Dolby Vision); in 2.0 hdr takes that meaning and hdr_format keeps naming the BT.2020 SDR case",
970
+ "removed_in": "2.0.0", "where": "json"},
971
+ {"what": "overwriting an existing output without --overwrite (warned, not refused)",
972
+ "since": "1.10.0", "replacement": "--overwrite, or FFMPEG_SKILL_NO_OVERWRITE=1 to refuse today",
973
+ "removed_in": "2.0.0", "where": "behaviour"},
974
+ ]
975
+
976
+
950
977
  MCP_JSON_EXEMPT = ("look", "probe")
978
+ # opt-in lean MCP schema (roadmap 1.10.0): json/progress are transport flags the server appends
979
+ # itself, not tool arguments. Off by default so tools/list stays byte-identical to the CLI surface
980
+ # the contract promises; 2.0 drops them unconditionally.
981
+ MCP_LEAN_DROP = ("json", "progress")
951
982
  MCP_STRUCTURED_NOTE = ("Structured arguments: keys are the input_schema property names (argparse dests), positionals "
952
983
  "are passed by name, output -> -o. Or argv: the raw CLI list (non-canonical; all other keys are then ignored). "
953
984
  "Media paths must be absolute.")
@@ -997,6 +1028,13 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
997
1028
  one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
998
1029
  if one_of:
999
1030
  structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
1031
+ if os.environ.get("FFMPEG_SKILL_MCP_LEAN", "") not in ("", "0"):
1032
+ for dest in MCP_LEAN_DROP:
1033
+ props.pop(dest, None)
1034
+ if structured.get("required"):
1035
+ structured["required"] = [d for d in structured["required"] if d not in MCP_LEAN_DROP]
1036
+ if not structured["required"]:
1037
+ del structured["required"]
1000
1038
  schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
1001
1039
  if structured:
1002
1040
  schema["anyOf"] = [{"required": ["argv"]}, structured]
@@ -1023,6 +1061,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1023
1061
  "unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
1024
1062
  return {
1025
1063
  "contract_version": CONTRACT_VERSION,
1064
+ "deprecated": [dict(d) for d in DEPRECATED],
1026
1065
  "skill": {
1027
1066
  "id": SKILL_ID,
1028
1067
  "version": version,
package/scripts/batch.py CHANGED
@@ -83,6 +83,9 @@ def run_step(argv: List[str]) -> bool:
83
83
  if proc.returncode != 0:
84
84
  info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
85
85
  return False
86
+ for line in proc.stderr.splitlines():
87
+ if line.startswith("warning:"): # a step's deprecation notice is not swallowed by a success (review 9)
88
+ info(" " + line)
86
89
  return True
87
90
 
88
91
 
package/scripts/render.py CHANGED
@@ -49,6 +49,7 @@ Examples:
49
49
  python3 render.py project.json --fast # preview quality
50
50
  """
51
51
  import argparse
52
+ import difflib
52
53
  import re
53
54
  import json
54
55
  import os
@@ -79,6 +80,58 @@ TEMPLATE = {
79
80
  }
80
81
 
81
82
 
83
+ # Every key render.py reads, per object. Anything else is a refusal rather than a silent no-op:
84
+ # a clip "start"/"end" (the spelling titles, graphics and overlays use) rendered the whole clip
85
+ # untrimmed, and a mistyped stage name dropped the stage -- both reported as a success (review 9).
86
+ OBJECT_KEYS: Dict[str, frozenset] = {
87
+ "project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
88
+ "graphics", "overlays", "audio", "loudness", "fit", "export", "check"}),
89
+ "clips[]": frozenset({"src", "in", "out", "speed"}),
90
+ "frame": frozenset({"aspect", "width", "height", "fps"}),
91
+ "transition": frozenset({"type", "duration"}),
92
+ "silence": frozenset({"threshold", "min_silence", "margin"}),
93
+ "captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
94
+ "animate", "highlight_color", "outline", "karaoke", "bold", "box"}),
95
+ "graphics[]": frozenset({"template", "name", "title", "subtitle", "start", "end", "position",
96
+ "from", "scale", "primary", "text_color"}),
97
+ "overlays[]": frozenset({"logo", "image", "text", "position", "start", "end", "fade", "opacity",
98
+ "scale", "font_size", "font", "font_file", "margin", "box"}),
99
+ "audio": frozenset({"music", "replace", "music_volume", "fade_in", "fade_out", "music_fade_out",
100
+ "gain", "duck_amount", "voice", "denoise", "duck", "music_loop", "stereo",
101
+ "mono", "downmix"}),
102
+ "loudness": frozenset({"lufs", "tp"}),
103
+ "fit": frozenset({"duration", "method", "aspect", "fit", "width", "height", "fps", "smooth"}),
104
+ "export": frozenset({"preset", "fit", "crf", "normalize"}),
105
+ "check": frozenset({"platform"}),
106
+ }
107
+ # Typos difflib cannot see: a clip is trimmed with in/out, not the start/end that time a title.
108
+ NEAR_KEYS: Dict[str, Dict[str, str]] = {"clips[]": {"start": "in", "end": "out", "from": "in", "to": "out"}}
109
+
110
+
111
+ def check_keys(obj: Any, schema: str, label: str) -> None:
112
+ """Refuse an unrecognised key, naming the object, the key and the nearest valid one."""
113
+ if not isinstance(obj, dict):
114
+ return
115
+ valid = OBJECT_KEYS[schema]
116
+ for key in obj:
117
+ if key in valid:
118
+ continue
119
+ near = NEAR_KEYS.get(schema, {}).get(str(key)) or next(iter(difflib.get_close_matches(str(key), sorted(valid), n=1, cutoff=0.6)), None)
120
+ die(f"{label}: unknown key {key!r}" + (f" (did you mean {near!r}?)" if near
121
+ else f" (valid keys: {', '.join(sorted(valid))})"))
122
+
123
+
124
+ def validate_project(proj: Dict[str, Any]) -> None:
125
+ check_keys(proj, "project", "project")
126
+ for name in ("frame", "transition", "silence", "captions", "audio", "loudness", "fit", "export", "check"):
127
+ check_keys(proj.get(name), name, name)
128
+ for name in ("clips", "graphics", "overlays"):
129
+ items = proj.get(name)
130
+ if isinstance(items, list):
131
+ for i, item in enumerate(items):
132
+ check_keys(item, f"{name}[]", f"{name}[{i}]")
133
+
134
+
82
135
  def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
83
136
  """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
84
137
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + child_args() + ["--json"]
@@ -226,6 +279,7 @@ def main() -> int:
226
279
  die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
227
280
  if "plan_version" in proj:
228
281
  return execute_plan(proj, os.path.abspath(args.project))
282
+ validate_project(proj)
229
283
  base = Path(args.project).resolve().parent
230
284
 
231
285
  def rel(p: Any) -> str: