ffmpeg-skill 1.9.1 → 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 +3 -0
- package/SKILL.md +17 -3
- package/docs/contract.md +27 -2
- package/package.json +1 -1
- package/scripts/_common.py +51 -22
- package/scripts/_contract.py +36 -0
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
|
|
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 (
|
|
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. `
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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",
|
package/scripts/_common.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
390
|
-
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run":
|
|
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
|
|
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
|
|
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
|
|
408
|
-
doc["plan"] = write_plan(
|
|
432
|
+
if ctx.plan:
|
|
433
|
+
doc["plan"] = write_plan(ctx.plan, output, extra)
|
|
409
434
|
print_json(doc)
|
|
410
|
-
elif
|
|
411
|
-
print(write_plan(
|
|
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
|
-
|
|
886
|
+
ctx.commands.append(_cmdline(cmd))
|
|
858
887
|
if not quiet:
|
|
859
|
-
info(("[dry-run] $ " if
|
|
860
|
-
if
|
|
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
|
-
|
|
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 "
|
package/scripts/_contract.py
CHANGED
|
@@ -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,
|