ffmpeg-skill 3.6.6 → 3.7.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
@@ -46,7 +46,7 @@ npx ffmpeg-skill
46
46
  </tr>
47
47
  </table>
48
48
 
49
- 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.
49
+ 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 built by `python3 demos/build.py`, so you can rebuild every frame of it yourself. The footage is synthetic (FFmpeg's own test sources) except in three demos, `reframe_talk`, `filler_en` and `pauses_en`, which use two AI-generated clips of people who do not exist ([demos/footage/](demos/footage/README.md)).
50
50
 
51
51
  `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.
52
52
 
@@ -250,7 +250,7 @@ These are the rules the skill file gives the agent and the code enforces.
250
250
 
251
251
  | Tool | What it does |
252
252
  |---|---|
253
- | `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
253
+ | `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N`, a tempo change that keeps the pitch (`--tempo`, atempo stages in 0.5–2.0) and an exact length in samples (`--length-samples`), resampling (`--sample-rate`) and one channel by index (`--channel N`), each read back from the output |
254
254
  | `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection); a third or later camera is an additional `more_sources` positional (`sync.py REF A B ...`), the `second` positional itself keeps its name |
255
255
  | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only`; `--versions streaming,ebu-r128,broadcast-jp` (or `LUFS/TP` pairs) writes one file per target from one master, each measured after and listed in `versions` (`output` is null), `verified` only when every version met its own target |
256
256
 
@@ -350,7 +350,7 @@ The contract is generated from the code that runs, not maintained beside it. For
350
350
 
351
351
  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. It is empty in 3.0.0: Python 3.9 and FFmpeg 5.x, the two 2.x entries, moved to `removed` with what 2.0 removed, and `docs/contract.md` "What 2.0 changed" and "What 3.0 changed" are written from that.
352
352
 
353
- `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).
353
+ `contract_version` (2.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).
354
354
 
355
355
  ### MCP
356
356
 
@@ -470,7 +470,7 @@ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools
470
470
 
471
471
  ### Does it validate the filter graph before running?
472
472
 
473
- No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--transcribe` (with `--filler` or a sentence flag) also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
473
+ No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--transcribe` (with `--filler` or a sentence flag) also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, and `render` and `batch` run each stage or step as its own dry run, so a measuring stage measures an input that exists. `verify` accepts `--dry-run` and ignores it: its steps run, and its document says `dry_run: false`. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
474
474
 
475
475
  ### Can it remove burned-in subtitles or logos?
476
476
 
@@ -617,14 +617,16 @@ The same repository installs as a plugin in two more hosts (checked against Code
617
617
  ```bash
618
618
  codex plugin marketplace add kajisho5/ffmpeg-skill --sparse .claude-plugin # Codex: add the marketplace,
619
619
  codex plugin add ffmpeg-skill@ffmpeg-skill # then the plugin
620
- gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI
620
+ gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI (clones the whole repository, see below)
621
621
  ```
622
622
 
623
623
  Those hosts find a plugin's skill only at `skills/ffmpeg-skill/SKILL.md`, a generated copy of the root `SKILL.md` (`.github/scripts/build_plugin_skill.py`; a test fails when it is stale). A Cursor plugin manifest (`.cursor-plugin/plugin.json`) is in the repository for the Cursor Marketplace; it follows Cursor's reference and has not been through their review yet.
624
624
 
625
625
  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 names the `ffmpeg-skill/` directory of the `plugin` branch: the 2.7 MB a running skill reads, rebuilt by every release, and not the 28 MB repository. `--sparse .claude-plugin` keeps the marketplace checkout small as well (measured at 3.4.0: 1.1 MB instead of 50 MB in Claude Code 2.1.322, 2.2 MB instead of 66 MB in Codex 0.167); without it the install is the same and only that checkout is larger. A `git-subdir` source needs Claude Code 2.1.69 or later (2.1.288 or later where git is older than 2.39, such as Ubuntu 22.04, by Claude Code's changelog; not run here); on anything older, update Claude Code or use `npx ffmpeg-skill`. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
626
626
 
627
- 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).
627
+ Gemini CLI is the exception to that size: `gemini extensions install` with a repository URL clones the default branch, so that install is the whole repository (about 28 MB: tests, evals, the demo gallery), not the plugin branch. It works, and it is the only route here that copies more than the skill needs.
628
+
629
+ Without Node: clone this repository and copy `SKILL.md`, `package.json`, `LICENSE`, `scripts/`, `templates/`, `mcp/`, `references/` and the one file `docs/contract.md` into your agent's skills directory. That is the set `npx ffmpeg-skill` installs, plus `references/process-pitfalls.md`, a maintainer diary the package leaves out. Do not copy the rest of `docs/`: in a clone it is about 14 MB of demo-gallery GIFs that no tool reads. Without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown.
628
630
 
629
631
  After installing:
630
632
 
@@ -664,8 +666,8 @@ FFmpeg itself:
664
666
  npm test # tests/run_parallel.py (test_all.py's end-to-end modules, sharded across the cores) + tests/test_contract.py
665
667
  python3 tests/test_picture.py # one tool group on its own (analysis, editing, cut_copy, audio, picture, delivery, orchestration, accel, asr)
666
668
  npm run release-check # pack, install, contract from the installed copy, MCP == contract, doctor, tests, contract evals
667
- npm run demo # python3 demos/build.py: synthetic footage -> every before/after demo + docs/demos/*.gif
668
- npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run of every script
669
+ npm run demo # python3 demos/build.py: synthetic footage (and two AI-generated clips) -> every before/after demo + docs/demos/*.gif
670
+ npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run, one clip through 13 of the 42 scripts
669
671
  python3 evals/run.py --list # agent eval prompts (see evals/); regex-only grading, runnable from any agent harness -- see evals/README.md "Running this from Cursor, Codex, or another harness"
670
672
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
671
673
  ```
@@ -674,7 +676,7 @@ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `mai
674
676
 
675
677
  `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.
676
678
 
677
- **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.
679
+ **Releasing** is fully automated end to end, including the version number itself — a PR never needs to touch `package.json` or the version in `docs/contract.md`. `CHANGELOG.md` is different: the release writes the version heading and one line per merged PR, but a `feat` PR writes its own notes under `## Unreleased` (CONTRIBUTING.md), and a PR that closes an issue must name that issue in `CHANGELOG.md` or `test_changelog_mentions_every_closed_issue_since_last_tag` fails. 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. If another PR merged while the bump was being prepared, the push is refused and that run releases nothing: the newer merge's own run, queued behind it, starts from `main`'s tip and releases both (the bump is never rebased onto a merge it did not read). After the tag it also asks `pages.yml` to rebuild the site, because the bump commit carries `[skip ci]` and would otherwise leave the site on the previous version. 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.
678
680
 
679
681
  **A version that was tagged but never reached npm** (the npm step failed; a re-run of that release sees the tag and skips the publish) is finished by dispatching the same workflow on its tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`. That runs only the workflow's `recover` job. It refuses a version that is not `X.Y.Z`, has no tag, or that npm already has, and a run whose commit is not the tagged one: the npm provenance attestation names the run's commit, so it must be the commit the package is built from. It publishes with `--provenance` under the `latest` dist-tag when the version is above every stable (non-prerelease) version npm has, and under `backport` otherwise, so `latest` never moves back. Then it publishes the `plugin` branch when no commit there is for this version and the branch's tip is older, and the MCP Registry entry when the registry does not list the version (never for a `backport`). The GitHub Release is left as it is. A tag cut before the job was added (#448; every tag up to v3.6.3) has no `recover` job at its commit and cannot be recovered this way.
680
682
 
package/SKILL.md CHANGED
@@ -15,8 +15,8 @@ Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
15
15
  `--overwrite` (step 7), `--timeout SECONDS` (default 1800, `0` = none; `kind: timeout`).
16
16
 
17
17
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
18
- `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`) may still run
19
- ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
18
+ `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
19
+ `batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
20
20
 
21
21
  Audio-only files work in the audio tools; picture tools refuse them (`references/workflow.md#audio-only-files`).
22
22
 
@@ -146,6 +146,8 @@ Variants, caveats and extra flags per row: `references/requests.md`.
146
146
  | "wider stereo" | `audio.py band.wav --stereo-widen 0.5` |
147
147
  | "swap in the narration track" | `audio.py in.mp4 --replace narration.wav` |
148
148
  | "give me the sound as WAV" | `audio.py in.mp4 -o in.wav` |
149
+ | "speed the line up, same pitch", "exactly 96000 samples long" | `audio.py line.wav --tempo 1.2 --length-samples 96000 -o fit.wav` |
150
+ | "just the third mic", "resample to 48 kHz" | `audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav` |
149
151
  | "compress the voice", "limit the peaks", "gate the noise" | `audio.py in.mp4 --compress` (or `--limit`, `--gate`) |
150
152
  | "audiogram", "turn this podcast into a video" | `render.py --template audiogram ep.m4a --image cover.png` |
151
153
  | "make this a Reel / TikTok / Short / YouTube" | `render.py --template reels in.mp4 --cues cues.txt` |
package/bin/install.js CHANGED
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * ffmpeg-skill installer.
4
4
  *
5
- * Copies SKILL.md and scripts/ into the skills directory of one or more
5
+ * Copies the skill (SKILL.md, scripts/, templates/, mcp/, the shipped references and
6
+ * docs/contract.md, package.json, LICENSE) into the skills directory of one or more
6
7
  * coding agents. Default target is Claude Code (~/.claude/skills/ffmpeg-skill).
7
8
  *
8
9
  * npx ffmpeg-skill # Claude Code
@@ -28,7 +29,43 @@ const { spawnSync } = require('child_process');
28
29
 
29
30
  const SKILL_NAME = 'ffmpeg-skill';
30
31
  const ROOT = path.resolve(__dirname, '..');
31
- const PAYLOAD = ['SKILL.md', 'scripts', 'templates', 'references', 'docs', 'mcp', 'package.json'];
32
+ const PAYLOAD = ['SKILL.md', 'scripts', 'templates', 'references', 'docs', 'mcp', 'package.json', 'LICENSE'];
33
+
34
+ // What of each PAYLOAD item is copied. package.json's "files" is the allow-list of what ships: it
35
+ // names whole directories (scripts/, templates/, mcp/) and, for references/ and docs/, single
36
+ // files. In the npm package those two directories hold nothing else, but in a git checkout docs/
37
+ // also holds the demo gallery and references/ the maintainer diary (17 MB against 2.5 MB), so a
38
+ // directory that "files" lists file by file is copied file by file.
39
+ function payloadPaths(root) {
40
+ let files = [];
41
+ try {
42
+ files = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).files || [];
43
+ } catch (_) { /* no readable package.json: copy the items whole, as before */ }
44
+ const out = [];
45
+ for (const item of PAYLOAD) {
46
+ const listed = files.filter((f) => f.startsWith(`${item}/`) && f.length > item.length + 1);
47
+ out.push(...(listed.length ? listed : [item]));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // The installer's own flags. Anything else is refused before a file is touched: an unknown flag
53
+ // used to be ignored, so `--uninstall --cursr` (a typo) or `--uninstall --dir` with no value fell
54
+ // through to the default target and removed the Claude Code install with exit 0.
55
+ const FLAGS = ['--claude', '--cursor', '--codex', '--all', '--dir', '--project', '--uninstall', '--help', '-h'];
56
+
57
+ // null when the arguments are ones the installer knows, else the line to print.
58
+ function argumentError(argv) {
59
+ const dirAt = argv.indexOf('--dir');
60
+ if (dirAt !== -1 && (!argv[dirAt + 1] || argv[dirAt + 1].startsWith('-'))) {
61
+ return 'error: --dir needs a directory (for one whose name starts with "-", write ./-name)';
62
+ }
63
+ const unknown = argv.filter((a, i) => !FLAGS.includes(a) && !(dirAt !== -1 && i === dirAt + 1));
64
+ if (unknown.length) {
65
+ return `error: unknown argument${unknown.length > 1 ? 's' : ''} ${unknown.join(' ')} (known: ${FLAGS.join(', ')}; or the commands contract, doctor, mcp). Nothing was installed or removed.`;
66
+ }
67
+ return null;
68
+ }
32
69
 
33
70
  const args = process.argv.slice(2);
34
71
  const has = (flag) => args.includes(flag);
@@ -118,6 +155,12 @@ if (args[0] === 'mcp') {
118
155
  process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
119
156
  }
120
157
 
158
+ const badArgs = argumentError(args);
159
+ if (badArgs) {
160
+ console.error(badArgs);
161
+ process.exit(2);
162
+ }
163
+
121
164
  const home = os.homedir();
122
165
  const targets = [];
123
166
  const want = { claude: has('--claude'), cursor: has('--cursor'), codex: has('--codex') };
@@ -145,6 +188,7 @@ function copyRecursive(src, dst) {
145
188
  copyRecursive(path.join(src, entry), path.join(dst, entry));
146
189
  }
147
190
  } else {
191
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
148
192
  fs.copyFileSync(src, dst);
149
193
  if (src.endsWith('.py')) fs.chmodSync(dst, 0o755);
150
194
  }
@@ -168,12 +212,39 @@ function checkFfmpeg() {
168
212
  return true;
169
213
  }
170
214
 
215
+ // Scratch directories an install that was killed (or that failed before this was cleaned up in
216
+ // the catch below) left next to the target: <name>.tmp-<pid>, each a partial copy with a SKILL.md.
217
+ function staleScratchDirs(dir) {
218
+ const parent = path.dirname(dir);
219
+ const prefix = `${path.basename(dir)}.tmp-`;
220
+ let entries = [];
221
+ try { entries = fs.readdirSync(parent); } catch (_) { return []; }
222
+ return entries
223
+ .filter((e) => e.startsWith(prefix) && /^\d+$/.test(e.slice(prefix.length)))
224
+ .filter((e) => !processIsRunning(Number(e.slice(prefix.length))))
225
+ .map((e) => path.join(parent, e));
226
+ }
227
+
228
+ // The number after ".tmp-" is the pid of the install that made the directory. One whose process
229
+ // is still running is that install's work in progress, not a leftover: removing it lets the
230
+ // install recreate the directory with only the files it had not copied yet and then rename that
231
+ // into place as a finished install. Signal 0 asks without signalling: ESRCH is "no such
232
+ // process"; EPERM is a live process this user may not signal, so it counts as running.
233
+ function processIsRunning(pid) {
234
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; }
235
+ }
236
+
171
237
  let failed = false;
172
238
  for (const t of targets) {
239
+ let tmpDir = null;
173
240
  try {
174
241
  if (has('--uninstall')) {
175
242
  fs.rmSync(t.dir, { recursive: true, force: true });
176
243
  console.log(`removed ${t.label}: ${t.dir}`);
244
+ for (const stale of staleScratchDirs(t.dir)) {
245
+ fs.rmSync(stale, { recursive: true, force: true });
246
+ console.log(`removed ${t.label} (left by an interrupted install): ${stale}`);
247
+ }
177
248
  // A copy left by the installer's earlier, wrong guess at this agent's directory (see the
178
249
  // Codex target above) would otherwise survive every uninstall from here on.
179
250
  if (t.legacy && fs.existsSync(t.legacy)) {
@@ -188,10 +259,10 @@ for (const t of targets) {
188
259
  // empty or half-populated; now it leaves the previous install untouched (first run: no
189
260
  // previous install to preserve, so an interruption here still leaves nothing, same as
190
261
  // before -- the guarantee is specifically for an upgrade of an existing install).
191
- const tmpDir = `${t.dir}.tmp-${process.pid}`;
262
+ tmpDir = `${t.dir}.tmp-${process.pid}`;
192
263
  fs.rmSync(tmpDir, { recursive: true, force: true });
193
264
  fs.mkdirSync(tmpDir, { recursive: true });
194
- for (const item of PAYLOAD) {
265
+ for (const item of payloadPaths(ROOT)) {
195
266
  const src = path.join(ROOT, item);
196
267
  if (!fs.existsSync(src)) { if (item !== 'SKILL.md' && item !== 'scripts') continue; throw new Error(`missing ${item} in package`); }
197
268
  copyRecursive(src, path.join(tmpDir, item));
@@ -214,6 +285,9 @@ for (const t of targets) {
214
285
  } catch (err) {
215
286
  failed = true;
216
287
  console.error(`failed for ${t.label} (${t.dir}): ${err.message}`);
288
+ // The partial copy is of no use to anyone and holds a SKILL.md, so an agent scanning the
289
+ // skills folder would find a second, broken skill there. Nothing else ever removed it.
290
+ if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* reported above */ } }
217
291
  }
218
292
  }
219
293
 
@@ -229,4 +303,4 @@ if (require.main === module) {
229
303
  main();
230
304
  }
231
305
 
232
- module.exports = { pythonCandidates, runWithPython, findPython, warnMissingPython, ffmpegUnsupported, FFMPEG_UNSUPPORTED, PYTHON_PROBE };
306
+ module.exports = { argumentError, payloadPaths, PAYLOAD, FLAGS, pythonCandidates, runWithPython, findPython, warnMissingPython, ffmpegUnsupported, FFMPEG_UNSUPPORTED, PYTHON_PROBE };
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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.6.6`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.7.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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.6.6", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.7.0", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -250,8 +250,12 @@ read-only (ffprobe still runs); `sync`, `multicam`, `scenes`, `cropdetect`, `rep
250
250
  `loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
251
251
  tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
252
252
  generated `.ass`), `overlay` measures the lines of a multi-line `--text` with one drawtext on a
253
- generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0 (it draws them one by one there), and `verify`
254
- does not support dry-run (its steps run). `SKILL.md` and
253
+ generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0 (it draws them one by one there), `metadata`
254
+ with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement,
255
+ `render` and `batch` run each stage or step as its own dry run (a measuring stage measures an
256
+ input that exists), and `verify` does not support dry-run (its steps run, and its document says
257
+ `dry_run: false`). `tests/test_contract.py` runs every tool's dry run behind an ffmpeg that logs
258
+ each decode and asserts that every tool that decoded is in `DRY_RUN_ANALYSIS`. `SKILL.md` and
255
259
  `references/scripts.md` repeat the same list; the contract is the authority.
256
260
 
257
261
  `--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
@@ -671,7 +675,18 @@ Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — t
671
675
  `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
672
676
  `--music` the `music_volume` plus a `duck` object naming the threshold (dB and
673
677
  linear), ratio, attack and release actually used, or `null` when `--duck` was not
674
- given. `loudness.py` reports `measured` (the input's loudnorm measurement,
678
+ given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
679
+ the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
680
+ and the rate that count is in (`null` before a pending input is measured); with
681
+ `--length-samples` a `length_samples` verification step decodes the written track and counts its
682
+ samples (`expected`, `measured`, `tolerance_samples`: 0 for PCM/FLAC/ALAC, one codec frame for a
683
+ lossy codec; `reason: not_measured` and `ok: false` when the count cannot be read).
684
+ Since #487 `audio.py --sample-rate HZ` resamples the output and `--channel N` keeps one channel by
685
+ index (0-based); the `audio` block reports `channel` and `sample_rate` (`null` when not asked), and
686
+ each is read back from the output by probe as a verification step, `sample_rate` and `channels`
687
+ (`{expected, measured}`; 1 channel, or 2 with `--stereo`; `reason: not_measured` when probe cannot
688
+ read it).
689
+ `loudness.py` reports `measured` (the input's loudnorm measurement,
675
690
  including `input_lra`) and `targets` (the requested lufs / tp / lra).
676
691
  `check.py --platform podcast` adds two informational rows to `checks`,
677
692
  `channels` and `chapters`.
@@ -690,7 +705,7 @@ Per-tool keys added in 1.17, all additive:
690
705
 
691
706
  | key | tool | what it holds |
692
707
  |---|---|---|
693
- | `fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`, `shrunk`, `fit_scope`, `fit_exhausted` | `caption.py` | siblings inside the same `caption` block: which mode fitted the size (`auto`/`on`/`off`), the size asked for and the size used in ASS units, the floor (13 = 4.5 % of the frame height), that size as a percentage of the frame, how many cues the shrink rescued, `file` or `cue` scope, whether the floor was reached with cues still split, and `size_source` (`input`, or `platform-frame` when a plan was written before the input existed). `size_used` is `null` when there was no geometry to fit against at all |
708
+ | `fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`, `shrunk`, `fit_scope`, `fit_exhausted` | `caption.py` | siblings inside the same `caption` block: which mode fitted the size (`auto`/`on`/`off`), the size asked for and the size used in ASS units, the floor (13 = 4.5 % of the frame height), that size as a percentage of the frame, how many cues the shrink rescued, `file` or `cue` scope, whether the floor was reached with cues still split, and `size_source` (`input`, or `platform-frame` when a plan was written before the input existed). `size_used` is `null` when there was no geometry to fit against at all, and when no fit ran (a `--transcribe --dry-run` has no cues yet; a stated `--size` or `--fit-size off` is the size used and is reported); `fit_scope` is the scope applied: `cue` only on the generated-ASS route, `file` (with a `notes` line) when `--fit-size-scope cue` met the plain SRT burn |
694
709
  | `beats`, `beat_grid` | `scenes.py --beats` | the measured beat times, and `{supported_beats, tempo_bpm, interval, confidence, phase, onsets, supported, unsupported, method, step_s, range_bpm, usable}`. `supported_beats` is the subset of the regular grid that a measured onset marks — the only list a tool that moves a cut may snap to. `usable` is `confidence >= --min-confidence`; a low confidence is reported, not refused — `scenes.py` measures, it does not act |
695
710
  | `snap` | `cut.py --snap beats`, `render.py` | `{mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}`. `grid` is `"supported"`: points are moved only onto grid points a measured onset marks, never onto the regular grid's continuation through a silent passage. `moved` has exactly one row per in/out point given (`from`, `to`, `delta`, `snapped`, `beat_index`) — a point is never added or dropped, and `to` is always either a measured beat or the caller's own value |
696
711
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
@@ -906,6 +921,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
906
921
  A refusal also carries `error.reason`, a stable snake_case code for *why*
907
922
  (`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
908
923
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
924
+ `output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
909
925
  `media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
910
926
  `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
911
927
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.6",
3
+ "version": "3.7.0",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -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` 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), `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, `render` and `batch` run each stage or step as its own dry run (so a measuring stage measures an input that exists), and `verify` accepts the flag but ignores it entirely (its document says `dry_run: false` and a note says so). 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
 
@@ -1698,7 +1698,11 @@ geometry to measure. With `--platform` the destination's own frame is used —
1698
1698
  that frame *is* what the real run will have, so the planned `FontSize` is the
1699
1699
  one the run will burn. Without a platform nothing can stand in for the frame
1700
1700
  and `size_used` is `null`, rather than presenting the requested size as a
1701
- fitted one.
1701
+ fitted one. It is `null` too under `--transcribe --dry-run`, where there are no
1702
+ cues to fit yet. `--fit-size-scope cue` sizes each cue only on the generated-ASS
1703
+ route (`--animate`, `--karaoke`, emoji overlays); a plain SRT burn draws one size
1704
+ and reports `fit_scope: file` with a note. With a caller's own `--ass` the file sets its
1705
+ sizes itself, so `fit_scope` is `null` with a note.
1702
1706
 
1703
1707
  | ASS size | px on 1920 | % frame h | em per line |
1704
1708
  |---|---|---|---|
@@ -2025,7 +2029,8 @@ it) or the copy fails, the video is re-encoded with the tags on its frames
2025
2029
  `verification`'s `color_tags` step. Tags that are still wrong after the
2026
2030
  re-encode fail the run with `kind: verification`. The re-encode is H.264
2027
2031
  unless `--codec` says otherwise, so an output container that cannot hold it
2028
- (`.webm`) fails with `kind: ffmpeg` instead of completing with the old tags.
2032
+ (`.webm`) is refused before it runs (`kind: input`, reason `output_container_codecs`) instead of
2033
+ completing with the old tags.
2029
2034
 
2030
2035
  ### audio.py — clean-up, music, ducking, layout
2031
2036
  ```
@@ -2033,7 +2038,8 @@ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25
2033
2038
  [--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
2034
2039
  [--duck-attack 20] [--duck-release 400]] [--music-loop]]
2035
2040
  [--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
2036
- [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
2041
+ [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE]
2042
+ [--tempo FACTOR] [--length-samples N] [--channel N] [--sample-rate HZ] [-o OUT]
2037
2043
  ```
2038
2044
  `--denoise` is `afftdn=nf=-N:tn=1`, where N is `--denoise-strength`, the noise
2039
2045
  floor in dB below full scale (20..60, default 25). It is the starting floor that
@@ -2047,7 +2053,7 @@ produced. The exact filter chains:
2047
2053
  | --- | --- |
2048
2054
  | `light` | `highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1` |
2049
2055
  | `medium` | `highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2` |
2050
- | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled` |
2056
+ | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1` |
2051
2057
 
2052
2058
  `light` for a good room (rumble and level only, noise floor and sibilance left
2053
2059
  alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
@@ -2084,6 +2090,29 @@ alone comes out 7.7 dB lower than it went in (9.9 dB on 7.1; follow with `loudne
2084
2090
  leaves a 1-channel input untouched and downmixes >2 channels through
2085
2091
  swresample. Video is always stream-copied, and so is a subtitle/data track
2086
2092
  when the container can hold it (`dropped_non_av_streams` says when it could not).
2093
+ `--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
2094
+ look-ahead is compensated, so the limited sound starts on the input's own sample instead of
2095
+ `--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476).
2096
+ With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
2097
+ source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
2098
+ so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
2099
+ keeps its start and still ends with the picture (a trim on the timestamp follows the count).
2100
+ A fade-out ends where the output ends, which is `--length-samples` when that is given.
2101
+ `--tempo FACTOR` (0.1..10) changes the main track's tempo and keeps its pitch: `atempo` stages of
2102
+ 0.5..2.0 whose product is the factor (3 is `atempo=2,atempo=1.5`, 0.3 is `atempo=0.5,atempo=0.6`).
2103
+ atempo's own output length is close to, not exactly, the input's divided by the factor, so
2104
+ `--length-samples N` pads with silence or trims the output to exactly N samples per channel at the
2105
+ track's rate; the result's `length_samples` verification step decodes the written track and counts
2106
+ them (exact for PCM/FLAC/ALAC, within one codec frame for a lossy codec). Both need an audio output,
2107
+ or for `--tempo` a `--replace` track beside the picture: the input's own sound beside its
2108
+ stream-copied picture would drift from it (`fit.py --method speed` retimes both). `timing` reports
2109
+ `{tempo, atempo, length_samples, sample_rate}`.
2110
+ `--channel N` keeps channel N of the track (0-based, `pan=mono|c0=cN`, first in the chain) as a
2111
+ 1-channel output, or both sides of a stereo one with `--stereo`; an index past the track's channels
2112
+ and `--downmix` are refused. `--sample-rate HZ` (1000..768000) resamples the output (`aresample`,
2113
+ after the mix and the fades; `--length-samples` counts in this rate). Each is read back from the
2114
+ output by probe as a verification step, `channels` and `sample_rate` (`expected`, `measured`), and
2115
+ the `audio` block reports `channel` and `sample_rate`.
2087
2116
  Run `loudness.py` after this for final levels.
2088
2117
 
2089
2118
  ### loudness.py — EBU R128 normalisation
@@ -25,6 +25,7 @@ import threading
25
25
  from typing import Any, Dict, List, Optional
26
26
 
27
27
  from _common import runner as _runner
28
+ from _common.color import carried_tag
28
29
  from _common.runner import STATE, ffmpeg_encoders, require_tool, run
29
30
 
30
31
  # A pixel format that carries an alpha plane (yuva*, rgba, argb, gbrap, ya8/ya16, ...).
@@ -284,10 +285,12 @@ _TAG_KEYS = (("colorspace", "color_space", "-colorspace"), ("color_primaries", "
284
285
  ("color_trc", "color_transfer", "-color_trc"))
285
286
 
286
287
 
287
- def _source_tags(meta: Optional[Dict[str, Any]]) -> List[tuple]:
288
+ def _source_tags(meta: Optional[Dict[str, Any]], dest: str = "option") -> List[tuple]:
289
+ """(setparams name, value, output option) per source tag that is passed on, the value spelled
290
+ for `dest` (carried_tag: an RGB source's `gbr` is left out, #454)."""
288
291
  v = (meta or {}).get("video") or {}
289
- return [(param, v.get(key), flag) for param, key, flag in _TAG_KEYS
290
- if v.get(key) and v.get(key) not in ("unknown", "reserved", "unspecified")]
292
+ tags = [(param, carried_tag(param, v.get(key), dest), flag) for param, key, flag in _TAG_KEYS]
293
+ return [t for t in tags if t[1]]
291
294
 
292
295
 
293
296
  def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]] = None) -> str:
@@ -295,7 +298,7 @@ def finish_chain(codec: str, mode: Optional[str], meta: Optional[Dict[str, Any]]
295
298
  when asked, the source's colour tags put back on the frames (`setparams`: a grade's RGB legs
296
299
  and an RGB layer's conversion relabel them, BT.601 or unknown), then the line's own pixel
297
300
  format (so no later conversion can drop the plane)."""
298
- tags = _source_tags(meta)
301
+ tags = _source_tags(meta, "setparams")
299
302
  label = ("setparams=" + ":".join(f"{p}={val}" for p, val, _ in tags) + ",") if tags else ""
300
303
  # premultiplied colour is colour x alpha in RGB: multiplied there (a YUV premultiply scales the
301
304
  # limited-range offsets too), after the tags are back so both conversions use the same matrix
@@ -57,6 +57,36 @@ NON_BT709_MATRICES = ("smpte170m", "bt470bg", "fcc", "smpte240m")
57
57
  _RGB_PIX_FMT_RE = re.compile(r"^(?:rgb|bgr|argb|abgr|0rgb|0bgr|gbr|x2rgb|x2bgr|pal8)")
58
58
 
59
59
 
60
+ # A probed colour tag passed back to FFmpeg (#454). ffprobe prints av_color_*_name(), which is
61
+ # also the setparams filter's table; the output options -colorspace/-color_primaries/-color_trc
62
+ # parse an older one that differs on three names (6.1, measured): -colorspace refuses `gbr` (its
63
+ # name is `rgb`) and -color_trc refuses `bt470m` and `bt470bg` (its names: `gamma22`, `gamma28`).
64
+ # `gbr` is not carried over at all: it says the stored samples are RGB, which is the pixel
65
+ # format's to say (the PNG encoder tags it itself), and a tool that converts the picture to YUV on
66
+ # the way (a grade's legs, a 4:4:4 alpha line) would label YUV samples as RGB with it.
67
+ _TAG_KIND = {"-colorspace": "colorspace", "-color_primaries": "color_primaries", "-color_trc": "color_trc"}
68
+ _OPTION_NAMES = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
69
+
70
+
71
+ def carried_tag(kind: str, name: Optional[str], dest: str = "option") -> Optional[str]:
72
+ """The probed tag `name` of `kind` (colorspace, color_primaries, color_trc) spelled the way
73
+ `dest` ("option": -colorspace & co; "setparams") accepts it, or None when it is not to be
74
+ passed on: unknown, unspecified, reserved, or the RGB `gbr`/`rgb`."""
75
+ if not name or name in ("unknown", "unspecified", "reserved") or (kind == "colorspace" and name in ("gbr", "rgb")):
76
+ return None
77
+ return _OPTION_NAMES.get(kind, {}).get(name, name) if dest == "option" else name
78
+
79
+
80
+ def carried_tag_args(pairs: Sequence[Tuple[str, Optional[str]]]) -> List[str]:
81
+ """[flag, value, ...] for each (flag, probed name) whose tag carried_tag() passes on."""
82
+ out: List[str] = []
83
+ for flag, name in pairs:
84
+ value = carried_tag(_TAG_KIND.get(flag, flag.lstrip("-")), name)
85
+ if value:
86
+ out += [flag, value]
87
+ return out
88
+
89
+
60
90
  def rgb_to_bt709_filter(pix_fmt: str = "yuv444p") -> str:
61
91
  """An RGB picture converted to BT.709 limited-range YUV right here, in `pix_fmt`, so no later
62
92
  conversion (an auto-inserted one, with FFmpeg's BT.601 default) decides the matrix. 4:4:4 by
@@ -193,6 +193,16 @@ def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
193
193
  return {"value": None, "drop_frame": None, "source": None}
194
194
 
195
195
 
196
+ # EBU R128 integrated loudness is gated over 400 ms blocks: sound shorter than one has a true peak
197
+ # and no integrated loudness. A longer track can read -inf too (every block under the absolute
198
+ # gate), so "too short" is decided on the sound's length, never on a finite peak alone.
199
+ LOUDNESS_BLOCK_SECONDS = 0.4
200
+
201
+
202
+ def shorter_than_a_loudness_block(length: "Optional[float]") -> bool:
203
+ return length is not None and 0 < length < LOUDNESS_BLOCK_SECONDS
204
+
205
+
196
206
  def probe(path: str, role: str = "input") -> Dict[str, Any]:
197
207
  """Return a compact, script-friendly description of a media file.
198
208
 
@@ -297,6 +307,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
297
307
  "codec": s.get("codec_name"),
298
308
  "language": (s.get("tags") or {}).get("language"),
299
309
  "title": (s.get("tags") or {}).get("title"),
310
+ # the stream's default disposition as the file states it (caption.py --mode mux
311
+ # reports a kept track's from here, A60)
312
+ "default": bool((s.get("disposition") or {}).get("default")),
300
313
  } for n, s in enumerate(subs)],
301
314
  }
302
315
  if video: