ffmpeg-skill 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -305,6 +305,8 @@ On Windows, `python3` is only on PATH if Python was installed from the Microsoft
305
305
 
306
306
  `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
307
 
308
+ `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.
309
+
308
310
  ### Capability detection
309
311
 
310
312
  ```bash
@@ -355,6 +357,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
355
357
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
356
358
  | **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
359
  | **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) |
360
+ | **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
361
  | **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
362
  | **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
363
  | **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` |
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`'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`).
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.
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.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.10.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -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.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 |
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.0", "execution_mode": "local", "kind": "execution",
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",
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,14 @@ 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` 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.
431
+
407
432
  ## Consuming the contract from an agent
408
433
 
409
434
  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.0",
3
+ "version": "1.10.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -400,7 +400,9 @@ for a `--platform` or a platform export preset), and reports `plan`, `tool`,
400
400
  `tool_result` and `check`. Show the plan to the user, get the yes, execute:
401
401
  one round trip instead of re-deriving the command.
402
402
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
403
- so the rendered file meets the platform's loudness without a separate pass.
403
+ so the rendered file meets the platform's loudness without a separate pass. Since
404
+ 1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
405
+ and the project has no `loudness` stage; `"normalize": false` opts out.
404
406
 
405
407
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
406
408
  captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
@@ -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,10 @@ 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
+ ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
180
181
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
181
- if STATE.json:
182
+ if ctx.json:
182
183
  doc: Dict[str, Any] = {
183
184
  "status": "failed", "exit_code": code,
184
185
  "error": {
@@ -186,7 +187,7 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
186
187
  "code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
187
188
  "retryable": ERROR_RETRYABLE,
188
189
  },
189
- "commands": list(STATE.commands),
190
+ "commands": list(ctx.commands),
190
191
  }
191
192
  if hint:
192
193
  doc["error"]["hint"] = hint
@@ -195,9 +196,10 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
195
196
  sys.exit(code)
196
197
 
197
198
 
198
- def info(msg: str) -> None:
199
+ def info(msg: str, ctx: "Optional[Context]" = None) -> None:
199
200
  # under --dry-run nothing is written; do not let scripts claim otherwise
200
- if msg.startswith("wrote ") and STATE.dry_run:
201
+ ctx = ctx or STATE
202
+ if msg.startswith("wrote ") and ctx.dry_run:
201
203
  msg = "[dry-run] would write " + msg[len("wrote "):]
202
204
  sys.stderr.write(f"{msg}\n")
203
205
 
@@ -274,6 +276,12 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
274
276
  g.add_argument("--plan", metavar="FILE",
275
277
  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
278
  if codec and "--crf" in ap._option_string_actions:
279
+ # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
280
+ # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
281
+ # here, once, rather than in each re-encoding tool's own parser.
282
+ crf = ap._option_string_actions["--crf"]
283
+ if crf.help and "deprecated" not in crf.help:
284
+ crf.help += " (deprecated: use --quality)"
277
285
  # only the tools that re-encode (they declare --crf before add_common): one encoder choice
278
286
  # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
279
287
  g.add_argument("--codec", choices=CODECS, default=None,
@@ -282,6 +290,12 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
282
290
  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
291
 
284
292
 
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:])
297
+
298
+
285
299
  def apply_common(args: "argparse.Namespace") -> None:
286
300
  STATE.plan = getattr(args, "plan", None) or None
287
301
  STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
@@ -316,6 +330,13 @@ def apply_common(args: "argparse.Namespace") -> None:
316
330
  die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
317
331
  hint="give -o NAME.mov")
318
332
  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"):
339
+ 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
340
  top = 63 if STATE.codec == "av1" else 51
320
341
  if crf is not None and not 0 <= int(crf) <= top:
321
342
  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 +402,17 @@ def _unwatch(proc: subprocess.Popen) -> None:
381
402
  _CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
382
403
 
383
404
 
384
- def emit(output: Optional[str], **extra: Any) -> None:
385
- """Final stdout line: the output path, or a JSON document with --json."""
405
+ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
406
+ """Final stdout line: the output path, or a JSON document with --json.
407
+
408
+ `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
409
+ omitted, every read falls back to the process-global STATE as before."""
410
+ ctx = ctx or STATE
386
411
  meta: Dict[str, Any] = {}
387
- if output and not STATE.dry_run:
412
+ if output and not ctx.dry_run:
388
413
  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)}
414
+ if ctx.json:
415
+ doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
391
416
  if meta:
392
417
  doc["probe"] = meta
393
418
  # What this tool itself verified about its artifact (issue #189 C, "verify as part of the
@@ -397,18 +422,18 @@ def emit(output: Optional[str], **extra: Any) -> None:
397
422
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
398
423
  # keep status completed and say verified: false, so a caller keys on one field.
399
424
  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:
425
+ if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
401
426
  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)
427
+ doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
403
428
  doc["verification"] = steps
404
429
  doc.update(extra)
405
430
  if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
406
431
  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)
432
+ if ctx.plan:
433
+ doc["plan"] = write_plan(ctx.plan, output, extra)
409
434
  print_json(doc)
410
- elif STATE.plan:
411
- print(write_plan(STATE.plan, output, extra))
435
+ elif ctx.plan:
436
+ print(write_plan(ctx.plan, output, extra))
412
437
  elif output:
413
438
  print(output)
414
439
 
@@ -840,7 +865,7 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
840
865
  return list(cmd[:-1]) + [tmp], output, tmp
841
866
 
842
867
 
843
- def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
868
+ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
844
869
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
845
870
 
846
871
  ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
@@ -848,16 +873,20 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
848
873
  with a progress readout under --progress. ffprobe and other tools always run. An output
849
874
  path that already exists is written through a temp file and replaced only on success
850
875
  (see _stage_existing_output), so a failed run never costs the caller the file that was there.
876
+
877
+ `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
878
+ omitted, the commands and flags are read from the process-global STATE as before.
851
879
  """
880
+ ctx = ctx or STATE
852
881
  is_ffmpeg = _is_ffmpeg(cmd)
853
882
  if is_ffmpeg:
854
883
  _check_no_overwrite_input(cmd)
855
884
  _check_output_path(cmd)
856
885
  _check_existing_output(cmd)
857
- STATE.commands.append(_cmdline(cmd))
886
+ ctx.commands.append(_cmdline(cmd))
858
887
  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:
888
+ info(("[dry-run] $ " if ctx.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd), ctx=ctx)
889
+ if ctx.dry_run and is_ffmpeg:
861
890
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
862
891
  with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
863
892
  exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
@@ -866,7 +895,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
866
895
  retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
867
896
  if retry is not None:
868
897
  info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
869
- STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
898
+ ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
870
899
  proc = _execute(retry)
871
900
  elif "not divisible by 2" in (proc.stderr or ""):
872
901
  die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
@@ -1438,6 +1467,8 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
1438
1467
  # time grammar); it overrides the source fps a tool passed in, and is meaningless without
1439
1468
  # the four-part form
1440
1469
  v, _, rate = v.rpartition("@")
1470
+ if "@" in v:
1471
+ raise ValueError(f"'{value}': only one @fps suffix is allowed")
1441
1472
  try:
1442
1473
  fps = float(rate)
1443
1474
  except ValueError:
@@ -947,7 +947,35 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
947
947
  # ----------------------------------------------------------------------------- MCP derivation
948
948
  # tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
949
949
  # does not append --json for them (stated in invocation.structured.argument_mapping.json)
950
+ # ----------------------------------------------------------------------------- deprecations
951
+ # What 2.0.0 removes, announced here per docs/contract.md's three-step deprecation policy:
952
+ # step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
953
+ # removes it in the major. `where` says which surface a caller sees it on. docs/contract.md's
954
+ # "What 2.0 changes" section is written from this list.
955
+ DEPRECATED: List[Dict[str, str]] = [
956
+ {"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)",
957
+ "since": "1.10.0", "replacement": "result_v2 (FFMPEG_SKILL_RESULT_V2=1 today; the only shape in 2.0)",
958
+ "removed_in": "2.0.0", "where": "json"},
959
+ {"what": "--crf as an alias of --quality on every re-encoding tool that takes --quality (export.py keeps --crf: its preset chooses the encoder)",
960
+ "since": "1.10.0", "replacement": "--quality N (same CRF scale, codec-neutral)",
961
+ "removed_in": "2.0.0", "where": "cli"},
962
+ {"what": "json and progress in the MCP inputSchema (they are CLI transport flags, not tool arguments)",
963
+ "since": "1.10.0", "replacement": "nothing: the MCP transport sets them itself (FFMPEG_SKILL_MCP_LEAN=1 drops them today)",
964
+ "removed_in": "2.0.0", "where": "mcp"},
965
+ {"what": "probe's hdr meaning BT.2020 primaries or a PQ/HLG transfer",
966
+ "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",
967
+ "removed_in": "2.0.0", "where": "json"},
968
+ {"what": "overwriting an existing output without --overwrite (warned, not refused)",
969
+ "since": "1.10.0", "replacement": "--overwrite, or FFMPEG_SKILL_NO_OVERWRITE=1 to refuse today",
970
+ "removed_in": "2.0.0", "where": "behaviour"},
971
+ ]
972
+
973
+
950
974
  MCP_JSON_EXEMPT = ("look", "probe")
975
+ # opt-in lean MCP schema (roadmap 1.10.0): json/progress are transport flags the server appends
976
+ # itself, not tool arguments. Off by default so tools/list stays byte-identical to the CLI surface
977
+ # the contract promises; 2.0 drops them unconditionally.
978
+ MCP_LEAN_DROP = ("json", "progress")
951
979
  MCP_STRUCTURED_NOTE = ("Structured arguments: keys are the input_schema property names (argparse dests), positionals "
952
980
  "are passed by name, output -> -o. Or argv: the raw CLI list (non-canonical; all other keys are then ignored). "
953
981
  "Media paths must be absolute.")
@@ -997,6 +1025,13 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
997
1025
  one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
998
1026
  if one_of:
999
1027
  structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
1028
+ if os.environ.get("FFMPEG_SKILL_MCP_LEAN", "") not in ("", "0"):
1029
+ for dest in MCP_LEAN_DROP:
1030
+ props.pop(dest, None)
1031
+ if structured.get("required"):
1032
+ structured["required"] = [d for d in structured["required"] if d not in MCP_LEAN_DROP]
1033
+ if not structured["required"]:
1034
+ del structured["required"]
1000
1035
  schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
1001
1036
  if structured:
1002
1037
  schema["anyOf"] = [{"required": ["argv"]}, structured]
@@ -1023,6 +1058,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1023
1058
  "unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
1024
1059
  return {
1025
1060
  "contract_version": CONTRACT_VERSION,
1061
+ "deprecated": [dict(d) for d in DEPRECATED],
1026
1062
  "skill": {
1027
1063
  "id": SKILL_ID,
1028
1064
  "version": version,
@@ -19,7 +19,7 @@ The subtitle codec is picked from the output container: mov_text for
19
19
  Text-to-SRT input format (one cue per line, blank lines ignored):
20
20
  0:00-0:03 Hello and welcome
21
21
  00:00:03.500 --> 00:00:06 Second line | with a manual line break
22
- 00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps)
22
+ 00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode (hh:mm:ss:ff, needs --fps or an @fps suffix: 00:00:03:15@29.97)
23
23
  Text without a time is auto-timed after the previous cue (--auto-seconds)
24
24
 
25
25
  Examples:
@@ -41,7 +41,7 @@ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_
41
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
42
 
43
43
  TIME_RE = re.compile(
44
- r"^\s*(?P<a>[\d:.,]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,]+)\s+(?P<text>.+)$"
44
+ r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
45
45
  )
46
46
 
47
47
 
@@ -30,7 +30,7 @@ import tempfile
30
30
  from pathlib import Path
31
31
  from typing import Any, Dict, List, Optional
32
32
 
33
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, STATE, read_text_or_die
33
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, STATE, read_text_or_die
34
34
 
35
35
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
36
36
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -46,10 +46,8 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
46
46
  if not line or line.startswith("#"):
47
47
  continue
48
48
  parts = line.split(None, 1)
49
- try:
50
- start = parse_time(parts[0])
51
- except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
52
- die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
49
+ # chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
50
+ start = time_arg(parts[0], f"{path}:{n}")
53
51
  title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
54
52
  if entries and start <= entries[-1]["start"]:
55
53
  die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
@@ -29,7 +29,7 @@ import argparse
29
29
  import sys
30
30
  from typing import List, Tuple
31
31
 
32
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS, fmt_secs
32
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs
33
33
  from sync import measure_offset
34
34
 
35
35
 
@@ -42,9 +42,10 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
42
42
  try:
43
43
  rng, cam = raw.rsplit(":", 1)
44
44
  a, b = rng.rsplit("-", 1)
45
- s, e, c = parse_time(a), parse_time(b), int(cam)
45
+ c = int(cam)
46
46
  except ValueError:
47
47
  die(f"bad switch entry '{raw}' (want START-END:CAM)")
48
+ s, e = time_arg(a, f"--switch {raw!r} start"), time_arg(b, f"--switch {raw!r} end")
48
49
  if not 0 <= c < n:
49
50
  die(f"camera {c} does not exist (inputs are 0..{n - 1})")
50
51
  if e <= s:
package/scripts/render.py CHANGED
@@ -27,7 +27,7 @@ Project format (all keys optional except clips):
27
27
  "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
28
28
  "loudness": {"lufs": -14, "tp": -1},
29
29
  "fit": {"duration": 60},
30
- "export": {"preset": "reels", "normalize": true},
30
+ "export": {"preset": "reels", "normalize": true}, (default for platform presets; false opts out)
31
31
  "check": {"platform": "reels"}
32
32
  }
33
33
 
@@ -19,7 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, parse_time
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
23
23
 
24
24
  MAX_SPEED = 20.0
25
25
  MIN_SPEED = 0.05
@@ -43,9 +43,10 @@ def parse_segment(raw: str) -> Tuple[float, float, float]:
43
43
  try:
44
44
  span, factor_s = raw.rsplit(":", 1)
45
45
  start_s, end_s = span.rsplit("-", 1)
46
- start, end, factor = parse_time(start_s), parse_time(end_s), float(factor_s)
46
+ factor = float(factor_s)
47
47
  except ValueError:
48
- die(f"--segment must look like START-END:FACTOR (times in seconds or mm:ss), got '{raw}'")
48
+ die(f"--segment must look like START-END:FACTOR, got '{raw}'")
49
+ start, end = time_arg(start_s, f"--segment {raw!r} start"), time_arg(end_s, f"--segment {raw!r} end")
49
50
  if end <= start:
50
51
  die(f"--segment {raw}: END must be after START")
51
52
  if not MIN_SPEED <= factor <= MAX_SPEED: