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