ffmpeg-skill 1.10.0 → 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
@@ -331,6 +333,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
331
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`).
332
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`.
333
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.
334
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.
335
338
 
336
339
  ## FFmpeg compatibility
@@ -357,6 +360,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
357
360
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
358
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) |
359
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` |
360
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` |
361
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` |
362
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` |
@@ -420,7 +424,7 @@ FFmpeg itself:
420
424
 
421
425
  ## Stability
422
426
 
423
- 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.
424
428
 
425
429
  ## Development
426
430
 
package/SKILL.md CHANGED
@@ -130,9 +130,9 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
130
130
 
131
131
  ## Request → script
132
132
 
133
- This table and `doctor`'s tool 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`).
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
134
 
135
- Times take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff` everywhere, with `@fps` naming the rate (`00:01:02:15@29.97`): 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.
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
136
 
137
137
  | User says | Do |
138
138
  |-----------|----|
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.10.0`) | 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`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
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.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.10.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.10.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.10.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.10.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 |
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
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.10.0", "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",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -426,8 +426,9 @@ identical to the translation of `contract --json`), which the tests check.
426
426
  them required). They are transport flags `mcp/server.py` sets itself -- it appends `--json` for
427
427
  every tool but `look` and `probe` -- rather than arguments a caller chooses, and 2.0 drops them
428
428
  for good (see "What 2.0 changes"). The flag is opt-in and changes nothing else: without it
429
- `tools/list` is byte-identical to what it has always been, which is what the frozen 1.x snapshot
430
- pins, so a lean client and a default client see the same tools with the same names.
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.
431
432
 
432
433
  ## Consuming the contract from an agent
433
434
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.10.0",
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.
@@ -177,7 +177,9 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
177
177
  read a failed delivery as a success."""
178
178
  hint = extra.pop("hint", None)
179
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
180
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
181
183
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
182
184
  if ctx.json:
183
185
  doc: Dict[str, Any] = {
@@ -259,6 +261,15 @@ class Context:
259
261
 
260
262
  STATE = Context()
261
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
+
262
273
 
263
274
  def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
264
275
  """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
@@ -280,8 +291,17 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
280
291
  # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
281
292
  # here, once, rather than in each re-encoding tool's own parser.
282
293
  crf = ap._option_string_actions["--crf"]
283
- if crf.help and "deprecated" not in crf.help:
284
- crf.help += " (deprecated: use --quality)"
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)"
285
305
  # only the tools that re-encode (they declare --crf before add_common): one encoder choice
286
306
  # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
287
307
  g.add_argument("--codec", choices=CODECS, default=None,
@@ -290,13 +310,18 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
290
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")
291
311
 
292
312
 
293
- def _passed_explicitly(flag: str) -> bool:
294
- """True when this process was given `flag` on the command line (`--flag` or `--flag=V`),
295
- as opposed to argparse filling in its default. Used for the deprecation warnings."""
296
- return any(a == flag or a.startswith(flag + "=") for a in sys.argv[1:])
313
+ # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
314
+ _CRF_DEFAULT: Optional[int] = None
297
315
 
298
316
 
299
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
300
325
  STATE.plan = getattr(args, "plan", None) or None
301
326
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
302
327
  if STATE.plan:
@@ -330,12 +355,10 @@ def apply_common(args: "argparse.Namespace") -> None:
330
355
  die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
331
356
  hint="give -o NAME.mov")
332
357
  crf = getattr(args, "crf", None)
333
- # The warning the deprecation policy asks for, only when the user typed the flag: argparse's
334
- # own default for --crf is 18 on every re-encoding tool, so `args.crf is not None` cannot tell
335
- # an explicit --crf from the default (sys.argv can).
336
- # export.py has no --quality (its preset chooses the encoder), so its --crf is not an alias and
337
- # is not deprecated: warn only where --quality exists.
338
- if crf is not None and hasattr(args, "quality") and _passed_explicitly("--crf"):
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:
339
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.")
340
363
  top = 63 if STATE.codec == "av1" else 51
341
364
  if crf is not None and not 0 <= int(crf) <= top:
@@ -408,6 +431,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
408
431
  `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
409
432
  omitted, every read falls back to the process-global STATE as before."""
410
433
  ctx = ctx or STATE
434
+ _set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
411
435
  meta: Dict[str, Any] = {}
412
436
  if output and not ctx.dry_run:
413
437
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
@@ -430,10 +454,10 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
430
454
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
431
455
  doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
432
456
  if ctx.plan:
433
- doc["plan"] = write_plan(ctx.plan, output, extra)
457
+ doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
434
458
  print_json(doc)
435
459
  elif ctx.plan:
436
- print(write_plan(ctx.plan, output, extra))
460
+ print(write_plan(ctx.plan, output, extra, ctx=ctx))
437
461
  elif output:
438
462
  print(output)
439
463
 
@@ -443,9 +467,10 @@ _PLAN_STRIP = ("--plan", "--dry-run", "--json")
443
467
 
444
468
 
445
469
  def _plan_at_exit() -> None:
446
- if STATE.plan and not STATE.plan_written:
470
+ ctx = _CURRENT_CTX or STATE
471
+ if ctx.plan and not ctx.plan_written:
447
472
  try:
448
- write_plan(STATE.plan, None, {})
473
+ write_plan(ctx.plan, None, {}, ctx=ctx)
449
474
  except SystemExit:
450
475
  pass
451
476
 
@@ -468,13 +493,13 @@ def fingerprint(path: str) -> Dict[str, Any]:
468
493
  return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
469
494
 
470
495
 
471
- 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]:
472
497
  """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
473
498
  existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
474
499
  inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
475
500
  import shlex
476
501
  seen: List[str] = []
477
- for a in list(argv) + list(STATE.plan_inputs):
502
+ for a in list(argv) + list((ctx or STATE).plan_inputs):
478
503
  if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
479
504
  seen.append(a)
480
505
  for line in commands:
@@ -488,10 +513,14 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]
488
513
  return seen
489
514
 
490
515
 
491
- 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:
492
517
  """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
493
- 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."""
494
522
  import datetime
523
+ ctx = ctx or STATE
495
524
  argv = [a for a in sys.argv[1:]]
496
525
  cleaned: List[str] = []
497
526
  skip = False
@@ -520,8 +549,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
520
549
  "tool": tool,
521
550
  "argv": cleaned,
522
551
  "cwd": os.getcwd(),
523
- "inputs": [fingerprint(p) for p in _plan_inputs(STATE.commands, cleaned)],
524
- "commands": list(STATE.commands),
552
+ "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
553
+ "commands": list(ctx.commands),
525
554
  "output": os.path.abspath(output) if output else None,
526
555
  "verify": verify,
527
556
  "notes": list(extra.get("notes") or []),
@@ -534,8 +563,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
534
563
  os.replace(tmp, path)
535
564
  except OSError as exc:
536
565
  die(f"cannot write plan {path}: {exc}", kind="output")
537
- STATE.plan_written = True
538
- 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)
539
568
  return path
540
569
 
541
570
 
@@ -1497,7 +1526,11 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
1497
1526
  raise ValueError(f"bad time: {value}")
1498
1527
  total = 0.0
1499
1528
  for part in parts:
1500
- 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")
1501
1534
  return total
1502
1535
 
1503
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:
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: