ffmpeg-skill 3.6.6 → 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.6`) | 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.6", "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.6",
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
  |---|---|---|---|
@@ -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:
@@ -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
 
@@ -241,12 +241,16 @@ DRY_RUN_ANALYSIS = {
241
241
  "sync": "audio is decoded to find the offset; the aligned output is not written",
242
242
  "multicam": "audio is decoded to align the cameras; the switched output is not written",
243
243
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
244
- "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
244
+ "report": "probe and loudness measurements run (and check.py, with --platform); contact sheets are not drawn under --dry-run (a report_section_dry_run skip); the HTML is not written",
245
245
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
246
246
  "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
247
247
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
248
248
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
249
249
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
250
+ "metadata": "only with --auto-chapters: its silence and scene measurement decodes the input so the proposed chapters are real; otherwise no ffmpeg runs, and the tagged output is never written",
251
+ "cut": "only with --snap beats (and no --snap-source): the audio is decoded to find the beat grid the in/out points snap to; otherwise no ffmpeg runs, and the cut is never written",
252
+ "render": "each stage runs as its own --dry-run, so a stage whose input exists measures it as that tool does under --dry-run (a clip's --snap beats cut, a loudness or silence pass on a source); a stage whose input an earlier stage would write is not measured (measurement_input_pending); nothing is written",
253
+ "batch": "each recipe step runs as its own --dry-run on the item's source, so a step that measures (loudness, silence, check, a --snap beats cut, a render project's stages) measures it; nothing is written",
250
254
  "overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
251
255
  }
252
256
  DRY_RUN_NOTES = {
@@ -449,6 +453,7 @@ ERROR_REASONS = {
449
453
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
450
454
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
451
455
  "encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
456
+ "audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
452
457
  "no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
453
458
  "engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
454
459
  "english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
@@ -500,7 +505,8 @@ DIAGNOSTIC_CODES = {
500
505
  "cues_outside_video": ("warnings", "caption.py: cues wholly outside the video are not drawn; {count, duration}"),
501
506
  "asr_language_assumed": ("warnings", "--transcribe: the language was assumed (English) and the transcript is wrong if it is not"),
502
507
  # analysis
503
- "drift_correction_skipped": ("skipped", "sync.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
508
+ "speed_length_placeholder": ("skipped", "render.py --dry-run: a clip's length could not be read before its cut exists, so its speed stage was planned on a stand-in 10 s; {stage, clip, path, placeholder_seconds}"),
509
+ "drift_correction_skipped": ("skipped", "sync.py / multicam.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
504
510
  "sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
505
511
  "cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
506
512
  "filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
@@ -521,7 +527,7 @@ DIAGNOSTIC_CODES = {
521
527
  "normalize_on_by_default": ("warnings", "render.py: a platform export preset with no loudness stage got export.py --normalize by default; {preset}"),
522
528
  # degraded or partial results (part 2)
523
529
  "colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
524
- "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
530
+ "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS and no true peak above the silence floor), so no gain meets the platform's loudness; {platform}"),
525
531
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
526
532
  "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
527
533
  "legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
@@ -529,7 +535,7 @@ DIAGNOSTIC_CODES = {
529
535
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
530
536
  "smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
531
537
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
532
- "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
538
+ "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, or the audio is shorter than one 400 ms gating block (loudness.too_short), so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
533
539
  "loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
534
540
  "report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
535
541
  "report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
@@ -608,11 +614,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
608
614
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
609
615
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
610
616
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
611
- "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
617
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
612
618
  "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
613
619
  "additionalProperties": True}
614
620
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
615
- "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
621
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
616
622
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
617
623
  "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
618
624
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
@@ -630,7 +636,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
630
636
  "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
631
637
  "change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
632
638
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
633
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
639
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN without a length limit) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
634
640
  elif name == "caption":
635
641
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
636
642
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -705,7 +711,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
705
711
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
706
712
  elif name == "export":
707
713
  extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
708
- "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
714
+ "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read, and with too_short true (lufs and ok null, tp measured) when the audio is shorter than one 400 ms gating block, which is not silence"},
709
715
  "alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
710
716
  "key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
711
717
  "fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
@@ -714,9 +720,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
714
720
  "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
715
721
  "notes": {"type": "array", "items": {"type": "string"}}}
716
722
  elif name == "loudness":
717
- extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
723
+ extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result. silent true (input_i \"-inf\") when the audio has no sound; too_short true (input_i null, input_tp measured) when it is shorter than one 400 ms gating block: not silence, and a normalisation is refused with error.reason audio_too_short"},
718
724
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
719
- "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"},
725
+ "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used, encodes and loudnorm_mode (linear, or dynamic when loudnorm could not apply one linear gain: the input's true peak plus the gain passes the ceiling, or its range is 0 or above --lra); note says why the loudness missed the target, naming dynamic mode when that is the cause. A loudness step on an output with no integrated loudness is ok false with reason not_measured"},
720
726
  "versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
721
727
  "outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
722
728
  elif name == "color":
@@ -768,7 +774,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
768
774
  "segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
769
775
  elif name == "multicam":
770
776
  extra = {"cuts": {"type": "array", "description": "[[start, end, camera], ...] on the reference timeline: the camera actually rendered. A range whose camera had not started yet plays camera 0, and diagnostics.fallbacks names the camera asked for (code camera_not_started)"},
771
- "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"}}
777
+ "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"},
778
+ "drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
779
+ "drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
772
780
  elif name == "join":
773
781
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
774
782
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
@@ -1229,7 +1229,9 @@ def main() -> int:
1229
1229
  args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
1230
1230
  fit_unmeasurable = not (play_w and play_h)
1231
1231
  fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
1232
- "size_used": None if fit_unmeasurable else args.size,
1232
+ # A76: null until a fit (or a stated size) decides it; a --transcribe
1233
+ # dry run has no cues, so nothing is fitted and nothing is claimed
1234
+ "size_used": args.size if (args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit)) else None,
1233
1235
  "size_floor": args._fit_floor,
1234
1236
  "size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
1235
1237
  "shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
@@ -1569,14 +1571,23 @@ def main() -> int:
1569
1571
  cmd += [f"-c:s:{i}", existing_out[i]]
1570
1572
  tracks: List[Dict[str, Any]] = []
1571
1573
  converted: List[str] = []
1574
+ # A60: a kept track is reported with the disposition the input gives it (the muxer copies
1575
+ # it), not False whatever the file says; when --default-track makes a new track the
1576
+ # default, the kept ones are cleared so the file does not carry two defaults
1577
+ new_default = bool(args.default_track and any(
1578
+ lang and lang.lower() == args.default_track.lower() for _p, lang in added))
1572
1579
  for i in range(existing_subs):
1573
1580
  detail = existing_details[i] if i < len(existing_details) else {}
1574
1581
  written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
1575
1582
  if existing_out[i] != "copy":
1576
1583
  converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
1584
+ kept_default = bool(detail.get("default")) and not new_default
1585
+ if new_default and detail.get("default"):
1586
+ # "-default" takes that one flag off; "0" would clear forced and the rest with it
1587
+ cmd += [f"-disposition:s:{i}", "-default"]
1577
1588
  tracks.append({"index": i, "file": None, "language": detail.get("language"),
1578
1589
  "title": detail.get("title"), "codec": written,
1579
- "default": False, "cues": None, "kept_from_input": True})
1590
+ "default": kept_default, "cues": None, "kept_from_input": True})
1580
1591
  for n, (path, lang) in enumerate(added):
1581
1592
  idx = existing_subs + n
1582
1593
  cmd += [f"-c:s:{idx}", codec]
@@ -1608,7 +1619,9 @@ def main() -> int:
1608
1619
  tracks.append({"index": idx, "file": path, "language": stored, "title": title,
1609
1620
  "codec": codec, "default": is_default, "cues": cues_n,
1610
1621
  "kept_from_input": False})
1611
- if args.default_track and not any(t["default"] for t in tracks):
1622
+ # asked of the tracks this run adds: a default the input already carried does not make
1623
+ # --default-track fr true when no --srt was tagged fr
1624
+ if args.default_track and not new_default:
1612
1625
  die(f"--default-track {args.default_track}: no --srt was tagged with that language",
1613
1626
  kind="input")
1614
1627
  # MPEG-4 has no way to say "no default subtitle track": the muxer sets the track-header
@@ -1726,6 +1739,19 @@ def main() -> int:
1726
1739
  generated_ass = True
1727
1740
  else:
1728
1741
  generated_ass = False
1742
+ if not generated_ass and caption_stats.get("fit_scope") == "cue":
1743
+ # A67: one size per cue is a {\fsN} override in the generated ASS; the plain SRT burn
1744
+ # draws every cue at one FontSize, so the scope that was applied is the file's, and
1745
+ # saying "cue" described sizes nobody drew. A caller's own --ass sets its sizes itself
1746
+ # (styles, per-cue overrides): this tool decided none of them, so it claims no scope.
1747
+ if args.ass:
1748
+ caption_stats["fit_scope"] = None
1749
+ side_notes.append("--fit-size-scope cue was not applied: the --ass file sets its own sizes, "
1750
+ "which this tool does not read or change")
1751
+ else:
1752
+ caption_stats["fit_scope"] = "file"
1753
+ side_notes.append("--fit-size-scope cue applies on the generated-ASS route (--animate, --karaoke, emoji "
1754
+ "overlays); this burn draws every cue at one size (size_used), so the fit is file-scoped")
1729
1755
 
1730
1756
  if args.ass:
1731
1757
  if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
package/scripts/check.py CHANGED
@@ -48,6 +48,7 @@ from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_
48
48
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
49
49
  from _common.emit import file_identity
50
50
  from _common.probe import PROBED_AUDIO
51
+ from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
51
52
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
52
53
 
53
54
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -459,8 +460,17 @@ def main() -> int:
459
460
  "reason": reason if status != "PASS" else "",
460
461
  "kind": "judgement" if name in JUDGEMENT else "format"})
461
462
 
462
- dur = meta.get("duration") or 0.0
463
- if spec["max_duration"]:
463
+ # A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
464
+ # The row says it was not measured (FAIL under a length limit, WARN without one), and the
465
+ # --content rows that are shares of the running time say so too instead of disappearing.
466
+ dur_read = meta.get("duration")
467
+ dur = dur_read or 0.0
468
+ if dur_read is None:
469
+ row("duration", "FAIL" if spec["max_duration"] else "WARN", "could not measure",
470
+ f"<= {spec['max_duration']:g}s" if spec["max_duration"] else "any",
471
+ "remux the file (export.py --preset copy) so the container states its length, then check again",
472
+ reason="the file does not state its length, so whether it is within the limit is unknown")
473
+ elif spec["max_duration"]:
464
474
  row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
465
475
  else:
466
476
  row("duration", "PASS", f"{dur:.2f}s", "any")
@@ -469,7 +479,9 @@ def main() -> int:
469
479
  w, h = v["width"], v["height"]
470
480
  if v.get("rotation") in (90, -90, 270, -270):
471
481
  w, h = h, w
472
- asp = aspect_name(w, h)
482
+ # A114: the aspect a viewer sees is the displayed frame's (rotation and SAR applied), not
483
+ # the stored grid's: 1440x1080 at SAR 4:3 is a 16:9 picture
484
+ asp = aspect_name(*shown_size(v)) if all(shown_size(v)) else aspect_name(w, h)
473
485
  if spec["aspects"]:
474
486
  row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
475
487
  else:
@@ -478,7 +490,11 @@ def main() -> int:
478
490
  if spec["min_height"]:
479
491
  row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
480
492
  fps = v.get("fps") or 0
481
- if spec["fps_max"]:
493
+ if spec["fps_max"] and not fps:
494
+ # a rate ffprobe did not report is not 0 fps under every limit (the class of A113)
495
+ row("fps", "WARN", "could not measure", f"<= {spec['fps_max']}", "re-encode at a fixed rate (fit.py --fps N)",
496
+ reason="the stream states no frame rate, so whether it is within the limit is unknown")
497
+ elif spec["fps_max"]:
482
498
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
483
499
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
484
500
  if spec["codecs"]:
@@ -517,7 +533,10 @@ def main() -> int:
517
533
  row("video", "FAIL", "none", "video stream", "")
518
534
 
519
535
  size = meta.get("size_bytes") or 0
520
- if spec["max_bytes"]:
536
+ if spec["max_bytes"] and meta.get("size_bytes") is None:
537
+ row("file size", "FAIL", "could not measure", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "",
538
+ reason="the file's size could not be read, so whether it is within the limit is unknown")
539
+ elif spec["max_bytes"]:
521
540
  row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
522
541
 
523
542
  if a:
@@ -569,12 +588,30 @@ def main() -> int:
569
588
  loudness_ran = False
570
589
  if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
571
590
  row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
572
- if not args.no_loudness and spec["lufs"] is not None:
591
+ # A117: --tp alone (custom has no loudness target) asks for the true-peak row, which
592
+ # needs the same measurement pass; without it the run reported 0 failed having measured nothing
593
+ tp_only = spec["lufs"] is None and args.tp is not None
594
+ if not args.no_loudness and (spec["lufs"] is not None or tp_only):
573
595
  lm = measure_loudness(args.input)
574
596
  loudness_ran = bool(lm)
575
597
  if lm and lm["tp"] <= SILENT_PEAK_DBFS:
576
598
  silent_audio(lm["tp"])
577
- if lm:
599
+ if lm and tp_only:
600
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
601
+ elif not lm and tp_only:
602
+ row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
603
+ reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
604
+ elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
605
+ and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
606
+ # A257: shorter than one 400 ms gating block: a true peak but no integrated
607
+ # loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
608
+ # the sound's length: a longer track whose every block is under the gate reads
609
+ # -inf too, and that one still FAILs the target below
610
+ row("loudness", "WARN", "could not measure (shorter than one 400 ms loudness block)",
611
+ f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "",
612
+ reason="integrated loudness is gated over 400 ms blocks; a clip this short has none")
613
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
614
+ elif lm:
578
615
  diff = abs(lm["lufs"] - spec["lufs"])
579
616
  row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
580
617
  reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
@@ -600,6 +637,14 @@ def main() -> int:
600
637
  if args.content:
601
638
  cd = detect_content(args.input, dur, video=bool(v), audio=bool(a))
602
639
  span = dur if dur > 0 else 0.0
640
+ if not span and not dry_run_input_pending(args.input):
641
+ # A113: black, frozen and silence are shares of the running time; with no length
642
+ # read there is nothing to divide by, and dropping the rows read as nothing to report
643
+ for name, stream in (("black", v), ("frozen", v), ("silence", a)):
644
+ if stream:
645
+ row(name, "WARN", "could not measure", "a share of the running time",
646
+ "remux the file (export.py --preset copy) so the container states its length, then check again",
647
+ reason="the file does not state its length, so this share of it could not be worked out")
603
648
  if "black" in cd and span:
604
649
  share = _span_total(cd["black"]) / span
605
650
  row("black", "FAIL" if share >= BLACK_FAIL else ("WARN" if share > BLACK_WARN else "PASS"),
package/scripts/export.py CHANGED
@@ -44,7 +44,7 @@ import os
44
44
  import sys
45
45
  import tempfile
46
46
  from pathlib import Path
47
- from typing import Dict, List
47
+ from typing import Dict, List, Optional
48
48
 
49
49
  from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
50
50
  from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
@@ -54,6 +54,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels
54
54
  from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
55
  from _common.emit import file_identity
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
+ from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
57
58
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
58
59
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
59
60
  # from a literal restated here: before 1.14 they were typed twice and the facebook preset had
@@ -288,17 +289,22 @@ def _luma_stats(path: str) -> "Dict[str, float] | None":
288
289
  return _frame_means([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"], path, "format=gray")
289
290
 
290
291
 
291
- def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False) -> None:
292
- """The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
292
+ def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False,
293
+ tp: Optional[float] = None) -> None:
294
+ """The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse, or,
295
+ with `tp`, the audio is shorter than one 400 ms gating block and has only a true peak): the
293
296
  platform's loudness was not verified, and the document says so -- a `loudness` block with null
294
297
  values and a reason, a failing `loudness` verification step, a warning -- rather than leaving
295
298
  the block out, which read as "nothing to check". check.py reports the same case as a WARN row."""
296
299
  reason = "not_measured"
297
- extra["loudness"] = {"lufs": None, "tp": None, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
298
- "measured": False, "reason": reason, **({"normalized": True} if normalized else {})}
300
+ extra["loudness"] = {"lufs": None, "tp": tp, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
301
+ "measured": False, "reason": reason, **({"too_short": True} if tp is not None else {}),
302
+ **({"normalized": True} if normalized else {})}
299
303
  extra["verification"] = [{"step": "loudness", "ok": False, "platform": platform, "reason": reason}]
304
+ why = ("the audio is shorter than one 400 ms loudness block, so it has a true peak but no integrated loudness"
305
+ if tp is not None else "ffmpeg's loudnorm output did not parse")
300
306
  msg = (f"the written file's loudness could not be measured{' after --normalize' if normalized else ''} "
301
- f"(ffmpeg's loudnorm output did not parse), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
307
+ f"({why}), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
302
308
  "verified; run check.py on the file")
303
309
  notes.append(msg)
304
310
  warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
@@ -722,7 +728,14 @@ def main() -> int:
722
728
  # written file here so the result names the gap and the caller plans one pass, not two.
723
729
  spec = PLATFORMS[platform]
724
730
  m = measure_loudness(output)
725
- if not m:
731
+ written_audio = probe(output, role="output").get("audio") or {}
732
+ if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
733
+ and shorter_than_a_loudness_block(written_audio.get("duration") or written_audio.get("tagged_duration"))):
734
+ # A257: too short for a gating block is not silent (it has a measured peak); a longer
735
+ # track under the gate is not "too short" and takes the path it took before
736
+ _loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
737
+ m = {}
738
+ elif not m:
726
739
  _loudness_unmeasured(extra, notes, platform, spec)
727
740
  if m:
728
741
  ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
@@ -202,6 +202,12 @@ def main(force_route: Optional[str] = None) -> int:
202
202
  fps = meta["video"].get("fps")
203
203
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
204
204
  e = time_arg(args.end, "--end", fps) if args.end else dur
205
+ # the picture's own length first: sound that runs on keeps the container open past the last frame
206
+ picture_len = meta["video"].get("duration") or dur
207
+ if picture_len and s >= picture_len:
208
+ # A172: a graphic that starts after the picture ends draws nothing (verified: true before)
209
+ die(f"--start {s:g} is at or after the end of the video ({picture_len:.3f} s): nothing would be drawn",
210
+ hint="give a --start inside the clip")
205
211
  if e <= s:
206
212
  die("--end must be after --start")
207
213
  en = f"enable='between(t,{s:.3f},{e:.3f})'"
@@ -23,7 +23,8 @@ from _platforms import TP_TOLERANCE_DB
23
23
 
24
24
  AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
25
25
  LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
26
- from _common.probe import PROBED_AUDIO, probed_maps
26
+ from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
27
+ from _common.emit import refuse
27
28
  from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
28
29
 
29
30
 
@@ -78,14 +79,37 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
78
79
  if proc.returncode != 0 or not m:
79
80
  die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
80
81
  data = json.loads(m.group(0))
82
+ data["too_short"] = False
81
83
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
82
84
  if data.get(k) in (None, "-inf", "inf", "nan"):
83
- data["silent"] = True
85
+ # A257: a clip shorter than one 400 ms gating block has no integrated loudness but a
86
+ # true peak; that is not silence, and telling the caller to replace the audio was wrong.
87
+ # The length decides it, not the peak: a long track under the absolute gate has a
88
+ # finite peak and no integrated loudness as well, and stays "silent" as before.
89
+ data["too_short"] = (data.get("input_tp") not in (None, "-inf", "inf", "nan")
90
+ and shorter_than_a_loudness_block(audio_length(probe(path))))
91
+ data["silent"] = not data["too_short"]
84
92
  return data
85
93
  data["silent"] = False
86
94
  return data
87
95
 
88
96
 
97
+ def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
98
+ """The mode loudnorm runs pass 2 in, by its own rule (af_loudnorm.c init): linear only when the
99
+ measured values are all usable and the linear gain keeps the true peak under the ceiling and
100
+ the range within --lra; otherwise it falls back to dynamic, which compresses the range and can
101
+ land away from the target on its own (A111). The values are the strings pass 1 printed, which
102
+ are the ones pass 2 is given, so the comparison is ffmpeg's own. Checked against loudnorm's
103
+ print_format=json normalization_type by test_loudnorm_mode_matches_what_ffmpeg_reports."""
104
+ try:
105
+ mi, mtp, mlra, mth = (float(stats[k]) for k in ("input_i", "input_tp", "input_lra", "input_thresh"))
106
+ except (KeyError, TypeError, ValueError):
107
+ return "dynamic"
108
+ if mtp == 99 or mth == -70 or mlra == 0 or mi == 0:
109
+ return "dynamic"
110
+ return "linear" if mtp + (lufs - mi) <= tp and mlra <= lra else "dynamic"
111
+
112
+
89
113
  def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
90
114
  """One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
91
115
  af = (
@@ -130,7 +154,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
130
154
  encode(tp_max, bitrate)
131
155
  if STATE.dry_run:
132
156
  # pass 1 measured the input for real; there is no output to measure
133
- return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}}
157
+ return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
158
+ "loudnorm_mode": loudnorm_mode(stats, lufs, tp_max, args.lra)}
134
159
  after = measure(output, lufs, tp_max, args.lra)
135
160
  # loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
136
161
  # its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
@@ -162,22 +187,43 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
162
187
  if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
163
188
  result["audio_bitrate_used"] = bitrate
164
189
  result["encodes"] = rounds + 1
165
- if not after.get("silent") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
166
- result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
167
- f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
168
- f"higher --audio-bitrate keeps both")
190
+ # the mode of the last encode: its ceiling is the one the file was made with
191
+ result["loudnorm_mode"] = loudnorm_mode(stats, lufs, ceiling, args.lra)
192
+ if not after.get("silent") and not after.get("too_short") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
193
+ if result["loudnorm_mode"] == "dynamic":
194
+ # A111: the miss is loudnorm's dynamic mode, not an encoder overshoot (it happens on a
195
+ # lossless output too): a linear gain to the target would have put the true peak over
196
+ # the ceiling, or the range is wider than --lra
197
+ result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because "
198
+ f"loudnorm could not apply one linear gain (the input's true peak {float(stats['input_tp']):.1f} dBTP "
199
+ f"plus the gain would pass the {ceiling:.2f} dBTP ceiling, or its range "
200
+ f"{float(stats['input_lra']):.1f} LU is outside 0 < LRA <= --lra {args.lra:g}) and fell back to "
201
+ f"dynamic mode, which compresses the range; a lower -I, a higher --tp or a wider --lra keeps it linear")
202
+ else:
203
+ result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
204
+ f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
205
+ f"higher --audio-bitrate keeps both")
169
206
  if not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB:
170
207
  die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {tp_max:g}); "
171
208
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
172
209
  kind="verification", output=output, result=result,
173
210
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
174
- verification = [{"step": "loudness",
175
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB),
176
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
177
- "property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
178
- "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
179
- "delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
180
- "tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
211
+ if after.get("silent") or after.get("too_short"):
212
+ # an output with no integrated loudness was not measured against the target: through
213
+ # 3.6 a silent one passed this step (ok: true) with -inf in its numbers
214
+ verification = [{"step": "loudness", "ok": False, "target_lufs": lufs, "target_tp": tp_max,
215
+ "property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
216
+ "reason": "not_measured: the output has no integrated loudness ("
217
+ + ("silent" if after.get("silent") else "shorter than one 400 ms block") + ")",
218
+ "provenance": "user_specified"}]
219
+ else:
220
+ verification = [{"step": "loudness",
221
+ "ok": abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB,
222
+ "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
223
+ "property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
224
+ "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
225
+ "delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
226
+ "tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
181
227
  written = probe(output, role="output")
182
228
  out_audio = audio_length(written)
183
229
  if in_audio and out_audio:
@@ -204,6 +250,10 @@ def _versions(args, meta: dict, plan: list) -> int:
204
250
  for label, lufs, tp in plan:
205
251
  out = default_output(args.input, f"loudnorm_{label}")
206
252
  stats = measure(args.input, lufs, tp, args.lra)
253
+ if stats.get("too_short"):
254
+ refuse("input", "audio_too_short",
255
+ f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
256
+ "no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
207
257
  if stats.get("silent"):
208
258
  die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
209
259
  doc = _normalize(args, meta, stats, lufs, tp, out)
@@ -250,6 +300,18 @@ def main() -> int:
250
300
  return _versions(args, meta, plan)
251
301
 
252
302
  stats = measure(args.input, args.lufs, args.tp, args.lra)
303
+ if stats.get("too_short"):
304
+ info(f"audio is shorter than one 400 ms loudness block: no integrated loudness (true peak {stats.get('input_tp')} dBTP)")
305
+ if args.measure_only:
306
+ measured = {"silent": False, "too_short": True, "input_i": None, "input_tp": stats.get("input_tp")}
307
+ if STATE.json:
308
+ emit(None, measured=measured)
309
+ else:
310
+ print(json.dumps(measured, indent=2))
311
+ return 0
312
+ refuse("input", "audio_too_short",
313
+ f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
314
+ "no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
253
315
  if stats.get("silent"):
254
316
  info("audio is silent (integrated loudness -inf); nothing to normalise")
255
317
  if args.measure_only:
@@ -33,7 +33,7 @@ import sys
33
33
  from typing import Dict, List, Tuple
34
34
 
35
35
  from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
- from _common.emit import note, record_fallback, warn
36
+ from _common.emit import note, record_fallback, record_skip, warn
37
37
  from _common.decision import av_trim, rebase_project_paths
38
38
  from _common.probe import display_size, square_pixels, square_pixels_note
39
39
  from sync import measure_offset
@@ -179,9 +179,13 @@ def main() -> int:
179
179
  offsets = [0.0]
180
180
  ratios = [1.0]
181
181
  conf = [1.0]
182
+ # A131: per camera, why --fix-drift measured no drift (too_short / low_confidence, as sync.py
183
+ # says it); None when it was measured, and for the reference, which is the clock (0 by definition)
184
+ drift_skipped: List[object] = [None]
182
185
  for p in args.inputs[1:]:
183
186
  off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
184
187
  ratio = 1.0
188
+ skipped_reason = None
185
189
  if args.fix_drift:
186
190
  ref_dur = metas[0]["duration"] or 0.0
187
191
  sec_dur = probe(p)["duration"] or 0.0
@@ -189,7 +193,11 @@ def main() -> int:
189
193
  window = 60.0
190
194
  head_len = min(args.analyze_seconds, overlap_end)
191
195
  tail_start = overlap_end - window
192
- if tail_start > head_len / 2 + 5:
196
+ if tail_start <= head_len / 2 + 5:
197
+ skipped_reason = "too_short"
198
+ record_skip(f"{p} too short to measure drift reliably; skipping drift correction", "drift_correction_skipped",
199
+ path=p, reason=skipped_reason, overlap_seconds=round(overlap_end, 3), drift_window=window)
200
+ else:
193
201
  ref_start, sec_start = tail_start, tail_start - off
194
202
  if sec_start < 0:
195
203
  ref_start -= sec_start
@@ -204,10 +212,18 @@ def main() -> int:
204
212
  if elapsed > 0 and sc > 0.1:
205
213
  ratio = 1.0 - residual / elapsed
206
214
  off = off + (ratio - 1.0) * (head_len / 2)
215
+ else:
216
+ skipped_reason = "low_confidence"
217
+ record_skip(f"could not measure drift with confidence for {p}; skipping drift correction",
218
+ "drift_correction_skipped", path=p, reason=skipped_reason,
219
+ confidence=round(max(0.0, sc), 3), measured_over_seconds=round(elapsed, 2))
220
+ drift_skipped.append(skipped_reason)
207
221
  offsets.append(off)
208
222
  ratios.append(ratio)
209
223
  conf.append(score)
210
- info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
224
+ info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})"
225
+ + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift and not skipped_reason else
226
+ f", drift not measured ({skipped_reason})" if args.fix_drift else ""))
211
227
  if score < 0.1:
212
228
  warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
213
229
  "trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
@@ -215,7 +231,8 @@ def main() -> int:
215
231
  report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
216
232
  "confidence": [round(c, 3) for c in conf]}
217
233
  if args.fix_drift:
218
- report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
234
+ report["drift_ppm"] = [None if why else round((r - 1) * 1e6, 1) for r, why in zip(ratios, drift_skipped)]
235
+ report["drift_skipped_reason"] = drift_skipped
219
236
  if args.offsets_only:
220
237
  emit(None, **report)
221
238
  if not args.json:
@@ -388,6 +388,12 @@ def main() -> int:
388
388
  end = time_arg(args.end, "--end", fps) if args.end else None
389
389
  if start is not None and end is not None and end <= start:
390
390
  die("--end must be after --start")
391
+ main_len = (meta.get("video") or {}).get("duration") or meta.get("duration")
392
+ if start is not None and main_len and start >= main_len:
393
+ # A172: an overlay that starts after the picture ends draws nothing, and the run said
394
+ # verified: true; --place already refuses the same case
395
+ die(f"--start {start:g} is at or after the end of the video ({main_len:.3f} s): nothing would be drawn",
396
+ hint="give a --start inside the clip")
391
397
  if not 0 <= args.opacity <= 1:
392
398
  die("--opacity must be within 0..1")
393
399
  if args.chromakey and not args.video:
package/scripts/pad.py CHANGED
@@ -16,6 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
+ from _common.emit import record_fallback
19
20
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
20
21
 
21
22
 
@@ -58,6 +59,11 @@ def main() -> int:
58
59
  # streams runs on 5.1-8.1 alike. run() only adds an even scale to a -vf, so add it here.
59
60
  v = meta["video"]
60
61
  even = EVEN_SCALE + "," if (v.get("width") or 0) % 2 or (v.get("height") or 0) % 2 else ""
62
+ if even:
63
+ # A239: the runner's retry records this when it adds the scale; here it is added up
64
+ # front (one -filter_complex), so the record is made here, as broll and stabilize do
65
+ record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
66
+ "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
61
67
  af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
62
68
  cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
63
69
  else:
package/scripts/render.py CHANGED
@@ -391,7 +391,8 @@ def render_pack(names: List[str], args) -> int:
391
391
  size = os.path.getsize(out) if os.path.exists(out) else 0
392
392
  rows.append({"platform": name, "file": os.path.basename(out), "path": out,
393
393
  "size_bytes": size, "duration": probe_doc.get("duration"),
394
- "check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("pass" if ok else "failed")),
394
+ # a destination whose check did not run is "not checked", not "pass" (the class of A113)
395
+ "check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("not checked" if ok else "failed")),
395
396
  "ok": bool(ok)})
396
397
  outputs.append(out)
397
398
  if not ok:
@@ -421,7 +422,8 @@ def render_pack(names: List[str], args) -> int:
421
422
  code=stopped[0]["exit_code"] if stopped else 1, kind=stopped[0]["kind"] if stopped else "verification",
422
423
  output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
423
424
  emit(pack, pack=rows, outputs=outputs, stages=["pack"],
424
- verification=[{"step": "check", "ok": r["ok"], "platform": r["platform"]} for r in rows])
425
+ # a destination whose check did not run has no check step: "not checked" is not ok: true
426
+ verification=[{"step": "check", "ok": True, "platform": r["platform"]} for r in rows if r["check"] == "pass"])
425
427
  return 0
426
428
 
427
429
 
@@ -1186,6 +1188,31 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
1186
1188
  return False
1187
1189
 
1188
1190
 
1191
+ def planned_clip_length(clip: Dict[str, Any], src: str, index: int) -> float:
1192
+ """A dry run's length of clip `index` before its speed stage: the source's measured length,
1193
+ or its in..out range of it (the cut a real run measures does not exist yet). Only when neither
1194
+ can be read is the speed planned on a stand-in 10 s, and that is recorded (A97): a plan that
1195
+ gave no sign its fit.py --duration came from an invented number read as a measured one."""
1196
+ from _common.decision import parse_time
1197
+ length = fps = None
1198
+ if os.path.isfile(src):
1199
+ meta = probe(src)
1200
+ length = meta.get("duration") or None
1201
+ # hh:mm:ss:ff needs the source's rate, which the real cut reads from the same place
1202
+ fps = (meta.get("video") or {}).get("fps") or None
1203
+ try:
1204
+ start = parse_time(str(clip["in"]), fps) if clip.get("in") is not None else 0.0
1205
+ end = parse_time(str(clip["out"]), fps) if clip.get("out") is not None else length
1206
+ except ValueError:
1207
+ start, end = 0.0, None
1208
+ if end is not None and end - start > 0:
1209
+ return end - start
1210
+ record_skip(f"[dry-run] clip {index}: its length could not be read before the cut exists, so the speed stage is "
1211
+ "planned on a stand-in 10 s; a real run measures the cut", "speed_length_placeholder",
1212
+ stage="clips", clip=index, path=src, placeholder_seconds=10.0)
1213
+ return 10.0
1214
+
1215
+
1189
1216
  def main() -> int:
1190
1217
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1191
1218
  ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
@@ -1474,7 +1501,7 @@ def main() -> int:
1474
1501
  if not (spd > 0) or spd != spd or spd == float("inf"):
1475
1502
  die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
1476
1503
  if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
1477
- dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
1504
+ dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else planned_clip_length(c, src, i)
1478
1505
  fitted = str(work / f"clip{i:02d}_speed{mid}")
1479
1506
  sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
1480
1507
  part = fitted
package/scripts/verify.py CHANGED
@@ -156,6 +156,12 @@ def main() -> int:
156
156
  apply_common(args)
157
157
  if STATE.plan:
158
158
  die("verify.py does not support --plan: its steps run for real (it ignores --dry-run too)")
159
+ ignored_dry_run = STATE.dry_run
160
+ if ignored_dry_run:
161
+ # A240: the flag is accepted and ignored (docs/design-decisions.md "Dry run"); the steps
162
+ # run and write, so the document says dry_run: false and a note says the flag was ignored
163
+ STATE.dry_run = False
164
+ info("--dry-run is ignored by verify.py: its steps run and write their outputs")
159
165
 
160
166
  files = collect(args.paths)
161
167
  tmp = None
@@ -267,11 +273,12 @@ def main() -> int:
267
273
  print(report)
268
274
  if tmp and not args.keep:
269
275
  tmp.cleanup()
276
+ notes = (["--dry-run is ignored by verify.py: its steps ran and wrote their outputs"] if ignored_dry_run else [])
270
277
  if failed:
271
278
  die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
272
- report=args.report, files=results, failed=failed, total=total)
279
+ report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
273
280
  if args.json:
274
- emit(None, report=args.report, files=results, failed=failed, total=total)
281
+ emit(None, report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
275
282
  return 0
276
283
 
277
284