ffmpeg-skill 0.12.5 → 0.16.13
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 +19 -6
- package/SKILL.md +14 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +160 -1
- package/scripts/_common.py +57 -3
- package/scripts/_contract.py +56 -5
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +37 -3
- package/scripts/check.py +8 -0
- package/scripts/color.py +9 -1
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +14 -3
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +1 -1
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +7 -2
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
|
|
18
18
|
<img src="https://img.shields.io/badge/ffmpeg-5.0%2B-orange" alt="FFmpeg 5.0+">
|
|
19
19
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
20
|
+
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
20
21
|
</p>
|
|
21
22
|
|
|
22
23
|
```bash
|
|
@@ -25,7 +26,7 @@ npx ffmpeg-skill
|
|
|
25
26
|
|
|
26
27
|

|
|
27
28
|
|
|
28
|
-
`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 **
|
|
29
|
+
`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 **40 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 an MCP tool, and the whole set is described by a machine-readable contract.
|
|
29
30
|
|
|
30
31
|
If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
|
|
31
32
|
|
|
@@ -139,7 +140,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
139
140
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
140
141
|
2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
141
142
|
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent and ETA). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
142
|
-
4. **Machine-readable contract.** `contract --json` describes all
|
|
143
|
+
4. **Machine-readable contract.** `contract --json` describes all 40 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
143
144
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
144
145
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
|
145
146
|
7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
|
|
@@ -148,7 +149,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
148
149
|
|
|
149
150
|
## Tools
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
40 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
|
|
152
153
|
|
|
153
154
|
**Analysis and inspection**
|
|
154
155
|
|
|
@@ -167,11 +168,23 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
167
168
|
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list |
|
|
168
169
|
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad or crop, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
|
|
169
170
|
| `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
|
|
171
|
+
| `cropdetect.py` | Measure existing black letterbox/pillarbox bars and report the `crop.py`-ready rectangle that removes them — analysis only, writes no file |
|
|
172
|
+
| `deinterlace.py` | Deinterlace interlaced source footage (`yadif`), `--mode frame`/`field`, `--parity` |
|
|
173
|
+
| `denoise.py` | Reduce video noise/grain (`hqdn3d`), `--strength low/medium/high` or individual spatial/temporal overrides |
|
|
174
|
+
| `redact.py` | Blur or pixelate an exact pixel rectangle for the whole clip (privacy/compliance redaction) |
|
|
175
|
+
| `sphere.py` | Extract a flat rectilinear viewport from a 360/spherical video (`--yaw --pitch --roll --h-fov --v-fov`); no subject tracking, only the aim you give it |
|
|
176
|
+
| `straighten.py` | Rotate by an arbitrary angle for horizon correction (`--degrees`, `--fit crop/pad`) — distinct from `fit.py --rotate`'s exact 90-degree turns |
|
|
170
177
|
| `insert.py` | Turn a still image into a silent, fixed-duration video clip (title card, end slate) at an exact frame size / fps, with an optional Ken Burns zoom/pan |
|
|
171
178
|
| `background.py` | Generate a solid-colour or two-colour gradient clip at an exact size/duration — no input file |
|
|
172
179
|
| `reverse.py` | Reverse playback (video and, unless `--no-audio`, audio) |
|
|
173
180
|
| `stabilize.py` | Two-pass motion stabilisation (`vidstabdetect`/`vidstabtransform`) |
|
|
174
181
|
| `sequence.py` | Numbered (`frame_%04d.png`) or glob-matched still images into a video |
|
|
182
|
+
| `waveform.py` | Render an audio track as a waveform or spectrum visualization video (`showwaves`/`showspectrum`) — for audio-only inputs with no picture worth showing |
|
|
183
|
+
| `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
|
|
184
|
+
| `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
|
|
185
|
+
| `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
|
|
186
|
+
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
|
|
187
|
+
| `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
|
|
175
188
|
|
|
176
189
|
**Audio**
|
|
177
190
|
|
|
@@ -259,7 +272,7 @@ npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py -
|
|
|
259
272
|
npx ffmpeg-skill contract --json --static # without environment detection
|
|
260
273
|
```
|
|
261
274
|
|
|
262
|
-
The contract is generated from the code that runs, not maintained beside it. For each of the
|
|
275
|
+
The contract is generated from the code that runs, not maintained beside it. For each of the 40 tools (`ffmpeg-skill/<name>`) it states:
|
|
263
276
|
|
|
264
277
|
| Field | Meaning |
|
|
265
278
|
|---|---|
|
|
@@ -284,7 +297,7 @@ The contract is generated from the code that runs, not maintained beside it. For
|
|
|
284
297
|
|
|
285
298
|
On Windows, `python3` is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes `python` (or the `py` launcher) instead — if your MCP client reports the server failed to start, change `"command"` above to `"python"` (or the full path from `where python`).
|
|
286
299
|
|
|
287
|
-
`mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same
|
|
300
|
+
`mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same 40 names, the same order, and `inputSchema` translated from each tool's `input_schema`. `tools/call` maps structured arguments to argv and runs the named script; a raw `argv` form is accepted for compatibility and marked non-canonical. `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
|
|
288
301
|
|
|
289
302
|
### Capability detection
|
|
290
303
|
|
|
@@ -382,7 +395,7 @@ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `mai
|
|
|
382
395
|
|
|
383
396
|
`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.
|
|
384
397
|
|
|
385
|
-
**Releasing
|
|
398
|
+
**Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it asks [release-drafter](.github/release-drafter.yml) to resolve the next version from the labels on every PR merged since the last tag (`major`/`minor`/`feature`/`enhancement`/`fix`/`bug`/... — an unlabeled PR defaults to `patch`), bumps `package.json` and `docs/contract.md`, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
|
|
386
399
|
|
|
387
400
|
Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
388
401
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Edit video and audio with local FFmpeg from natural-language reques
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress`. Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress`. Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
|
@@ -33,7 +33,7 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
33
33
|
writing tools this means nothing is written; `probe`/`check` still run
|
|
34
34
|
ffprobe/loudness-measurement passes (they're read-only, so `--dry-run`
|
|
35
35
|
changes nothing for `probe`, and only skips the loudness pass for
|
|
36
|
-
`check`), `sync`/`multicam`/`scenes`/`report` still run ffmpeg/ffprobe to
|
|
36
|
+
`check`), `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to
|
|
37
37
|
measure or analyse, and `verify` accepts the flag but ignores it entirely
|
|
38
38
|
(its steps run regardless) — see `contract --json`'s `dry_run` field per
|
|
39
39
|
tool for exact semantics. Trust `--json`, not a dry-run's human-readable
|
|
@@ -131,6 +131,12 @@ If a request needs an FFmpeg feature none of the 28 scripts expose, say so and n
|
|
|
131
131
|
| "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`) |
|
|
132
132
|
| "resize to a specific height, width follows" | `fit.py input.mp4 --height 1080` (or `--width`, or both for an exact frame) |
|
|
133
133
|
| "crop to this exact box/rectangle" (known x/y/width/height, not an aspect ratio) | `crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920` |
|
|
134
|
+
| "are there black bars on this?", "what's the crop rectangle to remove the letterboxing" | `cropdetect.py input.mp4` |
|
|
135
|
+
| "this old footage is interlaced / combed on motion" | `deinterlace.py input.mp4` |
|
|
136
|
+
| "this footage is grainy/noisy, clean it up" | `denoise.py input.mp4 --strength medium` |
|
|
137
|
+
| "blur/pixelate this face/plate/region" (known x/y/width/height) | `redact.py input.mp4 --x 820 --y 140 --width 240 --height 240 --mode pixelate` |
|
|
138
|
+
| "pull a flat clip out of this 360/spherical video, looking this way" (known yaw/pitch/fov, not "find the interesting part") | `sphere.py insta360.mp4 --yaw 90 --pitch 0 --h-fov 100 --v-fov 70` |
|
|
139
|
+
| "the horizon is tilted, straighten this out" (known degrees, not "auto-level") | `straighten.py tilted.mp4 --degrees -2.5` |
|
|
134
140
|
| "turn this image into a N-second clip", "title card / end slate" | `insert.py title.png --duration 3` |
|
|
135
141
|
| "slow zoom on a photo", "Ken Burns effect" | `insert.py photo.jpg --duration 6 --zoom in --pan right --width 1920 --height 1080` |
|
|
136
142
|
| "rotate this 90 degrees", "mirror it horizontally" | `fit.py input.mp4 --rotate 90` / `fit.py input.mp4 --flip h` |
|
|
@@ -138,6 +144,12 @@ If a request needs an FFmpeg feature none of the 28 scripts expose, say so and n
|
|
|
138
144
|
| "stabilize this shaky footage" | `stabilize.py input.mp4` |
|
|
139
145
|
| "make a blank/colour background clip" | `background.py -o bg.mp4 --duration 3 --width 1920 --height 1080 --color 0x101010` |
|
|
140
146
|
| "turn these numbered frames into a video" | `sequence.py --dir frames --pattern "frame_%04d.png" --fps 24` |
|
|
147
|
+
| "make a waveform/spectrum video for this podcast/track" | `waveform.py podcast.wav -o waveform.mp4` |
|
|
148
|
+
| "hold on this frame for a couple seconds", "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
149
|
+
| "add some black at the start before the title card" | `pad.py clip.mp4 --start 1.5` |
|
|
150
|
+
| "speed up here, slam into slow-mo there, then speed back up" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
|
|
151
|
+
| "loop this background clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
|
|
152
|
+
| "put these videos in a 4x2 grid with the filename on each" | `grid.py t1.mp4 t2.mp4 t3.mp4 t4.mp4 t5.mp4 t6.mp4 t7.mp4 t8.mp4 --cols 4 --rows 2` |
|
|
141
153
|
| "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
|
|
142
154
|
| "caption it with these lines" (plain text with times) | `caption.py input.mp4 --text cues.txt` |
|
|
143
155
|
| "add subtitles but keep them toggleable / editable", "mux in an SRT, don't burn it" | `caption.py input.mp4 --srt subs.srt --mode mux` |
|
package/bin/install.js
CHANGED
|
@@ -108,13 +108,22 @@ for (const t of targets) {
|
|
|
108
108
|
console.log(`removed ${t.label}: ${t.dir}`);
|
|
109
109
|
continue;
|
|
110
110
|
}
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// Copy into a scratch directory next to the real target first, then swap it into place
|
|
112
|
+
// with a single rename -- not delete-then-copy-into-the-gap. A process killed mid-copy
|
|
113
|
+
// (Ctrl-C, disk full, a permission error partway through) used to leave the target either
|
|
114
|
+
// empty or half-populated; now it leaves the previous install untouched (first run: no
|
|
115
|
+
// previous install to preserve, so an interruption here still leaves nothing, same as
|
|
116
|
+
// before -- the guarantee is specifically for an upgrade of an existing install).
|
|
117
|
+
const tmpDir = `${t.dir}.tmp-${process.pid}`;
|
|
118
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
119
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
113
120
|
for (const item of PAYLOAD) {
|
|
114
121
|
const src = path.join(ROOT, item);
|
|
115
122
|
if (!fs.existsSync(src)) { if (item !== 'SKILL.md' && item !== 'scripts') continue; throw new Error(`missing ${item} in package`); }
|
|
116
|
-
copyRecursive(src, path.join(
|
|
123
|
+
copyRecursive(src, path.join(tmpDir, item));
|
|
117
124
|
}
|
|
125
|
+
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
126
|
+
fs.renameSync(tmpDir, t.dir);
|
|
118
127
|
console.log(`installed ${t.label}: ${t.dir}`);
|
|
119
128
|
} catch (err) {
|
|
120
129
|
failed = true;
|
package/mcp/server.py
CHANGED
|
@@ -158,6 +158,14 @@ def main() -> int:
|
|
|
158
158
|
req = json.loads(line)
|
|
159
159
|
except ValueError:
|
|
160
160
|
continue
|
|
161
|
+
# json.loads accepts any valid JSON value, not just an object -- a bare `42`, `null`,
|
|
162
|
+
# `true` or `[1,2]` line parses fine but isn't a JSON-RPC request. Without this guard,
|
|
163
|
+
# "id" not in req raised TypeError for a non-dict req (ints/bools/None aren't iterable
|
|
164
|
+
# the way `in` needs), uncaught by the try/except below it, killing the whole stdio
|
|
165
|
+
# server process -- not just failing that one malformed line, but every other in-flight
|
|
166
|
+
# and future tool call in the session along with it.
|
|
167
|
+
if not isinstance(req, dict):
|
|
168
|
+
continue
|
|
161
169
|
if "id" not in req: # notification
|
|
162
170
|
continue
|
|
163
171
|
try:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor:
|
|
3
|
+
"version": "0.16.13",
|
|
4
|
+
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 40 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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
|
7
7
|
"video",
|
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`, `--fast`, `--progress`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), 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`, `--fast`, `--progress`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
|
@@ -86,6 +86,89 @@ lie entirely inside the source frame (after accounting for display rotation);
|
|
|
86
86
|
`--width`/`--height` must be even (4:2:0 chroma) and are refused, never
|
|
87
87
|
rounded, if they aren't.
|
|
88
88
|
|
|
89
|
+
### cropdetect.py — measure black bars, report the crop rectangle
|
|
90
|
+
```
|
|
91
|
+
cropdetect.py INPUT [--seconds N] [--samples N] [--limit F] [--round N]
|
|
92
|
+
```
|
|
93
|
+
Measurement only -- writes no file. Samples `--samples` windows spread
|
|
94
|
+
across the file (default 5, totalling `--seconds` 10s of footage) and
|
|
95
|
+
reports the crop rectangle FFmpeg's `cropdetect` filter found most often, as
|
|
96
|
+
`{x, y, width, height}` ready to hand to `crop.py`. Distinct from
|
|
97
|
+
`fit.py --fit crop`, which crops to a target aspect ratio it computes
|
|
98
|
+
itself with no black-bar measurement involved. A rectangle that matches the
|
|
99
|
+
full source frame means no bars were found. Does not decide whether
|
|
100
|
+
removing detected bars is wanted -- genuine letterboxed content (a
|
|
101
|
+
scope-ratio film in a 16:9 frame) "detects" the same way as accidental
|
|
102
|
+
bars; look at the frame before cropping it away.
|
|
103
|
+
|
|
104
|
+
### deinterlace.py — deinterlace interlaced footage
|
|
105
|
+
```
|
|
106
|
+
deinterlace.py INPUT [--mode frame|field] [--parity auto|tff|bff] [--only-interlaced] [-o OUT]
|
|
107
|
+
```
|
|
108
|
+
Wraps FFmpeg's `yadif` filter. `--mode frame` (default) keeps the source
|
|
109
|
+
frame rate; `--mode field` emits one frame per field, doubling the output
|
|
110
|
+
frame rate. `--parity` overrides field order when the container gets it
|
|
111
|
+
wrong; `--only-interlaced` skips frames the source doesn't itself mark
|
|
112
|
+
interlaced. Does not detect whether the source needs deinterlacing --
|
|
113
|
+
that's a `look.py` judgement call (visible combing on motion).
|
|
114
|
+
|
|
115
|
+
### denoise.py — reduce video noise/grain
|
|
116
|
+
```
|
|
117
|
+
denoise.py INPUT [--strength low|medium|high] [--luma-spatial F] [--chroma-spatial F]
|
|
118
|
+
[--luma-temporal F] [--chroma-temporal F] [-o OUT]
|
|
119
|
+
```
|
|
120
|
+
Wraps FFmpeg's `hqdn3d` filter. `--strength` picks a tested preset scaling
|
|
121
|
+
all four of hqdn3d's spatial/temporal luma/chroma parameters together; the
|
|
122
|
+
four `--luma-*`/`--chroma-*` flags override any of them individually.
|
|
123
|
+
Heavier denoising trades fine detail for a cleaner but softer image -- for
|
|
124
|
+
audio noise reduction use `audio.py --denoise` instead, this tool only
|
|
125
|
+
touches the picture.
|
|
126
|
+
|
|
127
|
+
### redact.py — blur or pixelate an exact rectangle
|
|
128
|
+
```
|
|
129
|
+
redact.py INPUT --x X --y Y --width W --height H [--mode blur|pixelate]
|
|
130
|
+
[--blur-strength N] [--block-size N] [-o OUT]
|
|
131
|
+
```
|
|
132
|
+
Same rectangle convention as `crop.py` -- the rectangle must already be
|
|
133
|
+
known (a saved detection box, a hand-picked region); this tool does not
|
|
134
|
+
locate faces or plates itself. `--mode blur` (default) box-blurs the
|
|
135
|
+
rectangle; `--mode pixelate` mosaics it into `--block-size`-px blocks, the
|
|
136
|
+
more unmistakably-redacted look often wanted for compliance footage. The
|
|
137
|
+
rest of the frame, and the whole clip's timeline, are untouched -- to
|
|
138
|
+
redact only part of the timeline, `cut.py` the clip into segments first.
|
|
139
|
+
|
|
140
|
+
### sphere.py — flat viewport extraction from 360/spherical video
|
|
141
|
+
```
|
|
142
|
+
sphere.py INPUT [--input-projection equirect|fisheye|dfisheye|c3x2|c6x1|barrel|cylindrical|hequirect]
|
|
143
|
+
[--yaw D] [--pitch D] [--roll D] [--h-fov D] [--v-fov D]
|
|
144
|
+
[--width W] [--height H] [--interp METHOD] [--stereo mono|sbs|tb]
|
|
145
|
+
[--audio-stream N] [-o OUT]
|
|
146
|
+
```
|
|
147
|
+
Wraps FFmpeg's `v360` filter to bake out an ordinary flat clip pointed at a
|
|
148
|
+
fixed direction — the same "look this way" operation a VR headset or a 360
|
|
149
|
+
player's viewport does. `--yaw`/`--pitch`/`--roll` aim the camera (each
|
|
150
|
+
-180..180, default 0); `--h-fov`/`--v-fov` set how wide the view is (each
|
|
151
|
+
1..170, default 90/60). `--input-projection` must match the source's own
|
|
152
|
+
projection (default `equirect`, the most common capture/export format) —
|
|
153
|
+
this tool cannot detect it from the pixels, so a wrong value produces a
|
|
154
|
+
distorted or garbled output, not an error. There is no subject detection or
|
|
155
|
+
tracking here: only the typed aim you give it. For a shot that follows a
|
|
156
|
+
moving subject, call this once per keyframe viewpoint from outside this
|
|
157
|
+
tool. `--width`/`--height` must be even (4:2:0 chroma); default 1920x1080.
|
|
158
|
+
|
|
159
|
+
### straighten.py — rotate by an arbitrary angle (horizon correction)
|
|
160
|
+
```
|
|
161
|
+
straighten.py INPUT --degrees D [--fit crop|pad] [--fill-color C] [-o OUT]
|
|
162
|
+
```
|
|
163
|
+
Distinct from `fit.py --rotate`, which only turns the picture in exact
|
|
164
|
+
90-degree steps -- this wraps FFmpeg's `rotate` filter for a small
|
|
165
|
+
corrective tilt (`--degrees`, -45..45). Rotating by a non-90-degree angle
|
|
166
|
+
leaves triangular gaps at the corners: `--fit crop` (default) scales up
|
|
167
|
+
just enough to fill the frame with no visible gap, losing a thin border of
|
|
168
|
+
the original picture; `--fit pad` keeps the full original picture and fills
|
|
169
|
+
the gaps with `--fill-color`. Does not measure the tilt itself -- give the
|
|
170
|
+
degrees once you can see how far off it is (a `look.py` judgement call).
|
|
171
|
+
|
|
89
172
|
### insert.py — still image to a timed silent clip
|
|
90
173
|
```
|
|
91
174
|
insert.py IMAGE --duration T [--width W] [--height H] [--fps N]
|
|
@@ -140,6 +223,82 @@ Turns a numbered or glob-matched set of still images into a video. The match
|
|
|
140
223
|
is checked on disk before ffmpeg runs (an empty match or a missing first
|
|
141
224
|
frame is refused here, not discovered from an opaque ffmpeg error).
|
|
142
225
|
|
|
226
|
+
### waveform.py — audio waveform/spectrum visualization video
|
|
227
|
+
```
|
|
228
|
+
waveform.py INPUT [--style waveform|spectrum] [--width W] [--height H] [--fps N]
|
|
229
|
+
[--color C] [--background C] [--waveform-mode M] [--split-channels] [-o OUT]
|
|
230
|
+
```
|
|
231
|
+
Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
|
|
232
|
+
`showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
|
|
233
|
+
draws a frequency-over-time heatmap instead, reading more out of dense
|
|
234
|
+
mixes at the cost of being less immediately readable. The output always
|
|
235
|
+
carries the audio it visualizes. For an audio-only input (no video stream
|
|
236
|
+
needed) or any file with an audio track worth visualizing.
|
|
237
|
+
|
|
238
|
+
### freeze.py — hold a frame for N seconds
|
|
239
|
+
```
|
|
240
|
+
freeze.py INPUT --hold T [--at T] [--mode insert|extend] [-o OUT]
|
|
241
|
+
```
|
|
242
|
+
`--at` (default: the last frame) is the timestamp to freeze; `--hold` is
|
|
243
|
+
how long the freeze lasts. `--mode insert` (default) inserts the hold at
|
|
244
|
+
`--at`, pushing everything after it later by `--hold` seconds. `--mode
|
|
245
|
+
extend` only works with `--at` at (or past) the clip's end and just makes
|
|
246
|
+
the last frame last `--hold` seconds longer, with nothing pushed. Audio is
|
|
247
|
+
silent during the held frame in `--mode insert` (there is no source audio
|
|
248
|
+
for a frozen moment that didn't exist before).
|
|
249
|
+
|
|
250
|
+
### pad.py — add black/silent padding at the start/end
|
|
251
|
+
```
|
|
252
|
+
pad.py INPUT [--start T] [--end T] [--color C] [-o OUT]
|
|
253
|
+
```
|
|
254
|
+
Distinct from `fit.py --fit pad`, which pads the *frame* (letterbox/
|
|
255
|
+
pillarbox bars around each existing frame) -- this pads the *timeline*:
|
|
256
|
+
extra seconds of solid colour and silence before and/or after the clip's
|
|
257
|
+
existing content. At least one of `--start`/`--end` must be > 0.
|
|
258
|
+
|
|
259
|
+
### speedramp.py — step through different speeds across a clip
|
|
260
|
+
```
|
|
261
|
+
speedramp.py INPUT --segment START-END:FACTOR [--segment ...] [-o OUT]
|
|
262
|
+
```
|
|
263
|
+
Distinct from `fit.py --duration --method speed`, which applies one
|
|
264
|
+
constant factor to the whole clip -- this takes a list of `--segment`
|
|
265
|
+
pieces (repeatable) covering the clip start to end with no gaps or
|
|
266
|
+
overlaps, each played at its own constant speed (pitch-preserving audio,
|
|
267
|
+
matching `fit.py`), then concatenates them: "speed up, then slow way down
|
|
268
|
+
for the punch, then speed back up," built from a few constant segments
|
|
269
|
+
rather than a continuous curve. `FACTOR` is 0.05..20 (2.0 = twice as fast,
|
|
270
|
+
0.5 = half speed). Picking exactly where a ramp should ease in or out is a
|
|
271
|
+
judgement call for the calling agent, made concrete here as the segment
|
|
272
|
+
boundaries it supplies.
|
|
273
|
+
|
|
274
|
+
### loop.py — repeat a clip
|
|
275
|
+
```
|
|
276
|
+
loop.py INPUT --times N | --duration T [-o OUT]
|
|
277
|
+
```
|
|
278
|
+
`--times` repeats the whole clip that many times back to back (2 =
|
|
279
|
+
original + 1 repeat). `--duration` instead loops (and trims the last
|
|
280
|
+
repeat) to hit an exact target length. For a background loop, an ambient
|
|
281
|
+
bed, or filling a fixed slot length with a short clip. Does not smooth the
|
|
282
|
+
loop point (no crossfade at the seam) -- a clip that doesn't already loop
|
|
283
|
+
cleanly will show a visible cut/pop at each repeat, which is a property of
|
|
284
|
+
the source material this tool cannot fix.
|
|
285
|
+
|
|
286
|
+
### grid.py — composite clips into a grid
|
|
287
|
+
```
|
|
288
|
+
grid.py CLIP1 CLIP2 [...] --cols N --rows N [--cell-width W] [--cell-height H]
|
|
289
|
+
[--label auto|none] [--font NAME] [--audio-from I]
|
|
290
|
+
[--pad] [-o OUT]
|
|
291
|
+
```
|
|
292
|
+
Letterboxes every clip into a common `--cell-width`x`--cell-height` cell (no
|
|
293
|
+
stretching) and tiles them `--cols`x`--rows`, filled left-to-right,
|
|
294
|
+
top-to-bottom -- the input count must equal cols*rows exactly. `--label auto`
|
|
295
|
+
(default) burns each clip's filename (extension stripped) into its cell's
|
|
296
|
+
bottom-right corner; `--label none` skips it. No audio unless `--audio-from`
|
|
297
|
+
picks one input's track by index -- mixing every clip's audio together is
|
|
298
|
+
rarely useful for a comparison grid, so this tool never does that silently.
|
|
299
|
+
Runs only as long as the shortest clip by default; `--pad` instead holds
|
|
300
|
+
each shorter clip's last frame (with silence) out to the longest.
|
|
301
|
+
|
|
143
302
|
### silence.py — remove dead air / jump cuts
|
|
144
303
|
```
|
|
145
304
|
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
|
package/scripts/_common.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import platform
|
|
12
|
+
import re
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
@@ -205,6 +206,10 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
205
206
|
success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
|
|
206
207
|
to write so a caller scanning the output directory after a failure never mistakes a partial
|
|
207
208
|
artifact for a real (if unverified) one."""
|
|
209
|
+
# run() also executes ffprobe, whose last argument is an INPUT. Never
|
|
210
|
+
# interpret a read-only tool's failure as permission to remove that file.
|
|
211
|
+
if not _is_ffmpeg(cmd):
|
|
212
|
+
return
|
|
208
213
|
output = cmd[-1]
|
|
209
214
|
if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
|
|
210
215
|
return
|
|
@@ -636,14 +641,46 @@ def default_font_file(font_name: str) -> Optional[str]:
|
|
|
636
641
|
|
|
637
642
|
|
|
638
643
|
def escape_drawtext(text: str) -> str:
|
|
644
|
+
"""Escape `text` for use as a single-quoted drawtext option value (`text='<this>'`).
|
|
645
|
+
|
|
646
|
+
Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash
|
|
647
|
+
escape regardless of the surrounding quotes -- the graph parser still splits on an
|
|
648
|
+
unescaped `,`/`;` or ends an option list on an unescaped `:`/`[`/`]` even while
|
|
649
|
+
"inside" a quoted value. The quote character itself has no reliable backslash
|
|
650
|
+
escape at all: `\\'` and the POSIX shell `'\\''` close-insert-reopen trick both
|
|
651
|
+
parse fine in a simple `-vf` chain, but silently corrupt a `-filter_complex` chain
|
|
652
|
+
that uses explicit `[label]` pads -- confirmed by rendering the result: the text
|
|
653
|
+
value doesn't end where the quote closes it, and trailing option names/values
|
|
654
|
+
(fontfile=..., fontsize=...) leak into the rendered picture as literal text
|
|
655
|
+
instead of being parsed as options. A quote is therefore dropped outright rather
|
|
656
|
+
than escaped -- losing one apostrophe from a label is a fine trade for "the
|
|
657
|
+
filter graph parses the way the code intends, on every call shape this codebase
|
|
658
|
+
uses it in".
|
|
659
|
+
|
|
660
|
+
`%` has the same problem the quote character did: `\%` is not a real escape as
|
|
661
|
+
far as drawtext's own text-expansion scanner (on by default, `expansion=normal`,
|
|
662
|
+
for `%{pts}`/`%{localtime}`/etc.) is concerned -- a bare backslash-escaped `%`
|
|
663
|
+
always logs "Stray % near ..." (confirmed with the minimal case
|
|
664
|
+
`text='100\%done'`), which is merely noisy on one ffmpeg
|
|
665
|
+
build (the warning is printed, the file still gets written) but a hard filtering
|
|
666
|
+
failure that writes no output at all on another. Every caller of this function
|
|
667
|
+
only ever wants a literal label, never `%{...}` expansion, so `%` is dropped
|
|
668
|
+
outright rather than chasing a per-build-safe escape (`expansion=none` on the
|
|
669
|
+
filter would also fix it, but needs touching every drawtext= call site instead
|
|
670
|
+
of the one shared helper). Control characters (newline, tab, ...) are dropped
|
|
671
|
+
for the same reason: none are meaningful in a one-line burnt-in label, and
|
|
672
|
+
unlike the graph-special characters above, ffmpeg's own text-expansion scanner
|
|
673
|
+
-- not just the graph parser -- is involved in whether they're actually safe."""
|
|
674
|
+
text = re.sub(r"[\x00-\x1f\x7f]", "", text)
|
|
639
675
|
return (
|
|
640
|
-
text.replace("
|
|
676
|
+
text.replace("'", "")
|
|
677
|
+
.replace("%", "")
|
|
678
|
+
.replace("\\", "\\\\")
|
|
641
679
|
.replace(":", "\\:")
|
|
642
|
-
.replace("'", "\\\\\\'")
|
|
643
|
-
.replace("%", "\\%")
|
|
644
680
|
.replace(",", "\\,")
|
|
645
681
|
.replace("[", "\\[")
|
|
646
682
|
.replace("]", "\\]")
|
|
683
|
+
.replace(";", "\\;")
|
|
647
684
|
)
|
|
648
685
|
|
|
649
686
|
|
|
@@ -815,6 +852,23 @@ def color_hex(value: str) -> str:
|
|
|
815
852
|
return v.upper()
|
|
816
853
|
|
|
817
854
|
|
|
855
|
+
_COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@[0-9.]+)?$")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def validate_color(value: str, flag: str = "--color") -> str:
|
|
859
|
+
"""Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
|
|
860
|
+
#RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
|
|
861
|
+
straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
|
|
862
|
+
validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
|
|
863
|
+
containing those characters lets a caller splice in an entirely different filter (a real,
|
|
864
|
+
demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
|
|
865
|
+
text), not just an odd colour. This is the same "no filter graph accepted from the caller"
|
|
866
|
+
invariant every other typed flag in this codebase already holds to."""
|
|
867
|
+
if not _COLOR_TOKEN_RE.match(value):
|
|
868
|
+
die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
|
|
869
|
+
return value
|
|
870
|
+
|
|
871
|
+
|
|
818
872
|
def print_json(obj: Any) -> None:
|
|
819
873
|
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
820
874
|
|
package/scripts/_contract.py
CHANGED
|
@@ -79,6 +79,42 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
79
79
|
"crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
|
|
80
80
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
81
81
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
82
|
+
"deinterlace": dict(role="execution", inputs=["video asset"], outputs=["deinterlaced (progressive) video artifact"],
|
|
83
|
+
required=FF + [X264, AAC, "filter:yadif"], optional=[HDR_X265],
|
|
84
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
85
|
+
"denoise": dict(role="execution", inputs=["video asset"], outputs=["denoised video artifact"],
|
|
86
|
+
required=FF + [X264, AAC, "filter:hqdn3d"], optional=[HDR_X265],
|
|
87
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
88
|
+
"cropdetect": dict(role="analysis", inputs=["video asset"], outputs=["detected crop rectangle JSON on stdout (no file)"],
|
|
89
|
+
required=FF + ["filter:cropdetect"], optional=[],
|
|
90
|
+
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=False, idempotency="environment_dependent", deterministic=False),
|
|
91
|
+
"redact": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the given pixel rectangle blurred or pixelated"],
|
|
92
|
+
required=FF + [X264, AAC, "filter:boxblur"], optional=[HDR_X265],
|
|
93
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
94
|
+
"waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
|
|
95
|
+
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}],
|
|
96
|
+
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
97
|
+
"sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
|
|
98
|
+
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
99
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
100
|
+
"grid": dict(role="execution", inputs=["cols*rows video assets, filled left-to-right top-to-bottom"], outputs=["composited grid video artifact"],
|
|
101
|
+
required=FF + [X264, "filter:xstack"], optional=[{"capability": "filter:drawtext", "when": "--label auto (the default)"}, {"capability": AAC, "when": "--audio-from"}],
|
|
102
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
103
|
+
"straighten": dict(role="execution", inputs=["video asset"], outputs=["video artifact rotated by the given angle (horizon correction)"],
|
|
104
|
+
required=FF + [X264, AAC, "filter:rotate"], optional=[HDR_X265],
|
|
105
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
106
|
+
"freeze": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a frame held for the given duration"],
|
|
107
|
+
required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
|
|
108
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
109
|
+
"pad": dict(role="execution", inputs=["video asset"], outputs=["video artifact with black/silent padding added at the start/end"],
|
|
110
|
+
required=FF + [X264, AAC, "filter:tpad"], optional=[HDR_X265],
|
|
111
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
112
|
+
"speedramp": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a stepped speed ramp applied across segments"],
|
|
113
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
114
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
115
|
+
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
|
|
116
|
+
required=FF + [X264, AAC], optional=[],
|
|
117
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
82
118
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
83
119
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
84
120
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
@@ -111,17 +147,19 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
111
147
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
112
148
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
113
149
|
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
114
|
-
required=FF
|
|
115
|
-
|
|
150
|
+
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
|
|
151
|
+
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"},
|
|
152
|
+
{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
116
153
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
117
154
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
118
|
-
required=FF + ["filter:loudnorm", AAC
|
|
155
|
+
required=FF + ["filter:loudnorm"], optional=[{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
119
156
|
video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
120
157
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
121
158
|
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
|
|
122
159
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
123
160
|
"join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
|
|
124
|
-
required=FF + [
|
|
161
|
+
required=FF + ["filter:xfade", "filter:acrossfade"],
|
|
162
|
+
optional=[{"capability": X264, "when": "joining video inputs"}, {"capability": AAC, "when": "joining video inputs, or an audio-only join whose output extension isn't .mp3/.opus/.ogg/.flac"}, HDR_X265] + AUDIO_OUT,
|
|
125
163
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
126
164
|
"color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
|
|
127
165
|
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
@@ -134,7 +172,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
134
172
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
135
173
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
136
174
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
137
|
-
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "
|
|
175
|
+
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
138
176
|
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
139
177
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
|
|
140
178
|
video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
@@ -169,6 +207,7 @@ DRY_RUN_ANALYSIS = {
|
|
|
169
207
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
170
208
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
171
209
|
"report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
|
|
210
|
+
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
172
211
|
}
|
|
173
212
|
DRY_RUN_NOTES = {
|
|
174
213
|
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
@@ -189,6 +228,18 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
189
228
|
"cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
|
|
190
229
|
"fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
|
|
191
230
|
"crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
|
|
231
|
+
"deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
232
|
+
"denoise": dict(video="always", audio="always", note="the hqdn3d filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
233
|
+
"cropdetect": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
234
|
+
"redact": dict(video="always", audio="always", note="the boxblur/pixelate filter_complex always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
235
|
+
"waveform": dict(video="always", audio="always", note="always encodes a fresh generated visualization clip; the source audio is always re-encoded to AAC"),
|
|
236
|
+
"sphere": dict(video="always", audio="always", note="the v360 filter always forces a re-encode of the video stream; audio is passed through to AAC unchanged"),
|
|
237
|
+
"straighten": dict(video="always", audio="always", note="the rotate filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
238
|
+
"grid": dict(video="always", audio="conditional", note="the xstack composite always forces a re-encode of the video stream; there is no audio at all unless --audio-from picks one input's track, which is then re-encoded to AAC"),
|
|
239
|
+
"freeze": dict(video="always", audio="always", note="the tpad/concat filter graph always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
240
|
+
"pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
|
|
241
|
+
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
242
|
+
"loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
|
|
192
243
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
193
244
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
194
245
|
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
package/scripts/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -49,11 +49,20 @@ def main() -> int:
|
|
|
49
49
|
c0, c1 = args.gradient.split(":")
|
|
50
50
|
except ValueError:
|
|
51
51
|
die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
|
|
52
|
+
validate_color(c0, "--gradient")
|
|
53
|
+
validate_color(c1, "--gradient")
|
|
52
54
|
rad = math.radians(args.angle)
|
|
53
55
|
x1 = round(args.width * math.cos(rad))
|
|
54
56
|
y1 = round(args.width * math.sin(rad))
|
|
55
|
-
|
|
57
|
+
# gradients defaults to seed=-1 (a random seed picked fresh each run) and speed=0.01 (a
|
|
58
|
+
# slow rotation applied every frame), so without pinning both, this "static" background
|
|
59
|
+
# was neither reproducible between runs nor actually static across its own duration --
|
|
60
|
+
# violating the bit_exact/deterministic contract _contract.py declares for this tool.
|
|
61
|
+
# speed's own valid range bottoms out at 1e-05 (0 is refused), so that's the closest to
|
|
62
|
+
# motionless the filter allows.
|
|
63
|
+
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}:seed=0:speed=1e-05"
|
|
56
64
|
else:
|
|
65
|
+
validate_color(args.color, "--color")
|
|
57
66
|
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
58
67
|
|
|
59
68
|
output = args.output
|