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 +10 -8
- package/SKILL.md +2 -2
- package/bin/install.js +79 -5
- package/docs/contract.md +9 -5
- package/package.json +1 -1
- package/references/scripts.md +6 -2
- package/scripts/_common/probe.py +13 -0
- package/scripts/_common/runner.py +17 -3
- package/scripts/_contract.py +19 -11
- package/scripts/caption.py +29 -3
- package/scripts/check.py +52 -7
- package/scripts/export.py +20 -7
- package/scripts/graphics.py +6 -0
- package/scripts/loudness.py +76 -14
- package/scripts/multicam.py +21 -4
- package/scripts/overlay.py +6 -0
- package/scripts/pad.py +6 -0
- package/scripts/render.py +30 -3
- package/scripts/verify.py +9 -2
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
|
|
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` (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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),
|
|
254
|
-
|
|
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.
|
|
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": [
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--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
|
|---|---|---|---|
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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"
|
|
611
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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": "
|
|
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
|
|
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)"},
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
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":
|
|
298
|
-
"measured": False, "reason": reason, **({"
|
|
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"(
|
|
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
|
-
|
|
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
|
package/scripts/graphics.py
CHANGED
|
@@ -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})'"
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
package/scripts/multicam.py
CHANGED
|
@@ -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
|
|
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})"
|
|
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:
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|