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