ffmpeg-skill 1.10.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/SKILL.md +2 -2
- package/docs/contract.md +11 -10
- package/package.json +1 -1
- package/references/scripts.md +4 -2
- package/scripts/_common.py +58 -25
- package/scripts/_contract.py +6 -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
|
|
@@ -331,6 +333,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
331
333
|
- **Non-Latin captions need a font with the glyphs.** Without one you get boxes, not an error. Name it (`caption.py --font "Noto Sans CJK JP"`) or point at the file (`overlay.py --font-file /path/to/NotoSansCJK-Regular.ttc`).
|
|
332
334
|
- **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
|
|
333
335
|
- **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
|
|
336
|
+
- **Outputs are never overwritten silently.** An existing output path is warned about today and refused from 2.0; set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) to get the refusal now and pass `--overwrite` where a replacement is intended.
|
|
334
337
|
- **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
|
|
335
338
|
|
|
336
339
|
## FFmpeg compatibility
|
|
@@ -357,6 +360,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
|
|
|
357
360
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
358
361
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
|
359
362
|
| **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
|
|
363
|
+
| **108 / 108** | 1.10.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + focused Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, report format 108/108 by both graders (the harness now names the five labels), user's language 105/108 (every Japanese request in Japanese; 3 English requests drifted to Spanish or Portuguese), visual check 21/24, trigger set 22/22; real-device corpus 101/101 steps PASS. Details in `evals/results/iteration-10.json` |
|
|
360
364
|
| **108 / 108** | 1.9.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + independent Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 23/26, user's language 105/108 (every Japanese request answered in Japanese; 3 English requests drifted to Spanish), report format 108/108 by regex (65/108 by the stricter grader, which now counts any missing label), trigger set 22/22; 12 of 15 platform jobs were one encode and `render.py` rendered once in 3/3 (was 1/3). The 1.9.0 time grammar was not used by any agent. Details in `evals/results/iteration-9.json` |
|
|
361
365
|
| **108 / 108** | 1.8.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 22 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 22/24, report format 108/108 by regex (91/108 by the stricter grader: 'What/how:' in place of Steps:), user's language 98/108 by the stricter grader (Japanese labels-only reports counted), trigger set 22/22; 13 of 14 platform exports used `--normalize` and platform jobs went from three encodes to one; r04/f01 now answered in the request's language 5/6 (was 0/6). Details in `evals/results/iteration-8.json` |
|
|
362
366
|
| **108 / 108** | 1.7.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 24 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 25/25, report format 108/108 by regex (104/108 by the stricter grader), user's language 101/108 (six English refusals answered in Spanish or Portuguese, one Japanese request in English); trigger set 22/22. Iteration-6 fixes held (fade-in only, Japanese audio trims, music no longer shortens the video). Details in `evals/results/iteration-7.json` |
|
|
@@ -420,7 +424,7 @@ FFmpeg itself:
|
|
|
420
424
|
|
|
421
425
|
## Stability
|
|
422
426
|
|
|
423
|
-
1.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 2.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-1x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch.
|
|
427
|
+
1.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 2.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-1x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. What 2.0 will remove is already announced: `contract --json` lists it under `deprecated`, `--crf` prints a one-line warning where `--quality` exists, and [docs/contract.md](docs/contract.md#what-20-changes) says what a caller does today to be ready.
|
|
424
428
|
|
|
425
429
|
## Development
|
|
426
430
|
|
package/SKILL.md
CHANGED
|
@@ -130,9 +130,9 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
|
|
|
130
130
|
|
|
131
131
|
## Request → script
|
|
132
132
|
|
|
133
|
-
This table and `doctor`'s
|
|
133
|
+
This table and `doctor --json`'s `tools` list are the source of truth for what exists: name only a script you have seen in one of them, never a plausible-sounding one (there is no `doctor.py`, no `trim.py`, no `subtitle.py`).
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
Timestamp flags -- `--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files -- take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff`, with `@fps` naming the rate (`00:01:02:15@29.97`); tolerance-style flags that are a length rather than a point in time (`--min-silence`, `--margin`, `--min-keep`, `--fade`) are plain seconds. Use the timecode forms when the user pastes an editor's timecode list or an NLE cue sheet, so nothing is converted by hand on the way in.
|
|
136
136
|
|
|
137
137
|
| User says | Do |
|
|
138
138
|
|-----------|----|
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.10.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.10.1`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
|
|
|
88
88
|
|
|
89
89
|
| What 2.0 removes | Since | Replacement | To be ready today |
|
|
90
90
|
|---|---|---|---|
|
|
91
|
-
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.10.
|
|
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.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.10.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.10.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.10.
|
|
91
|
+
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.10.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
|
|
92
|
+
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.10.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
|
|
93
|
+
| `json` and `progress` in the MCP `inputSchema` | 1.10.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
|
|
94
|
+
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.10.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
|
|
95
|
+
| Overwriting an existing output with only a warning | 1.10.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
|
|
96
96
|
|
|
97
97
|
## Skill
|
|
98
98
|
|
|
99
99
|
```json
|
|
100
100
|
{
|
|
101
101
|
"contract_version": "1.0",
|
|
102
|
-
"deprecated": [{"what": "...", "since": "1.10.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.10.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.10.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.10.1", "execution_mode": "local", "kind": "execution",
|
|
104
104
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
105
105
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
106
106
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -426,8 +426,9 @@ identical to the translation of `contract --json`), which the tests check.
|
|
|
426
426
|
them required). They are transport flags `mcp/server.py` sets itself -- it appends `--json` for
|
|
427
427
|
every tool but `look` and `probe` -- rather than arguments a caller chooses, and 2.0 drops them
|
|
428
428
|
for good (see "What 2.0 changes"). The flag is opt-in and changes nothing else: without it
|
|
429
|
-
`tools/list`
|
|
430
|
-
pins
|
|
429
|
+
`tools/list` carries the tool names, argument names and `required` lists the frozen 1.x snapshot
|
|
430
|
+
pins -- descriptions may change between releases (the `--crf` deprecation mark did) -- so a lean
|
|
431
|
+
client and a default client see the same tools with the same names.
|
|
431
432
|
|
|
432
433
|
## Consuming the contract from an agent
|
|
433
434
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.10.
|
|
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
|
@@ -177,7 +177,9 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
|
|
|
177
177
|
read a failed delivery as a success."""
|
|
178
178
|
hint = extra.pop("hint", None)
|
|
179
179
|
ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
|
|
180
|
+
_set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
|
|
180
181
|
ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
|
|
182
|
+
STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
|
|
181
183
|
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
182
184
|
if ctx.json:
|
|
183
185
|
doc: Dict[str, Any] = {
|
|
@@ -259,6 +261,15 @@ class Context:
|
|
|
259
261
|
|
|
260
262
|
STATE = Context()
|
|
261
263
|
|
|
264
|
+
# The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
|
|
265
|
+
# here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
|
|
266
|
+
_CURRENT_CTX: "Optional[Context]" = None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _set_current_ctx(ctx: "Context") -> None:
|
|
270
|
+
global _CURRENT_CTX
|
|
271
|
+
_CURRENT_CTX = ctx
|
|
272
|
+
|
|
262
273
|
|
|
263
274
|
def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
264
275
|
"""Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
|
|
@@ -280,8 +291,17 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
280
291
|
# `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
|
|
281
292
|
# here, once, rather than in each re-encoding tool's own parser.
|
|
282
293
|
crf = ap._option_string_actions["--crf"]
|
|
283
|
-
|
|
284
|
-
|
|
294
|
+
# The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
|
|
295
|
+
# spelling argparse accepts, including the --cr / --c abbreviations) from the default;
|
|
296
|
+
# apply_common() puts _CRF_DEFAULT back when the flag was absent.
|
|
297
|
+
global _CRF_DEFAULT
|
|
298
|
+
_CRF_DEFAULT = crf.default
|
|
299
|
+
crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
|
|
300
|
+
crf.default = None
|
|
301
|
+
if "deprecated" not in (crf.help or ""):
|
|
302
|
+
# the nine tools that declare --crf with no help string used to fall through this and
|
|
303
|
+
# never show the mark at all (review 9)
|
|
304
|
+
crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
|
|
285
305
|
# only the tools that re-encode (they declare --crf before add_common): one encoder choice
|
|
286
306
|
# resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
|
|
287
307
|
g.add_argument("--codec", choices=CODECS, default=None,
|
|
@@ -290,13 +310,18 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
290
310
|
help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
|
|
291
311
|
|
|
292
312
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
as opposed to argparse filling in its default. Used for the deprecation warnings."""
|
|
296
|
-
return any(a == flag or a.startswith(flag + "=") for a in sys.argv[1:])
|
|
313
|
+
# The declared default of a deprecated --crf, parked by add_common() (one parser per process).
|
|
314
|
+
_CRF_DEFAULT: Optional[int] = None
|
|
297
315
|
|
|
298
316
|
|
|
299
317
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
318
|
+
# Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
|
|
319
|
+
# whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
|
|
320
|
+
# an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
|
|
321
|
+
# prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
|
|
322
|
+
crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
|
|
323
|
+
if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
|
|
324
|
+
args.crf = _CRF_DEFAULT
|
|
300
325
|
STATE.plan = getattr(args, "plan", None) or None
|
|
301
326
|
STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
|
|
302
327
|
if STATE.plan:
|
|
@@ -330,12 +355,10 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
330
355
|
die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
|
|
331
356
|
hint="give -o NAME.mov")
|
|
332
357
|
crf = getattr(args, "crf", None)
|
|
333
|
-
# The warning the deprecation policy asks for, only when the
|
|
334
|
-
#
|
|
335
|
-
# an
|
|
336
|
-
|
|
337
|
-
# is not deprecated: warn only where --quality exists.
|
|
338
|
-
if crf is not None and hasattr(args, "quality") and _passed_explicitly("--crf"):
|
|
358
|
+
# The warning the deprecation policy asks for, only when the caller typed the flag (see
|
|
359
|
+
# crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
|
|
360
|
+
# --crf is not an alias and is not deprecated: warn only where --quality exists.
|
|
361
|
+
if crf is not None and crf_explicit:
|
|
339
362
|
info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
|
|
340
363
|
top = 63 if STATE.codec == "av1" else 51
|
|
341
364
|
if crf is not None and not 0 <= int(crf) <= top:
|
|
@@ -408,6 +431,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
408
431
|
`ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
|
|
409
432
|
omitted, every read falls back to the process-global STATE as before."""
|
|
410
433
|
ctx = ctx or STATE
|
|
434
|
+
_set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
|
|
411
435
|
meta: Dict[str, Any] = {}
|
|
412
436
|
if output and not ctx.dry_run:
|
|
413
437
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
@@ -430,10 +454,10 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
430
454
|
if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
|
|
431
455
|
doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
|
|
432
456
|
if ctx.plan:
|
|
433
|
-
doc["plan"] = write_plan(ctx.plan, output, extra)
|
|
457
|
+
doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
|
|
434
458
|
print_json(doc)
|
|
435
459
|
elif ctx.plan:
|
|
436
|
-
print(write_plan(ctx.plan, output, extra))
|
|
460
|
+
print(write_plan(ctx.plan, output, extra, ctx=ctx))
|
|
437
461
|
elif output:
|
|
438
462
|
print(output)
|
|
439
463
|
|
|
@@ -443,9 +467,10 @@ _PLAN_STRIP = ("--plan", "--dry-run", "--json")
|
|
|
443
467
|
|
|
444
468
|
|
|
445
469
|
def _plan_at_exit() -> None:
|
|
446
|
-
|
|
470
|
+
ctx = _CURRENT_CTX or STATE
|
|
471
|
+
if ctx.plan and not ctx.plan_written:
|
|
447
472
|
try:
|
|
448
|
-
write_plan(
|
|
473
|
+
write_plan(ctx.plan, None, {}, ctx=ctx)
|
|
449
474
|
except SystemExit:
|
|
450
475
|
pass
|
|
451
476
|
|
|
@@ -468,13 +493,13 @@ def fingerprint(path: str) -> Dict[str, Any]:
|
|
|
468
493
|
return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
|
|
469
494
|
|
|
470
495
|
|
|
471
|
-
def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]:
|
|
496
|
+
def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
|
|
472
497
|
"""Every existing file the plan depends on: the `-i` inputs of the planned commands, any
|
|
473
498
|
existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
|
|
474
499
|
inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
|
|
475
500
|
import shlex
|
|
476
501
|
seen: List[str] = []
|
|
477
|
-
for a in list(argv) + list(STATE.plan_inputs):
|
|
502
|
+
for a in list(argv) + list((ctx or STATE).plan_inputs):
|
|
478
503
|
if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
|
|
479
504
|
seen.append(a)
|
|
480
505
|
for line in commands:
|
|
@@ -488,10 +513,14 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = ()) -> List[str]
|
|
|
488
513
|
return seen
|
|
489
514
|
|
|
490
515
|
|
|
491
|
-
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
516
|
+
def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
|
|
492
517
|
"""The dry run as an artifact: what will run, on which exact inputs, producing what, checked
|
|
493
|
-
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
|
|
518
|
+
how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
|
|
519
|
+
|
|
520
|
+
`ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
|
|
521
|
+
they were given); omitted, it is the process-global STATE as before."""
|
|
494
522
|
import datetime
|
|
523
|
+
ctx = ctx or STATE
|
|
495
524
|
argv = [a for a in sys.argv[1:]]
|
|
496
525
|
cleaned: List[str] = []
|
|
497
526
|
skip = False
|
|
@@ -520,8 +549,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
|
520
549
|
"tool": tool,
|
|
521
550
|
"argv": cleaned,
|
|
522
551
|
"cwd": os.getcwd(),
|
|
523
|
-
"inputs": [fingerprint(p) for p in _plan_inputs(
|
|
524
|
-
"commands": list(
|
|
552
|
+
"inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
|
|
553
|
+
"commands": list(ctx.commands),
|
|
525
554
|
"output": os.path.abspath(output) if output else None,
|
|
526
555
|
"verify": verify,
|
|
527
556
|
"notes": list(extra.get("notes") or []),
|
|
@@ -534,8 +563,8 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any]) -> str:
|
|
|
534
563
|
os.replace(tmp, path)
|
|
535
564
|
except OSError as exc:
|
|
536
565
|
die(f"cannot write plan {path}: {exc}", kind="output")
|
|
537
|
-
|
|
538
|
-
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}")
|
|
566
|
+
ctx.plan_written = True
|
|
567
|
+
info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
|
|
539
568
|
return path
|
|
540
569
|
|
|
541
570
|
|
|
@@ -1497,7 +1526,11 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
1497
1526
|
raise ValueError(f"bad time: {value}")
|
|
1498
1527
|
total = 0.0
|
|
1499
1528
|
for part in parts:
|
|
1500
|
-
|
|
1529
|
+
try:
|
|
1530
|
+
total = total * 60 + float(part)
|
|
1531
|
+
except ValueError:
|
|
1532
|
+
# not the interpreter's "could not convert string to float: 'zz'" (review 9)
|
|
1533
|
+
raise ValueError(f"'{value}': not a time")
|
|
1501
1534
|
return total
|
|
1502
1535
|
|
|
1503
1536
|
|
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:
|
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:
|