ffmpeg-skill 3.6.5 → 3.6.7

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
 
@@ -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
 
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.5`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.6.7`) | 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.5", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.6.7", "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,
@@ -690,7 +694,7 @@ Per-tool keys added in 1.17, all additive:
690
694
 
691
695
  | key | tool | what it holds |
692
696
  |---|---|---|
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 |
697
+ | `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
698
  | `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
699
  | `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
700
  | `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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.5",
3
+ "version": "3.6.7",
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
  |---|---|---|---|
@@ -74,7 +74,8 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
74
74
  _EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
75
75
  _MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
76
76
  % (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
77
- _INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
77
+ # [N:v], [N:v:0] and [N:V:0] (PROBED_VIDEO: the motion picture, not an attached cover)
78
+ _INPUT_VIDEO_REF = re.compile(r"\[(\d+):[vV](?::0)?\]")
78
79
 
79
80
 
80
81
  def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
@@ -263,7 +264,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
263
264
 
264
265
  - no other filter: unchanged (the -vf is the graph);
265
266
  - a -vf: the conversion is prepended to that chain;
266
- - a -filter_complex: every `[N:v]` / `[N:v:0]` reference to an input that reads the same file
267
+ - a -filter_complex: every `[N:v]` / `[N:v:0]` / `[N:V:0]` reference to an input that reads the same file
267
268
  as input 0 (the file the encoder line was built for; cut.py's segment join opens it once
268
269
  per segment) reads that input through the conversion instead. Other inputs are untouched.
269
270
 
@@ -298,7 +299,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
298
299
  graph = _INPUT_VIDEO_REF.sub(through, str(out[graph_at + 1]))
299
300
  if not refs:
300
301
  die("internal: a BT.709 matrix conversion was requested for a -filter_complex that never reads "
301
- "input 0's video as [0:v]", kind="ffmpeg")
302
+ "input 0's video as [0:v] or [0:V:0]", kind="ffmpeg")
302
303
  # one conversion per input, split when the graph reads that input more than once
303
304
  # (speedramp.py trims [0:v] once per segment)
304
305
  chains = [f"{ref}{conv}" + (f",split={len(labels)}" if len(labels) > 1 else "") + "".join(labels)
@@ -15,6 +15,14 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
15
15
  from _common.emit import die, warn
16
16
  from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
17
17
 
18
+ # The streams probe() describes, as stream specifiers: its `video` is the first picture that is not
19
+ # a cover (`V`), its `audio` the first sound stream. Without a -map, FFmpeg picks the largest
20
+ # picture and the sound with the default disposition or the most channels, so on a file with two
21
+ # of either a tool measured one stream and wrote another: loudness measured track 1 and applied the
22
+ # gain to track 0, cut and crop wrote the larger picture they never probed (A110, A118, A128, A258).
23
+ PROBED_VIDEO = "V:0"
24
+ PROBED_AUDIO = "a:0"
25
+
18
26
 
19
27
  def fingerprint(path: str) -> Dict[str, Any]:
20
28
  """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
@@ -50,7 +58,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
50
58
  # first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
51
59
  # so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
52
60
  # starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
53
- cmd += ["-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
61
+ # the first audio track, the one probe() describes (default selection takes the most channels)
62
+ cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
54
63
  proc = run_analysis(cmd, check=False, text=False)
55
64
  if proc.returncode != 0 or not proc.stdout:
56
65
  if check:
@@ -99,7 +108,7 @@ def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start:
99
108
  cmd += ["-i", path]
100
109
  if seconds is not None:
101
110
  cmd += ["-t", f"{max(0.0, seconds):.3f}"]
102
- cmd += ["-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
111
+ cmd += ["-map", f"0:{PROBED_VIDEO}", "-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
103
112
  "-f", "rawvideo", "-"]
104
113
  proc = run_analysis(cmd, check=False, text=False)
105
114
  if proc.returncode != 0 or not proc.stdout:
@@ -184,6 +193,16 @@ def start_timecode(streams: List[Dict[str, Any]], fmt: Dict[str, Any],
184
193
  return {"value": None, "drop_frame": None, "source": None}
185
194
 
186
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
+
187
206
  def probe(path: str, role: str = "input") -> Dict[str, Any]:
188
207
  """Return a compact, script-friendly description of a media file.
189
208
 
@@ -288,6 +307,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
288
307
  "codec": s.get("codec_name"),
289
308
  "language": (s.get("tags") or {}).get("language"),
290
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")),
291
313
  } for n, s in enumerate(subs)],
292
314
  }
293
315
  if video:
@@ -422,6 +444,26 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
422
444
  _CARRY: Dict[str, Dict[str, Any]] = {}
423
445
 
424
446
 
447
+ def probed_maps(meta: Optional[Dict[str, Any]], *, video: bool = True, audio: bool = True,
448
+ audio_stream: int = 0, input_index: int = 0) -> List[str]:
449
+ """`-map` arguments for the picture and sound probe() describes in `meta` (audio track
450
+ `audio_stream` where a tool takes --audio-stream). A kind the file does not have is left out,
451
+ so the same call serves a silent clip and an audio-only file."""
452
+ out: List[str] = []
453
+ if video and (meta or {}).get("video"):
454
+ out += ["-map", f"{input_index}:{PROBED_VIDEO}"]
455
+ if audio and (meta or {}).get("audio"):
456
+ out += ["-map", f"{input_index}:a:{audio_stream}"]
457
+ return out
458
+
459
+
460
+ def several_av_streams(meta: Optional[Dict[str, Any]]) -> bool:
461
+ """True when FFmpeg's default stream selection could pick a picture or a sound other than the
462
+ one probe() describes: more than one playable video stream or more than one audio stream."""
463
+ counts = (meta or {}).get("stream_counts") or {}
464
+ return (counts.get("video") or 0) > 1 or (counts.get("audio") or 0) > 1
465
+
466
+
425
467
  def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
426
468
  """The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
427
469
  return _CARRY.get(os.path.realpath(path)) if path else None
@@ -628,10 +670,11 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
628
670
  audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
629
671
  """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
630
672
  Cheap enough to run once as a hint when a threshold-based tool found nothing. seconds=None
631
- measures the whole file; audio_stream picks the Nth audio stream (default: ffmpeg's choice)."""
673
+ measures the whole file; audio_stream picks the Nth audio stream (default: the first, the one
674
+ probe() describes; FFmpeg's own choice was the track with the most channels)."""
632
675
  ffmpeg = require_tool("ffmpeg")
633
676
  head = ["-t", f"{seconds:.0f}"] if seconds is not None else []
634
- pick = ["-map", f"0:a:{audio_stream}"] if audio_stream is not None else []
677
+ pick = ["-map", f"0:a:{audio_stream or 0}"]
635
678
  proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", *head, "-i", path, *pick, "-vn",
636
679
  "-af", "volumedetect", "-f", "null", "-"], check=False)
637
680
  m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
@@ -751,7 +794,7 @@ def measure_signal_range(path: str, standard: str, window: float = 10.0,
751
794
  stats_file = os.path.join(tmp, "stats.txt")
752
795
  vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
753
796
  f"{mask},signalstats,metadata=print:file=-")
754
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
797
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
755
798
  proc = run_analysis(cmd, check=False, record=True)
756
799
  try:
757
800
  with open(stats_file, encoding="utf-8", errors="replace") as f:
@@ -804,7 +847,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
804
847
  white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
805
848
  """
806
849
  ffmpeg = require_tool("ffmpeg")
807
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
850
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an",
808
851
  "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
809
852
  proc = run_analysis(cmd, check=False)
810
853
  vals: Dict[str, List[float]] = {}
@@ -976,7 +1019,7 @@ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tu
976
1019
  if dry_run_input_pending(path):
977
1020
  return []
978
1021
  ffmpeg = require_tool("ffmpeg")
979
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
1022
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn", "-af",
980
1023
  f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
981
1024
  proc = run_analysis(cmd, check=False, record=True)
982
1025
  if proc.returncode != 0:
@@ -1188,7 +1231,7 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
1188
1231
  a real cut is a one-frame spike. On real footage this roughly doubles precision at
1189
1232
  equal recall compared with the raw scdet threshold."""
1190
1233
  ffmpeg = require_tool("ffmpeg")
1191
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
1234
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf",
1192
1235
  "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
1193
1236
  # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
1194
1237
  # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
@@ -606,12 +606,26 @@ def kept_deliverables() -> List[str]:
606
606
  return [p for p in STATE.delivered if os.path.isfile(p)]
607
607
 
608
608
 
609
+ def written_on_disk() -> List[str]:
610
+ """Files this process wrote itself (run()'s outputs, STATE.written) that are still on disk, the
611
+ deliverables mark_delivered() named excepted. A stopped command's partial output is removed
612
+ before a stop is reported, so it is not among them."""
613
+ delivered = {os.path.realpath(p) for p in STATE.delivered}
614
+ return sorted(p for p in STATE.written if p not in delivered and os.path.isfile(p))
615
+
616
+
609
617
  def kept_clause() -> str:
610
- """How a stopped run's message ends: "nothing was written", or which deliverable it had already
611
- written and keeps (not the finished result: the step that was stopped did not run on it)."""
618
+ """How a stopped run's message ends: "nothing was written" only when no file this run wrote is
619
+ on disk (A182: every tool but export and render said it after writing its output), else which
620
+ deliverable it had already written and keeps (not the finished result: the step that was
621
+ stopped did not run on it), or which files it had written before the stop."""
612
622
  kept = kept_deliverables()
613
623
  if not kept:
614
- return "nothing was written"
624
+ written = written_on_disk()
625
+ if not written:
626
+ return "nothing was written"
627
+ return (f"{', '.join(written)} had already been written by this run before the stop and "
628
+ f"{'is' if len(written) == 1 else 'are'} still on disk")
615
629
  return (f"{', '.join(kept)} had already been written by this run and {'is' if len(kept) == 1 else 'are'} kept, "
616
630
  "without the step that was stopped (pass --overwrite to make it again)")
617
631
 
@@ -2045,6 +2059,26 @@ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
2045
2059
  return True
2046
2060
 
2047
2061
 
2062
+ def default_selection_maps(meta: Optional[Dict[str, Any]], dst: str) -> List[str]:
2063
+ """The -map arguments for a re-encode that would otherwise rely on ffmpeg's default stream
2064
+ selection: none for a file with one picture and one sound (the default picks those, and a
2065
+ subtitle stream the container can hold, as it always did), and with several, the picture
2066
+ and sound probe() describes plus the first subtitle stream when `dst`'s container can hold it
2067
+ -- what the default would have carried, without its largest-picture / most-channels choice
2068
+ (A258, #484 review)."""
2069
+ from _common.probe import probed_maps, several_av_streams # probe imports this module
2070
+ if not several_av_streams(meta):
2071
+ return []
2072
+ maps = probed_maps(meta)
2073
+ subs = (meta or {}).get("subtitle_stream_details") or []
2074
+ codec, ext = (subs[0].get("codec") or "") if subs else "", os.path.splitext(dst)[1]
2075
+ if subs and subtitle_container_holds(codec, ext):
2076
+ # with its own -c:s: the default subtitle encoder does not exist for every container
2077
+ # (none for MP4 in FFmpeg 6.1) and is a text encoder that cannot take a bitmap track
2078
+ maps += ["-map", "0:s:0", "-c:s", subtitle_codec_for(codec, ext)]
2079
+ return maps
2080
+
2081
+
2048
2082
  def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
2049
2083
  """Run an ffmpeg command that already maps its video/audio, trying first to also
2050
2084
  stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when