ffmpeg-skill 2.17.3 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ npx ffmpeg-skill
47
47
  </tr>
48
48
  </table>
49
49
 
50
- Left half is the input, right half is what the command produced. **[All 66 before/after demos, with the exact command under each one →](docs/demos.md)** — all of it generated from synthetic footage by `python3 demos/build.py`, so you can rebuild every frame of it yourself.
50
+ Left half is the input, right half is what the command produced. **[Every before/after demo, with the exact command under each one →](docs/demos.md)** — all of it generated from synthetic footage by `python3 demos/build.py`, so you can rebuild every frame of it yourself.
51
51
 
52
52
  `ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **42 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also callable as an MCP tool (`tools/list` advertises a core 12 by default to keep client context small, with `FFMPEG_SKILL_MCP_FULL=1` listing all 42; every tool is reachable by name through `tools/call` either way), and the whole set is described by a machine-readable contract.
53
53
 
@@ -102,6 +102,9 @@ Kajiyama, S. (2026). *What Is Verification-Driven Coding? — A Discipline for N
102
102
  ## Quick start
103
103
 
104
104
  ```bash
105
+ # 0. FFmpeg itself, once (Ubuntu/Debian shown; macOS and Windows are in the Install table below)
106
+ sudo apt install ffmpeg
107
+
105
108
  # 1. install the skill for Claude Code (Cursor: --cursor, Codex: --codex, all three: --all)
106
109
  npx ffmpeg-skill
107
110
 
@@ -112,9 +115,11 @@ npx ffmpeg-skill doctor
112
115
  npx ffmpeg-skill contract --json | head -40
113
116
  ```
114
117
 
118
+ Captions from speech (`caption.py --transcribe`) need one local speech engine, installed separately. `pip install faster-whisper` is the simplest; the first run downloads its model, and the audio never leaves the machine. Without an engine, everything else works, `doctor` lists the engine as missing optional, and `--transcribe` refuses with the install line for each engine it supports.
119
+
115
120
  Already installed? re-run `npx ffmpeg-skill` to refresh `~/.claude/skills/ffmpeg-skill`. Copies are not updated automatically.
116
121
 
117
- `doctor`'s overall `ok` and a single tool's `usable: no` are different signals: `ok` means nothing *required by every tool* is missing, but a plain Homebrew `ffmpeg` on macOS can still be `ok` while `caption.py` specifically can't run (no `subtitles` filter) — check `doctor --json`'s `tools` field for the per-tool answer, not just `ok`.
122
+ `doctor`'s overall `ok` and a single tool's `usable` are different signals: `ok` is false when anything *some* tool requires is missing, so one missing filter (a plain Homebrew `ffmpeg` without `subtitles`, or a build without libvidstab) turns it false even though most tools still run. Check `doctor --json`'s `tools` field for the per-tool answer, not just `ok`.
118
123
 
119
124
  Then talk to your agent:
120
125
 
@@ -191,7 +196,7 @@ Names, order and `inputSchema` in `tools/list` are translated from each tool's a
191
196
  These are the rules the skill file gives the agent and the code enforces.
192
197
 
193
198
  1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
194
- 2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
199
+ 2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` always re-encodes, so it can crossfade and conform mismatched clips). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
195
200
  3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
196
201
  4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
197
202
  5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
@@ -344,7 +349,7 @@ The contract is generated from the code that runs, not maintained beside it. For
344
349
  | `mutates_input` | always `false` |
345
350
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
346
351
 
347
- 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.
352
+ Next to the tool list the document carries a top-level `deprecated` list (since 1.10): what the next major removes, since when, the replacement and the surface it lives on. Today that is Python 3.9 and FFmpeg 5.x, both removed in 3.0.0. What 2.0 removed moved to `removed`, and `docs/contract.md` "What 2.0 changed" is written from that.
348
353
 
349
354
  `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": …}}`; a refusal adds `error.reason`, a stable code such as `output_exists` or `plan_inputs_changed`, and `error.details`, the facts behind it; warnings, fallbacks and skipped steps a run decided on its own are in `diagnostics`), 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).
350
355
 
@@ -544,7 +549,7 @@ claude plugin install ffmpeg-skill@ffmpeg-skill # then in
544
549
 
545
550
  The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
546
551
 
547
- Without Node: clone this repository and copy `SKILL.md`, `scripts/`, `references/` and `mcp/` into your agent's skills directory.
552
+ Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
548
553
 
549
554
  After installing:
550
555
 
@@ -568,7 +573,7 @@ FFmpeg itself:
568
573
 
569
574
  ## Requirements
570
575
 
571
- - FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended). Always required: `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `scdet`, `silencedetect` and `tile` filters. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
576
+ - FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended). Required by at least one tool (so `doctor`'s `ok` needs them all): `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `amix`, `overlay`, `scdet`, `silencedetect`, `cropdetect`, `tile`, `tpad`, `boxblur`, `hqdn3d`, `yadif`, `reverse`, `rotate`, `showwaves`, `v360`, `xstack` and `vidstabdetect`/`vidstabtransform` (libvidstab, `stabilize.py` only) filters; `doctor --json` lists them as `capabilities.required`. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
572
577
  - Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
573
578
  - Node 16+ only for the `npx` installer
574
579
 
@@ -581,8 +586,8 @@ FFmpeg itself:
581
586
  ## Development
582
587
 
583
588
  ```bash
584
- npm test # tests/test_all.py (end-to-end incl. VFR, rotated, 5.1, HDR10, drifting sources) + tests/test_contract.py
585
- python3 tests/test_picture.py # one tool group on its own (analysis, editing, audio, picture, delivery, orchestration)
589
+ npm test # tests/run_parallel.py (test_all.py's end-to-end modules, sharded across the cores) + tests/test_contract.py
590
+ python3 tests/test_picture.py # one tool group on its own (analysis, editing, cut_copy, audio, picture, delivery, orchestration, accel, asr)
586
591
  npm run release-check # pack, install, contract from the installed copy, MCP == contract, doctor, tests, contract evals
587
592
  npm run demo # python3 demos/build.py: synthetic footage -> every before/after demo + docs/demos/*.gif
588
593
  npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run of every script
@@ -590,11 +595,11 @@ python3 evals/run.py --list # agent eval prompts (see evals/); regex-only grad
590
595
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
591
596
  ```
592
597
 
593
- CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1, Python 3.9 and 3.13), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
598
+ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.9 on 24.04, FFmpeg 8.0.1 with Python 3.13 on 26.04), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
594
599
 
595
600
  `tests/test_contract.py` runs on all three OSes, but a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
596
601
 
597
- **Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json` and `docs/contract.md`, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
602
+ **Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
598
603
 
599
604
  Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
600
605
 
@@ -603,7 +608,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
603
608
  | | |
604
609
  |---|---|
605
610
  | [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
606
- | [docs/roadmap.md](docs/roadmap.md) | 1.8.0 to 1.21.0 one theme per minor, each marked shipped + evaluated, shipped with eval pending, or planned; and what 2.0.0 then removes |
611
+ | [docs/roadmap.md](docs/roadmap.md) | the release history from 1.8.0, one theme per minor, each marked shipped + evaluated, shipped with eval pending, or planned; and what 2.0.0 then removes |
607
612
  | [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
608
613
  | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
609
614
  | [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
package/SKILL.md CHANGED
@@ -1,26 +1,26 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". Python 3.10+ standard library only (3.9 deprecated), no cloud, no API keys.'
3
+ description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". FFmpeg 6.1+ and Python 3.10+ (5.x/3.9 deprecated), stdlib only, no cloud, no API keys.'
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
- Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
10
+ Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; for writing steps); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
11
11
 
12
12
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
13
13
 
14
14
  ## Workflow (in this order)
15
15
 
16
- 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (or `npx ffmpeg-skill doctor`) after such a failure, or when asked what the machine can do: read `ok`/`usable`, report the missing capability. `contract --json`'s tool schema is for a *planning* agent, not this workflow.
16
+ 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (the installed copy) after such a failure, or when asked what the machine can do: read `ok`/`usable`, report the missing capability. `contract --json`'s tool schema is for a *planning* agent, not this workflow.
17
17
  1. **Probe what you must plan from.** Run `probe.py` on each input you plan from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
18
18
  2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
19
- 3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts. Never point `-o` at a file you did not create in this job unless the user asked for it to be replaced; pass `--overwrite` only then.
19
+ 3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
20
20
  4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
21
21
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
22
22
  6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
23
- 7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite` is the one way to say "yes, replace it".
23
+ 7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); pass `--overwrite` only for a file this job made or the user asked to replace.
24
24
  8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
25
25
  - **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
26
26
  - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, the calling agent's call — say what you see in one line; they judge.
@@ -150,14 +150,12 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
150
150
  | "where does the subject move, so I can crop it myself" | `cropdetect.py input.mp4 --motion-centre` — motion centroid per second, report-only; the calling agent picks the crop |
151
151
  | "does it look like Log / S-Log?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
152
152
  | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
153
- | "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
154
153
  | "it's a phone video with variable frame rate" | nothing extra: re-encodes conform VFR to constant fps; `fit.py --fps 30` picks the rate |
155
154
 
156
155
  ## Audio-only files
157
156
 
158
157
  Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scenes`, `look`) refuse audio with "input has no video stream" — don't force a video wrapper. Recipes: `references/gotchas.md#audio-only-files`.
159
158
 
160
-
161
159
  ## Report format
162
160
 
163
161
  Reply in the language the request is written in — subtitles in another language are still reported in the request's language. Keep the labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English; everything else is the user's language. Never drift because the job was short or failed: even a one-line "file does not exist". A mid-conversation switch follows the user's latest message.
@@ -182,7 +180,7 @@ Look: final_sheet.png(字幕はセーフエリア内、ロゴは右上)
182
180
  Notes: 元は VFR だったので 30 fps に揃えた。音声はモノラルだったのでステレオにした
183
181
  ```
184
182
 
185
- Keep it to those five lines plus anything the user must decide. Never report success without the output probe; never describe a fix you did not run.
183
+ Keep it to those five lines plus anything the user must decide. Never describe a fix you did not run. `verified` covers only what was measured: in `Notes:` name any `unverified` item the user would assume checked. Report a step with `attribution: unresolved`; do not re-run it.
186
184
 
187
185
  When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
188
186
 
@@ -197,9 +195,7 @@ Notes: send a valid .cube, or say if you want the clip left as is
197
195
  Those filler lines are sentences, not labels: the same report for a Japanese request ends
198
196
  `Check: 検証するものなし` / `Look: 不要`.
199
197
 
200
- A refusal uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. A partial result is `Done:` with the shortfall in `Notes:`; a refusal that still delivers something is `Failed:` — never a third label like `Done (partially):`. Quote a failure's `error.hint` in `Notes:`.
201
-
202
- A failure's `--json` is `status: failed` plus `error.kind` and `error.message`; quote the message, never paraphrase it.
198
+ A refusal uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. A partial result is `Done:` with the shortfall in `Notes:`; a refusal that still delivers something is `Failed:` — never a third label like `Done (partially):`. A failure's `--json` is `status: failed` plus `error.kind`, `error.message` and `error.hint`: quote the message in `Failed:`, never paraphrase it, and the hint in `Notes:`.
203
199
 
204
200
  ## Things that look right but are wrong
205
201
 
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 (`2.17.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.18.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.17.3", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.18.0", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -169,10 +169,11 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
169
169
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
170
170
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
171
171
  | `requires_visual_verification` | the picture changed; run `ffmpeg-skill/look` and inspect the PNG |
172
+ | `unverified_properties` | 2.18: the output properties this tool does not measure, the same list its results carry as `unverified` (empty when the tool names none) |
172
173
  | `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
173
174
  | `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
174
175
  | `video_required` | refuses an input without a video stream ("input has no video stream") |
175
- | | `join` has `audio_only: true` and `video_required: false` since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
176
+ | | `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
176
177
  | `deterministic_inputs`, `idempotency_hint` | see Repeatability |
177
178
  | `mcp` | the MCP tool name and its positional arguments |
178
179
 
@@ -685,6 +686,19 @@ Per-tool keys added after 2.2.2, all additive:
685
686
  | `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (the default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
686
687
  | `join_check`, `segment_end_snap_seconds` | `cut.py --segments` | `join_check`: a video stream-copy join measured by demuxing, `{packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}`. `expected_packets` counts the source's video packets from each part's start keyframe to its end (or the end of the video); `max_step_seconds` is the largest presentation step, `null` when `vfr_check` measured anything but `sampled_cfr` (a `--vfr-guard off` join of variable timing: count only), and each step at a join must be the step its parts predict within 1 ms. `audio_offset_ms`: per part, its sound's shift minus its picture's in ms (+ = sound late), from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than `a:0`; an MPEG-TS source's AAC is hashed without its ADTS headers); `null` for a part with no unique match (digital silence, a periodic tone), and as a whole when the source has no audio or the video check failed. `audio_parts_checked` counts the measured parts. `ok` is the conjunction (count, steps, every measured part within 5 ms); `ok: false` means the join was re-cut (`concat_fallback`) and this is the failed measurement. `null` for a single segment, audio, `--dry-run`, any re-encode forced for every segment, a segment that re-encoded, or a join re-cut before a copy was tried (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error). `segment_end_snap_seconds`: per segment, the signed seconds its end moved to the keyframe the copied part stops at, the first at or after the requested end (`null` for a part that reaches the end of the video, a part of a source with no B-frames, cut to its requested end as in 2.5.1, or a Matroska/MPEG-TS part, which is not snapped); `null` as a whole unless the output is those copied parts |
687
688
 
689
+ Keys added in 2.18, all additive. They make a result carry what the VDC paper
690
+ (Verification-Driven Coding) calls the co-reported items: what was measured against what, how far,
691
+ from where the expected value came, and what was not measured.
692
+
693
+ | key | tool | what it holds |
694
+ |---|---|---|
695
+ | `verification[].property`, `expected`, `measured`, `delta`, `tolerance`, `provenance` | steps that measure a property (join's `duration`, loudness's `loudness`, export's `loudness`, every `streams` step) | the property measured, its expected and measured values, their difference, the tolerance applied, and where the expected value came from: `specification` (the platform table), `user_specified` (a flag), `independent_measurement`, or `input_premeasurement` (the input's own probe) |
696
+ | `verification[]` step `streams` | `cut.py`, `join.py`, `export.py` | the output's video/audio streams against the ones the input had and the operation was not told to drop (an audio extension drops the picture, a GIF the sound). A missing stream is `ok: false` with `missing`, so the result is `verified: false` (#367: a TS cut that lost its video) |
697
+ | `verification[].attribution` | any step with `ok: false` | `unresolved` until a tool knows whether the artifact, the reference, the measuring instrument or the expected value's provenance is at fault. A caller does not re-run on an unresolved failure: a re-run can hide which one it was |
698
+ | `unverified` | every writing tool `UNVERIFIED` names (`_common/emit.py`) | the output's properties this tool did not measure, e.g. `caption`'s `rendered_placement`, `sync`'s `lip_sync`. `verified` is about the measured properties only; a report that passes the verdict on must pass this list with it. The contract carries the same list per tool as `unverified_properties` |
699
+ | `audio_substituted` | `join.py` (video join) | the clips that had no audio stream, whose span of the join is generated silence; `notes` says the same |
700
+ | loudness `tolerance.tp` | `loudness.py`, `export.py`, and `check.py`'s true-peak row | the one dBTP margin every tool applies (`_platforms.TP_TOLERANCE_DB`, 0.1 dB); before 2.18 it was 0.1, 0.05 and 0.0 in the three tools |
701
+
688
702
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
689
703
 
690
704
  | key | tool | what it holds |
@@ -920,7 +934,9 @@ A planning agent (for example video-production-agent's SkillRegistry) can:
920
934
  3. pick a tool by `role`, `inputs`/`outputs`, `video_required` and `audio_only`;
921
935
  4. build the call from `input_schema` and the argument mapping, plan with `--dry-run`;
922
936
  5. run, parse `output_schema`, then run `verification.tools`, adding `look` when
923
- `requires_visual_verification` is true.
937
+ `requires_visual_verification` is true and the output has a video stream. The flag is
938
+ per tool, not per call: an audio-only `join` and `render --template podcast` write no
939
+ picture, and `look` is skipped for them (`Look: not needed`).
924
940
 
925
941
  The measurement documents (`probe`, `check`, `scenes`, `sync`) are ffmpeg-skill's own
926
942
  shapes, not another system's Observation model; convert them in the agent's adapter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.17.3",
3
+ "version": "2.18.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -50,7 +50,7 @@
50
50
  "LICENSE"
51
51
  ],
52
52
  "scripts": {
53
- "test": "python3 tests/test_all.py && python3 tests/test_contract.py",
53
+ "test": "python3 tests/run_parallel.py && python3 tests/test_contract.py",
54
54
  "test:fast": "python3 tests/test_contract.py",
55
55
  "release-check": "bash tests/release_check.sh",
56
56
  "demo": "python3 demos/build.py",
@@ -301,8 +301,9 @@ the contact sheet (`--sheet`) before treating the picks as final.
301
301
  Anything chained by hand through three re-encodes should be one `render.py`
302
302
  project instead, so the plan is one file and the user can change one number.
303
303
  Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
304
- intermediates and keep the default for the final export. `render.py` does that by itself since
305
- 2.15 when its project ends in an encoding export (`--intermediate-preset medium` turns it off). On Apple Silicon,
304
+ intermediates and keep the default for the final export. `render.py` encodes its intermediates at
305
+ `superfast` by itself since 2.16 (veryfast in 2.15) when its project ends in an encoding export
306
+ (`--intermediate-preset medium` turns it off). On Apple Silicon,
306
307
  `--hw` (or `FFMPEG_SKILL_HW=1` for the machine) moves h264/hevc/prores encodes to
307
308
  VideoToolbox: 2–7× faster, 1.1–2.9× the bytes at the same quality (1.9–3.5× on HDR). The variable
308
309
  leaves `export.py`'s delivery presets on the CPU, which is the usual split (GPU
@@ -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`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--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 re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); 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, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--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 `--codec` nor `--quality`; 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`/`join` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), 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`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--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 re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); 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, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--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 `--codec` nor `--quality`; 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), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), 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
 
@@ -686,8 +686,7 @@ in for its streams (any audio extension the skill reads, `.aiff` and `.caf` incl
686
686
  picture), so a pending `.mp4` next to measured audio is refused as the mix a real run would
687
687
  refuse; `expected_duration` is null while any input is pending, and `notes` names the planned
688
688
  frame, rate or xfade offsets that rest on a pending input's unmeasured placeholder.
689
- Every existing input with an audio stream is also measured (one volumedetect pass; not under `--dry-run`
690
- too): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
689
+ Every existing input with an audio stream is also measured (one volumedetect pass, skipped under `--dry-run`): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
691
690
  call that wrote a valid but empty file. `--on-silent warn` (default) joins it and names it under
692
691
  `silent: [{index, path, peak_db, at, end}]` (`at`/`end`: where it sits in the output, seconds)
693
692
  and in `notes`; `fail` refuses it in the same `kind: input` document (`silent (peak -91.0 dBFS)`);
@@ -96,7 +96,7 @@ from _common.color import (
96
96
  is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
97
97
  )
98
98
  from _common.text import (
99
- ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
99
+ ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align, drawtext_fontfile,
100
100
  drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics, NEWLINE_GLYPH_RELEASES,
101
101
  drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
102
102
  _EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
@@ -191,7 +191,7 @@ __all__ = [
191
191
  "BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
192
192
  "BEAT_Z_FLOOR", "BEAT_Z_SPAN",
193
193
  "_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
194
- "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_draws_newline_glyph", "is_multiline", "drawtext_clean", "drawtext_line_metrics", "NEWLINE_GLYPH_RELEASES",
194
+ "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_fontfile", "drawtext_draws_newline_glyph", "is_multiline", "drawtext_clean", "drawtext_line_metrics", "NEWLINE_GLYPH_RELEASES",
195
195
  "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
196
196
  "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
197
197
  "emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
@@ -13,6 +13,71 @@ from _common.decision import escape_filter_path
13
13
  from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version
14
14
 
15
15
 
16
+ def _on_windows() -> bool:
17
+ return os.name == "nt"
18
+
19
+
20
+ def _windows_short_path(path: str) -> "Optional[str]":
21
+ """The 8.3 short form of an existing Windows path, or None (not Windows, the call failed, or
22
+ the volume keeps no short names)."""
23
+ if not _on_windows():
24
+ return None
25
+ try:
26
+ import ctypes
27
+ buf = ctypes.create_unicode_buffer(32768)
28
+ n = ctypes.windll.kernel32.GetShortPathNameW(str(path), buf, len(buf))
29
+ except (OSError, AttributeError, ValueError):
30
+ return None
31
+ return buf.value if 0 < n < len(buf) else None
32
+
33
+
34
+ def drawtext_fontfile(path: str) -> str:
35
+ """`fontfile=<path>` for drawtext, with a path FreeType can open.
36
+
37
+ drawtext hands the path to FreeType, which opens it with the C runtime's ANSI fopen on
38
+ Windows: measured on the gyan.dev 9.0.2 build, a font under `compat dir ünïcode` fails to
39
+ load (drawtext falls back to fontconfig and the job dies with "could not parse fontconfig
40
+ pattern") while the same file under an ASCII directory loads, spaces and all. A non-ASCII
41
+ path on Windows is therefore given in its 8.3 short form, or, where the volume keeps no
42
+ short names, copied into this run's private drawtext directory under an ASCII name (recorded,
43
+ `font_path_copied`). Elsewhere the path is passed as it is. The original file is what a plan
44
+ binds, never the copy.
45
+ """
46
+ path = str(path)
47
+ if not _on_windows() or path.isascii() or not os.path.isfile(path):
48
+ # a missing file is left to ffmpeg's own error, as before: there is nothing to copy
49
+ return f"fontfile={escape_filter_path(path)}"
50
+ if os.path.isfile(path) and path not in STATE.plan_inputs:
51
+ STATE.plan_inputs.append(path)
52
+ short = _windows_short_path(path)
53
+ if short and short.isascii():
54
+ return f"fontfile={escape_filter_path(short, bind=False)}"
55
+ import hashlib
56
+ from _common.emit import record_fallback, warn
57
+ tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
58
+ if not tmpdir.isascii():
59
+ tmpdir = _windows_short_path(tmpdir) or tmpdir
60
+ st = os.stat(path)
61
+ # the path plus size and mtime: a font replaced in place during the run gets a new copy
62
+ key = f"{path}|{st.st_size}|{st.st_mtime_ns}".encode("utf-8")
63
+ name = "font_" + hashlib.sha1(key).hexdigest()[:16] + os.path.splitext(path)[1].lower()
64
+ copy = os.path.join(tmpdir, name)
65
+ if not copy.isascii():
66
+ warn(f"font: {path} is not an ASCII path and no ASCII copy could be placed; drawtext may "
67
+ "not open it on Windows (move the font to a folder named in ASCII)", "font_path_not_ascii", font=path)
68
+ return f"fontfile={escape_filter_path(path, bind=False)}"
69
+ if not STATE.dry_run and not os.path.exists(copy):
70
+ try:
71
+ shutil.copyfile(path, copy)
72
+ except OSError as e:
73
+ warn(f"font: could not copy {path} to an ASCII path ({e}); drawtext may not open it on "
74
+ "Windows (move the font to a folder named in ASCII)", "font_path_not_ascii", font=path)
75
+ return f"fontfile={escape_filter_path(path, bind=False)}"
76
+ record_fallback(f"font: {path} is not an ASCII path, which drawtext cannot open on Windows; "
77
+ f"drawing with a copy at {copy}", "font_path_copied", font=path, file=copy)
78
+ return f"fontfile={escape_filter_path(copy, bind=False)}"
79
+
80
+
16
81
  def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
17
82
  """drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
18
83
  arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
@@ -372,6 +372,45 @@ def steps_encoder_report(steps: Sequence["Tuple[str, Any]"]) -> Dict[str, Any]:
372
372
  return out
373
373
 
374
374
 
375
+ # The properties each writing tool does NOT measure about its output, by tool name: the unverified
376
+ # range U that every --json result reports (docs/contract.md "unverified"). A property listed here
377
+ # is one a caller might assume was checked; naming it is the difference between "verified" meaning
378
+ # "the listed properties were measured" and "everything is fine". Kept beside emit() so a tool
379
+ # cannot report verified without this list travelling with it.
380
+ UNVERIFIED: Dict[str, List[str]] = {
381
+ "cut": ["picture_content"],
382
+ "join": ["picture_content", "transition_look"],
383
+ "fit": ["subject_framing"],
384
+ "crop": ["subject_framing"],
385
+ "caption": ["rendered_placement", "legibility"],
386
+ "overlay": ["rendered_placement"],
387
+ "graphics": ["rendered_placement"],
388
+ "color": ["grade_look"],
389
+ "export": ["picture_content"],
390
+ "render": ["picture_content", "rendered_placement"],
391
+ "silence": ["removed_span_content"],
392
+ "audio": ["mix_balance"],
393
+ "loudness": ["perceived_loudness"],
394
+ "sync": ["lip_sync"],
395
+ "multicam": ["lip_sync", "shot_choice"],
396
+ }
397
+
398
+
399
+ def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, bool]) -> Dict[str, Any]:
400
+ """The output's stream composition checked against what the operation should have produced
401
+ (#367: a cut of an MPEG-TS source lost its whole video stream and still said verified: true,
402
+ because the only properties measured were duration and start skew). `expect` comes from the
403
+ input's own probe and the tool's arguments (provenance "input_premeasurement"): a stream the
404
+ input had and the operation was not told to drop must still be there."""
405
+ measured = {kind: bool(meta.get(kind)) for kind in expect}
406
+ missing = [k for k, want in expect.items() if want and not measured[k]]
407
+ step: Dict[str, Any] = {"step": "streams", "property": "stream_inventory", "expected": dict(expect),
408
+ "measured": measured, "provenance": "input_premeasurement", "ok": not missing}
409
+ if missing:
410
+ step["missing"] = missing
411
+ return step
412
+
413
+
375
414
  def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
376
415
  """Final stdout line: the output path, or a JSON document with --json.
377
416
 
@@ -392,13 +431,22 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
392
431
  # only when the file was written, probed, and every self-check met its target; a dry run
393
432
  # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
394
433
  # keep status completed and say verified: false, so a caller keys on one field.
434
+ expect_streams = extra.pop("expect_streams", None)
395
435
  steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
436
+ if meta and expect_streams:
437
+ steps.append(stream_inventory_step(meta, expect_streams))
396
438
  # a tool that ran others (render.py) verified what its stages verified too (absorb_stage())
397
439
  steps += [s for s in ctx.stage_verification if s not in steps]
398
440
  if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
399
441
  steps.insert(0, {"step": "exists", "ok": True})
400
442
  doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
443
+ # a failed check asserts that something in artifact, reference, instrument or the expected
444
+ # value's provenance is wrong; until a tool knows which, it says it does not know
445
+ steps = [dict(s, attribution=s.get("attribution") or "unresolved") if s.get("ok") is False else s for s in steps]
401
446
  doc["verification"] = steps
447
+ tool = os.path.splitext(os.path.basename(sys.argv[0] or ""))[0]
448
+ if output and not ctx.dry_run and tool in UNVERIFIED and "unverified" not in extra:
449
+ doc["unverified"] = list(UNVERIFIED[tool])
402
450
  doc.update(_encoder_report(ctx))
403
451
  doc.update(extra)
404
452
  hw_notes = [n for n in _hw_notes_for(ctx) if n not in (doc.get("notes") or [])]
@@ -30,7 +30,7 @@ from _common.emoji import (
30
30
  emoji_support, resolve_emoji_assets, emoji_filter_chain,
31
31
  )
32
32
  from _common.drawtext import (
33
- drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
33
+ drawtext_boxborderw, drawtext_fontfile, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
34
34
  escape_drawtext, drawtext_text_opts, drawtext_center_align, NEWLINE_GLYPH_RELEASES,
35
35
  drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics,
36
36
  )
@@ -218,7 +218,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
218
218
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
219
219
  "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
220
220
  required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
221
- video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
221
+ video_required=False, audio_only=True, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
222
222
  "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
223
223
  required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
224
224
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
@@ -453,6 +453,8 @@ DIAGNOSTIC_CODES = {
453
453
  "font_substituted": ("fallbacks", "the asked font family is not installed: fontconfig or the Windows Fonts folder gave another (font_used names it); {asked, file, family, via}"),
454
454
  "font_lacks_script": ("warnings", "an explicit --font does not cover the text's script and is kept as asked; {font, script}"),
455
455
  "font_coverage_unverified": ("warnings", "whether a font covers the text's script could not be checked (no fontconfig / fc-scan); {script, fonts_dir?}"),
456
+ "font_path_copied": ("fallbacks", "a --font-file under a non-ASCII path was copied to an ASCII one, because drawtext cannot open it on Windows; {font, file}"),
457
+ "font_path_not_ascii": ("warnings", "a --font-file under a non-ASCII path could not be given an ASCII form on Windows; drawtext may not open it; {font}"),
456
458
  "fonts_dir_lacks_script": ("fallbacks", "no face in --fonts-dir covers the text's script, so one was picked by script; {fonts_dir, script}"),
457
459
  "emoji_monochrome": ("warnings", "emoji drawn by the text font (monochrome at best): no colour path here"),
458
460
  "emoji_stripped": ("warnings", "overlay.py --emoji none: emoji removed from the drawn text; {mode}"),
@@ -534,7 +536,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
534
536
  "hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
535
537
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
536
538
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
537
- "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's"},
539
+ "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
540
+ "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
538
541
  "diagnostics": DIAGNOSTICS_SCHEMA}
539
542
  extra: Dict[str, Any] = {}
540
543
  if name == "check":
@@ -681,6 +684,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
681
684
  return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
682
685
 
683
686
  @functools.lru_cache(maxsize=1)
687
+ def _unverified_table() -> Dict[str, List[str]]:
688
+ """emit()'s per-tool unverified range, read where the results are made so the two cannot differ."""
689
+ sys.path.insert(0, str(ROOT / "scripts"))
690
+ from _common.emit import UNVERIFIED
691
+ return UNVERIFIED
692
+
693
+
684
694
  def skill_examples() -> Dict[str, List[Dict[str, Any]]]:
685
695
  """1.20.0: per-tool `examples`, parsed from SKILL.md's "User says" / "Do" table -- the
686
696
  same facts that table already states, machine-readable instead of prose-only. A row
@@ -1545,6 +1555,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1545
1555
  "reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
1546
1556
  **({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
1547
1557
  "requires_visual_verification": meta["visual"],
1558
+ # 2.18: what `verified` does not cover for this tool (the result's `unverified`)
1559
+ "unverified_properties": list(_unverified_table().get(name, [])),
1548
1560
  "audio_only": meta["audio_only"],
1549
1561
  "video_required": meta["video_required"],
1550
1562
  "deterministic_inputs": meta["deterministic"],
@@ -55,6 +55,13 @@ _NO_SAFE = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
55
55
  _CAP_VERTICAL = {"size": 0.0833, "position": "bottom", "box": False, "outline": 2, "animate": "pop"}
56
56
  _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "animate": "none"}
57
57
 
58
+ # The one true-peak tolerance every tool applies to a dBTP ceiling, reported beside the verdict.
59
+ # Before 2.18 loudness.py allowed +0.1 dB, check.py +0.05 and export.py 0.0, so "verified" meant a
60
+ # different scale in each tool and export could fail a file check passed (the VDC paper's
61
+ # sensitivity run). 0.1 dB is loudness.py's margin: two loudnorm passes on the same encode differ
62
+ # by up to that much, so a tighter rule fails a file on measurement noise.
63
+ TP_TOLERANCE_DB = 0.1
64
+
58
65
  PLATFORMS: Dict[str, Dict[str, Any]] = {
59
66
  "tiktok": {
60
67
  "title": "TikTok",
package/scripts/check.py CHANGED
@@ -34,7 +34,7 @@ import sys
34
34
  from fractions import Fraction
35
35
  from typing import Any, Dict, List
36
36
 
37
- from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
37
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
38
38
  from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
39
39
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
40
40
 
@@ -249,7 +249,7 @@ def main() -> int:
249
249
  diff = abs(lm["lufs"] - spec["lufs"])
250
250
  row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
251
251
  reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
252
- row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
252
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
253
253
  else:
254
254
  # ffmpeg's loudnorm JSON didn't parse out of stderr (unexpected/garbled output).
255
255
  # Dropping the loudness/true-peak rows silently here would report an overall PASS
package/scripts/cut.py CHANGED
@@ -1677,7 +1677,11 @@ def main() -> int:
1677
1677
  notes.append(skew_note)
1678
1678
  info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
1679
1679
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
1680
+ # the streams the source had and this cut was not told to drop (an audio extension drops the
1681
+ # picture): #367, a TS cut that lost its video must not say verified
1682
+ expect_streams = {"video": bool(meta.get("video")) and not is_audio_output(output), "audio": bool(meta.get("audio"))}
1680
1683
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
1684
+ expect_streams=expect_streams,
1681
1685
  dropped_non_av_streams=bool(DROPPED_STREAMS),
1682
1686
  requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
1683
1687
  requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
package/scripts/export.py CHANGED
@@ -40,10 +40,22 @@ from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_ta
40
40
  from _common.decision import hw_preset_video, restate_last_swap
41
41
  from _common.emit import note, record_skip, warn
42
42
  from check import SPECS as PLATFORMS, measure_loudness
43
- from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
43
+ from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
44
44
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
45
45
  # from a literal restated here: before 1.14 they were typed twice and the facebook preset had
46
46
  # already drifted (no duration cap against the table's 14400 s).
47
+
48
+ def _loudness_step(ok: bool, platform: str, spec: dict, m: dict) -> dict:
49
+ """export's loudness verification step: the platform's targets, what was measured, the
50
+ difference and the tolerance each was held to (docs/contract.md, 2.18 keys)."""
51
+ expected = {"lufs": spec["lufs"], "tp_max": spec["tp"]}
52
+ measured = {"lufs": m["lufs"], "tp": m["tp"]}
53
+ delta = {"lufs": round(m["lufs"] - spec["lufs"], 2) if math.isfinite(m["lufs"]) else None,
54
+ "tp": round(m["tp"] - spec["tp"], 2) if math.isfinite(m["tp"]) else None}
55
+ return {"step": "loudness", "ok": ok, "platform": platform, "property": "loudness",
56
+ "expected": expected, "measured": measured, "delta": delta,
57
+ "tolerance": {"lufs": spec["lufs_tol"], "tp": TP_TOLERANCE_DB}, "provenance": "specification"}
58
+
47
59
  def _from_table(dest: str, **over) -> Dict:
48
60
  """A preset's w/h/max read from PLATFORMS[dest], with the encoder settings given here.
49
61
 
@@ -268,9 +280,9 @@ def main() -> int:
268
280
  if not m:
269
281
  _loudness_unmeasured(extra, notes, platform, spec)
270
282
  if m:
271
- ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
283
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
272
284
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
273
- extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
285
+ extra["verification"] = [_loudness_step(ok, platform, spec, m)]
274
286
  if not math.isfinite(m["lufs"]):
275
287
  # a silent output measures -inf LUFS: no gain reaches the target, so recommending
276
288
  # (or running, under --normalize) loudness.py only returns "nothing to normalise"
@@ -305,9 +317,9 @@ def main() -> int:
305
317
  _loudness_unmeasured(extra, notes, platform, spec, normalized=True)
306
318
  ok = True # nothing more to say about a level that was not measured
307
319
  else:
308
- ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
320
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
309
321
  extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
310
- extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
322
+ extra["verification"] = [_loudness_step(ok, platform, spec, m)]
311
323
  if not ok:
312
324
  notes.append(f"loudness is still {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP after loudness.py (target {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP): {child.get('result', {}).get('note') or 'the encoder overshoots the ceiling'}")
313
325
  warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=True)
@@ -323,6 +335,8 @@ def main() -> int:
323
335
  notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
324
336
  if notes:
325
337
  extra["notes"] = notes
338
+ # the delivery keeps the source's picture and, except as a GIF, its sound
339
+ extra["expect_streams"] = {"video": bool(meta.get("video")), "audio": has_audio and args.preset != "gif"}
326
340
  emit(output, **extra)
327
341
  return 0
328
342
 
@@ -43,6 +43,7 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
43
43
  from _common.emit import warn
44
44
  from _common.fonts import font_choice, record_font_choice
45
45
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
46
+ from _common import drawtext_fontfile # noqa: E402
46
47
 
47
48
  # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
48
49
  TEXT = "@TEXT@"
@@ -62,7 +63,7 @@ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script
62
63
  choice = font_choice(font or brand.get("font", "DejaVu Sans"), font_file=font_file or brand.get("font_file"),
63
64
  script_file=script_file, script_family=script_family, renderer=renderer)
64
65
  if choice["file"]:
65
- return f"fontfile={escape_filter_path(choice['file'])}", choice
66
+ return drawtext_fontfile(choice['file']), choice
66
67
  return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'", choice
67
68
 
68
69
 
package/scripts/grid.py CHANGED
@@ -26,8 +26,9 @@ import os
26
26
  import sys
27
27
 
28
28
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
- draws_chroma, drawn_chain, escape_drawtext, escape_filter_path, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
29
+ draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
+ from _common import drawtext_fontfile # noqa: E402
31
32
 
32
33
  LABEL_MARGIN = 10
33
34
 
@@ -120,7 +121,7 @@ def main() -> int:
120
121
  drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
121
122
  if args.label == "auto":
122
123
  stem = os.path.splitext(os.path.basename(p))[0]
123
- font_opt = f"fontfile={escape_filter_path(font_file)}" if font_file else f"font='{escape_drawtext(args.font)}'"
124
+ font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
124
125
  drawn.append((f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
125
126
  f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
126
127
  f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
package/scripts/join.py CHANGED
@@ -565,11 +565,21 @@ def main() -> int:
565
565
  verification = []
566
566
  if expected is not None and measured is not None and not STATE.dry_run:
567
567
  tol = max(1.5 / fps, 0.05)
568
- verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
569
- "measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
568
+ verification.append({"step": "duration", "property": "video_duration", "ok": abs(measured - expected) <= tol,
569
+ "measured": round(measured, 3), "expected": expected,
570
+ "delta": round(measured - expected, 3), "tolerance": round(tol, 3),
571
+ "provenance": "input_premeasurement"})
572
+ # a clip with no audio stream is joined over generated silence: the join is right to do that, but
573
+ # the report must say which clips' sound is not theirs (the paper's join finding: absence was
574
+ # filled and nothing in the result showed it)
575
+ substituted = [p for p, m in zip(args.inputs, metas) if not m.get("audio")]
576
+ if substituted:
577
+ STATE_NOTES.append(f"{len(substituted)} clip(s) had no audio stream; their span of the join is generated "
578
+ "silence: " + ", ".join(os.path.basename(p) for p in substituted))
570
579
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
571
580
  emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
572
- verification=verification,
581
+ verification=verification, expect_streams={"video": True, "audio": True},
582
+ audio_substituted=substituted,
573
583
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
574
584
  return 0
575
585
 
package/scripts/look.py CHANGED
@@ -17,7 +17,8 @@ from pathlib import Path
17
17
  from typing import List, Optional
18
18
 
19
19
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
20
- from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
20
+ from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
21
+ from _common import drawtext_fontfile # noqa: E402
21
22
 
22
23
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
23
24
 
@@ -161,9 +162,12 @@ def main() -> int:
161
162
  # a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
162
163
  # own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
163
164
  # nothing can be resolved, unchanged from before this existed.
164
- default_font = default_font_file("DejaVu Sans")
165
- font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
166
- tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
165
+ if args.no_timecode:
166
+ font_prefix, tc = "", "" # nothing is drawn, so no font is resolved or copied
167
+ else:
168
+ default_font = default_font_file("DejaVu Sans")
169
+ font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
170
+ tc = "," + timecode_filter(font_prefix)
167
171
  # --safe: the platform's occluded zones, drawn as shaded boxes in fractions of the frame so
168
172
  # the same filter is right at any scale (a tile of a contact sheet as much as a full frame).
169
173
  safe_filter = ""
@@ -19,6 +19,7 @@ import os
19
19
  import re
20
20
  import sys
21
21
 
22
+ from _platforms import TP_TOLERANCE_DB
22
23
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
23
24
 
24
25
 
@@ -128,7 +129,7 @@ def main() -> int:
128
129
  # integrated loudness -- reported, never hidden.
129
130
  ceiling, rounds = args.tp, 0
130
131
  steps = [] if bitrate_pinned or ext in AUDIO_CODECS and "aac" not in AUDIO_CODECS[ext] else [b for b in ("256k", "320k") if _kbps(b) > _kbps(bitrate)]
131
- while not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1 and rounds < 5:
132
+ while not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB and rounds < 5:
132
133
  rounds += 1
133
134
  overshoot = float(after["input_tp"]) - args.tp
134
135
  if steps:
@@ -152,7 +153,7 @@ def main() -> int:
152
153
  result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
153
154
  f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
154
155
  f"higher --audio-bitrate keeps both")
155
- if not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1:
156
+ if not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB:
156
157
  die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {args.tp:g}); "
157
158
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
158
159
  kind="verification", output=output, result=result,
@@ -161,8 +162,12 @@ def main() -> int:
161
162
  measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
162
163
  targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra},
163
164
  verification=[{"step": "loudness",
164
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + 0.1),
165
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp}])
165
+ "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= 1.0 and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
166
+ "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
167
+ "property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
168
+ "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
169
+ "delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
170
+ "tolerance": {"lufs": 1.0, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}])
166
171
  return 0
167
172
 
168
173
 
@@ -30,9 +30,10 @@ import sys
30
30
  from typing import List, Optional
31
31
 
32
32
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
33
- from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
33
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
34
34
  from _common.emit import warn
35
35
  from _common.fonts import font_choice, record_font_choice
36
+ from _common import drawtext_fontfile # noqa: E402
36
37
 
37
38
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
38
39
  # like column is 14 % of the width, its description block 22 % of the height), so a position
@@ -465,7 +466,7 @@ def main() -> int:
465
466
  x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
466
467
  # 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
467
468
  # (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
468
- font_opt = (f"fontfile={escape_filter_path(args.font_file)}" if args.font_file
469
+ font_opt = (drawtext_fontfile(args.font_file) if args.font_file
469
470
  else f"font='{escape_drawtext(args.font)}'")
470
471
  alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
471
472
  timing = ([f"alpha='{alpha}'"] if alpha != "1" else []) + ([f"enable='{enable}'"] if enable else [])
package/scripts/scenes.py CHANGED
@@ -24,8 +24,9 @@ from typing import Dict, List, Optional, Tuple
24
24
 
25
25
  # `detect_scenes` moved into _common/probe.py in 1.16.0 (see silence.py); the body is unchanged.
26
26
  from _common import (detect_scenes, STATE, add_common, apply_common, beat_grid, default_font_file, die, emit,
27
- escape_filter_path, ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
27
+ ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
28
28
  rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
29
+ from _common import drawtext_fontfile # noqa: E402
29
30
 
30
31
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
31
32
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -286,7 +287,7 @@ def main() -> int:
286
287
  stamp = ""
287
288
  if not args.no_timecode:
288
289
  default_font = default_font_file("DejaVu Sans")
289
- font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
290
+ font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
290
291
  stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
291
292
  vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
292
293
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
package/scripts/verify.py CHANGED
@@ -114,7 +114,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
114
114
  return r
115
115
  try:
116
116
  proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
117
- text=True, encoding="utf-8", errors="replace", timeout=timeout)
117
+ text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
118
118
  except subprocess.TimeoutExpired:
119
119
  msg = f"timeout after {timeout:.0f}s"
120
120
  return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
@@ -149,7 +149,7 @@ def main() -> int:
149
149
  ap.add_argument("--keep", action="store_true", help="keep the outputs")
150
150
  ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
151
151
  ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
152
- ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
152
+ ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds (default 600; 0 = no limit, as everywhere else)")
153
153
  ap.add_argument("--report", help="write a Markdown report here")
154
154
  add_common(ap)
155
155
  args = ap.parse_args()