ffmpeg-skill 3.1.8 → 3.2.1
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 +4 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +12 -2
- package/package.json +1 -1
- package/references/scripts.md +56 -7
- package/scripts/_common/emit.py +22 -0
- package/scripts/_contract.py +16 -7
- package/scripts/audio.py +4 -3
- package/scripts/export.py +300 -10
- package/scripts/look.py +60 -5
- package/scripts/loop.py +182 -18
- package/scripts/loudness.py +136 -62
package/README.md
CHANGED
|
@@ -214,7 +214,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
214
214
|
|---|---|
|
|
215
215
|
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision (`hdr` for a real PQ/HLG/DV transfer only, `bt2020_or_hdr` also for BT.2020 SDR), colour space, rotation, every audio stream; `--analyze` flags Log footage |
|
|
216
216
|
| `scenes.py` | Scene changes, audio peaks, highlight proposals (`--rank-by audio` loudest, or `--rank-by duration` longest — both proxies, not "best") and a per-scene sheet; cut list for `cut.py --segments`; `--beats` measures the music's beat grid (tempo, beat times, confidence); `--shots` classifies each shot static/pan/motion by measured optical flow; `--audio-peaks` lists per-second dBFS; `--speech` reports a speech-vs-music ratio, not a classification |
|
|
217
|
-
| `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made; `--safe NAME` shades the zones a platform's own UI covers |
|
|
217
|
+
| `look.py` | Contact sheet, single frames, side-by-side comparison as PNG so the agent can see what it made; `--safe NAME` shades the zones a platform's own UI covers; `--guides title,action,4:3` outlines the broadcast safe areas (on the PNG only) |
|
|
218
218
|
|
|
219
219
|
**Editing**
|
|
220
220
|
|
|
@@ -240,7 +240,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
240
240
|
| `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
|
|
241
241
|
| `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 |
|
|
242
242
|
| `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 |
|
|
243
|
-
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--boomerang` plays it forward then backward |
|
|
243
|
+
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--crossfade` smooths the seam, `--boomerang` plays it forward then backward |
|
|
244
244
|
| `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
|
|
245
245
|
| `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit; `--auto-chapters` proposes the markers from measured pauses and scene cuts and names them `Chapter N` for you to rename |
|
|
246
246
|
| `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
|
|
@@ -251,7 +251,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
251
251
|
|---|---|
|
|
252
252
|
| `audio.py` | Voice clean-up chain at three strengths (`--voice light\|medium\|strong`), FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking (`--duck-amount/-threshold/-attack/-release`), a never-ducked effects bed (`--effects`), `--stereo-widen`, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
|
|
253
253
|
| `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection); a third or later camera is an additional `more_sources` positional (`sync.py REF A B ...`), the `second` positional itself keeps its name |
|
|
254
|
-
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only` |
|
|
254
|
+
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target (`--lra` for the range), video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only`; `--versions streaming,ebu-r128,broadcast-jp` (or `LUFS/TP` pairs) writes one file per target from one master, each measured after and listed in `versions` (`output` is null), `verified` only when every version met its own target |
|
|
255
255
|
|
|
256
256
|
**Picture**
|
|
257
257
|
|
|
@@ -266,7 +266,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
266
266
|
|
|
267
267
|
| Tool | What it does |
|
|
268
268
|
|---|---|
|
|
269
|
-
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
|
|
269
|
+
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `dnxhr-lb`/`sq`/`hq`/`hqx`/`444`, the alpha masters `prores4444`/`prores4444xq`/`qtrle`/`vp9-alpha`/`hap-alpha` (alpha read back), `key-fill` (a switcher's fill + key pair), `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR or are an editing master (`prores`, `dnxhr-*`: the source's tags kept, codec, profile and pixel format read back) (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
|
|
270
270
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
271
271
|
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence, short audio-dropout, single-frame-flash and flat-first-frame rows ; `--compare REF` measures SSIM/PSNR against a reference; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
|
|
272
272
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
package/SKILL.md
CHANGED
|
@@ -107,7 +107,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
107
107
|
| "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
108
108
|
| "smooth slow motion" | `fit.py in.mp4 --duration 2x --smooth interpolate` |
|
|
109
109
|
| "reverse this clip" | `reverse.py in.mp4` |
|
|
110
|
-
| "loop it to fill 30 seconds" | `loop.py clip.mp4 --duration 30` |
|
|
110
|
+
| "loop it to fill 30 seconds", "seamless loop" | `loop.py clip.mp4 --duration 30` (`--crossfade 0.5` hides the seam) |
|
|
111
111
|
| "make it vertical / 9:16 / square" | `fit.py in.mp4 --aspect 9:16 --fit pad` (or `crop`, `blur`) |
|
|
112
112
|
| "resize to a height/width" | `fit.py in.mp4 --height 1080` |
|
|
113
113
|
| "crop to this exact box" | `crop.py in.mp4 --x 100 --y 0 --width 1080 --height 1920` |
|
|
@@ -137,7 +137,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
137
137
|
| "use our brand fonts/colours/logo" | `--brand brand.json` on caption/overlay/graphics |
|
|
138
138
|
| "sync the lav mic", "line up two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` |
|
|
139
139
|
| "the audio drifts out of sync" | `sync.py camera.mp4 rec.wav --fix-drift --replace-audio` |
|
|
140
|
-
| "normalise to -14 LUFS" | `loudness.py in.mp4` |
|
|
140
|
+
| "normalise to -14 LUFS", "streaming and broadcast versions" | `loudness.py in.mp4` (`--versions streaming,ebu-r128`) |
|
|
141
141
|
| "clean up the audio", "remove the hiss" | `audio.py in.mp4 --voice` (or `--denoise`) |
|
|
142
142
|
| "music under the talking", "duck harder" | `audio.py in.mp4 --music bed.mp3 --duck --fade-out 3` |
|
|
143
143
|
| "convert the 5.1 to stereo" | `audio.py in.mov --downmix` |
|
|
@@ -148,7 +148,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
148
148
|
| "audiogram", "turn this podcast into a video" | `render.py --template audiogram ep.m4a --image cover.png` |
|
|
149
149
|
| "make this a Reel / TikTok / Short / YouTube" | `render.py --template reels in.mp4 --cues cues.txt` |
|
|
150
150
|
| "post it everywhere" | `render.py --template all in.mp4 --cues cues.txt` |
|
|
151
|
-
| "export for YouTube / Reels / X", "ProRes master" | `export.py in.mp4 --preset youtube` |
|
|
151
|
+
| "export for YouTube / Reels / X", "ProRes / DNxHR / alpha master", "key and fill" | `export.py in.mp4 --preset youtube` (`prores`, `dnxhr-hq`, `prores4444`, `key-fill`) |
|
|
152
152
|
| "under 25 MB" | `export.py in.mp4 --preset youtube --max-mb 25` |
|
|
153
153
|
| "make a GIF preview" | `export.py in.mp4 --preset gif` |
|
|
154
154
|
| "the recording is corrupted" | `export.py in.mp4 --preset copy --repair` |
|
|
@@ -172,7 +172,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
172
172
|
| "every file in the folder", "use all the cores" | `batch.py FOLDER --recipe batch.json --jobs auto` |
|
|
173
173
|
| "three cameras, cut between them" | `multicam.py a.mp4 b.mp4 c.mp4 --switch energy` |
|
|
174
174
|
| "what's in this file", "is it Log?" | `probe.py in.mp4` (`--analyze`) |
|
|
175
|
-
| "show me what it looks like" | `look.py out.mp4 --tiles 3x2`, then view the PNG |
|
|
175
|
+
| "show me what it looks like", "title safe" | `look.py out.mp4 --tiles 3x2`, then view the PNG (`--at T --guides title,action`) |
|
|
176
176
|
| "static vs moving shots", "speech or music" | `scenes.py in.mp4 --shots` (`--audio-peaks`, `--speech`) |
|
|
177
177
|
| "where does the subject move" | `cropdetect.py in.mp4 --motion-centre` |
|
|
178
178
|
| "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
|
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.1
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.2.1`) | 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.1
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.2.1", "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"},
|
|
@@ -780,6 +780,16 @@ the track gone and nothing in the result saying so):
|
|
|
780
780
|
| `non_av_streams` | `export.py` | the source's subtitle and data streams: `{kept: [{type, index, codec, language, output_codec}], dropped: [{type, index, codec, language, reason}]}` (`index` is the n-th stream of its type; `language` on subtitles). Read back from the written file; under `--dry-run`, as planned. A subtitle track is copied when the container holds it, else converted to the container's text track (`mov_text` in .mp4/.mov, SubRip in .mkv); a platform trim cuts it with the picture. `reason`: `write_failed` (the attempt that mapped it failed and the encode ran again without it; the error is in its `non_av_streams_dropped` fallback, whose `dropped` says data alone first, then subtitles too), `gif`, `not_in_output` |
|
|
781
781
|
| `dropped_non_av_streams` | `export.py` | `true` when `non_av_streams.dropped` is not empty (a `notes` line too), as on the tools that use `run_keeping_subtitles()` |
|
|
782
782
|
|
|
783
|
+
Keys added after 3.1.8, additive (delivery masters and versions, #417-#423):
|
|
784
|
+
|
|
785
|
+
| key | tool | what it holds |
|
|
786
|
+
|---|---|---|
|
|
787
|
+
| `alpha_mode`; `verification[]` steps `codec`, `alpha` | `export.py` alpha presets (`prores4444`, `prores4444xq`, `qtrle`, `vp9-alpha`, `hap-alpha`); `codec` also on `prores` and `dnxhr-*` | `alpha_mode`: `straight` or `premultiplied` (`--alpha-mode`). `codec`: the written codec, profile and pixel format against the preset's. `alpha`: the written file's alpha against the source's frame by frame (the mean alpha of a 64x36 copy of each frame), each within 2 of 255 (`comparison: {ok, paired, worst_delta, worst_frame}`; with a different frame count, a conformed variable-rate source, the mean and the most transparent frame, `paired: false`; `frames: {expected, measured}`, each `{mean, min, frames}`). A source without alpha is refused; a dry run decides on the pixel format and a VP9 stream's `alpha_mode` tag without decoding. A VP9 source is decoded with libvpx-vp9, and these presets stay on the CPU under `--hw` (a note says so) |
|
|
788
|
+
| `key`, `fill_sha256`; steps `key_fill_pair`, `key_level` | `export.py --preset key-fill` | the key file `{output (<out stem>_key.mov), identity, sha256, timecode}` and the fill's SHA-256. `key_fill_pair`: width, height, frame count, rate and the video streams' lengths equal on both; `key_level`: the key's level against the source's alpha over every frame. Both paths are guarded as outputs (not the input, not an existing file without `--overwrite`); the source's subtitle/data streams are reported in `non_av_streams.dropped` with `reason: key_fill`. `--max-mb` is refused |
|
|
789
|
+
| `versions`, `outputs` | `loudness.py --versions` | one row per target `{target, output, targets, result}` (top-level `output` is null) and the files written; each version's `loudness` and `duration` steps carry `version`, and `verified` holds only when every version met its own target. An empty list is refused; a `--plan` path that is one of the version files is refused (`output_path_taken`) |
|
|
790
|
+
| `crossfade`, `expected_duration`, `seam` | `loop.py` | `crossfade`: the fade in seconds, whole frames; `expected_duration`: the length asked for, checked by the `duration` step within 1.5 frames; `seam`: the first seam measured (`video_step` against `video_typical_step`, `audio_level_step_db`) |
|
|
791
|
+
| `guides` | `look.py --guides` | each outline drawn on the PNG (`action` 0.93, `title` 0.90 of the frame, or the `4:3` centre cut with its own); a frame (either clip under `--compare`) no wider than 4:3 refuses `4:3` |
|
|
792
|
+
|
|
783
793
|
Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
784
794
|
|
|
785
795
|
| key | tool | what it holds |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -550,15 +550,24 @@ output; the result says so with `dropped_non_av_streams: true`.
|
|
|
550
550
|
|
|
551
551
|
### loop.py — repeat a clip
|
|
552
552
|
```
|
|
553
|
-
loop.py INPUT --times N | --duration T [--boomerang] [-o OUT]
|
|
553
|
+
loop.py INPUT --times N | --duration T [--crossfade S | --boomerang] [-o OUT]
|
|
554
554
|
```
|
|
555
555
|
`--times` repeats the whole clip that many times back to back (2 =
|
|
556
556
|
original + 1 repeat). `--duration` instead loops (and trims the last
|
|
557
557
|
repeat) to hit an exact target length. For a background loop, an ambient
|
|
558
|
-
bed, or filling a fixed slot length with a short clip.
|
|
559
|
-
loop point
|
|
560
|
-
|
|
561
|
-
the
|
|
558
|
+
bed, or filling a fixed slot length with a short clip. Without `--crossfade`
|
|
559
|
+
the loop point is not smoothed: a clip that doesn't already loop cleanly
|
|
560
|
+
shows a visible cut/pop at each repeat.
|
|
561
|
+
`--crossfade S` makes the clip loop first: its last S seconds (whole frames)
|
|
562
|
+
fade into its first S, picture and sound, so each seam continues the motion.
|
|
563
|
+
One repeat is then the clip minus S (a 4 s clip with `--crossfade 1` repeats
|
|
564
|
+
every 3 s), S may be at most half the clip, and `--times 1` writes one
|
|
565
|
+
seamless loop file. The repeat is written losslessly to a temporary file
|
|
566
|
+
first, so the output still has a single lossy encode.
|
|
567
|
+
Every run except `--boomerang` measures the first seam of the written file
|
|
568
|
+
(`seam`: the picture step across it against the typical step between
|
|
569
|
+
neighbouring frames, and the sound level step in dB) and checks the length
|
|
570
|
+
against the request (`expected_duration`, within 1.5 frames).
|
|
562
571
|
`--boomerang` plays the clip forward, then backward (frames 0..N-1, then
|
|
563
572
|
N-2..1, repeated), so any clip loops without a jump and neither turnaround
|
|
564
573
|
frame is shown twice. `--times` then counts round trips (1 is allowed), and
|
|
@@ -1441,7 +1450,13 @@ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact shee
|
|
|
1441
1450
|
look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
|
|
1442
1451
|
look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
|
|
1443
1452
|
look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
|
|
1453
|
+
look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
|
|
1444
1454
|
```
|
|
1455
|
+
`--guides` draws safe-area outlines on the PNG only (look.py writes no video, so a guide never
|
|
1456
|
+
reaches a delivery): `action` 93 % and `title` 90 % of the frame (SMPTE ST 2046-1), and `4:3` the
|
|
1457
|
+
centre 4:3 window of a wider frame with SD's 90 % action and 80 % title safe inside it (refused on
|
|
1458
|
+
a frame that is not wider than 4:3). It combines with `--safe PLATFORM` (the app's own UI zones)
|
|
1459
|
+
and works on a contact sheet's tiles as on a single frame; the result's `guides` lists them.
|
|
1445
1460
|
Outputs PNG. View it with the Read tool (or any image viewer) and judge the
|
|
1446
1461
|
frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
|
|
1447
1462
|
destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
|
|
@@ -1836,6 +1851,11 @@ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25
|
|
|
1836
1851
|
[--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
|
|
1837
1852
|
[--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
|
|
1838
1853
|
```
|
|
1854
|
+
`--denoise` is `afftdn=nf=-N:tn=1`, where N is `--denoise-strength`, the noise
|
|
1855
|
+
floor in dB below full scale (20..60, default 25). It is the starting floor that
|
|
1856
|
+
`tn=1` then tracks, not an amount removed; ffmpeg's `nf` accepts -80..-20 only, so
|
|
1857
|
+
values under 20 are refused before ffmpeg runs.
|
|
1858
|
+
|
|
1839
1859
|
`--voice` takes a strength; a bare `--voice` is `medium`, the chain it has always
|
|
1840
1860
|
produced. The exact filter chains:
|
|
1841
1861
|
|
|
@@ -1882,7 +1902,16 @@ Run `loudness.py` after this for final levels.
|
|
|
1882
1902
|
### loudness.py — EBU R128 normalisation
|
|
1883
1903
|
```
|
|
1884
1904
|
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
1885
|
-
|
|
1905
|
+
loudness.py INPUT --versions=streaming,ebu-r128,broadcast-jp[,-16/-1.5] [--lra 11]
|
|
1906
|
+
```
|
|
1907
|
+
`--versions` writes one file per target from the one master, each a full
|
|
1908
|
+
two-pass normalisation measured after: `streaming` (-14 LUFS / -1 dBTP),
|
|
1909
|
+
`ebu-r128` (-23 / -1), `broadcast-jp` (-24 LKFS / -1, ARIB TR-B32), or any
|
|
1910
|
+
`LUFS/TP` pair (write `--versions=...` when the list starts with a `-`). Files are `<name>_loudnorm_<target>.<ext>` (`-o` is refused);
|
|
1911
|
+
the result's `versions` lists each target, file and measurement, its
|
|
1912
|
+
verification steps carry a `version` key, and `verified` holds only when every
|
|
1913
|
+
version met its own target. An empty list (`--versions=`) is refused, and so is a `--plan` path
|
|
1914
|
+
that is one of the version files.
|
|
1886
1915
|
`--lra N` is the loudness-range target in LU (default 11): lower it to squeeze a
|
|
1887
1916
|
wide-dynamic mix into a phone speaker, raise it to leave a film mix alone.
|
|
1888
1917
|
`--json` reports the measured range on both sides — `measured.input_lra` for the
|
|
@@ -1903,11 +1932,31 @@ integrated loudness ended more than 1 LU from the target because of it.
|
|
|
1903
1932
|
|
|
1904
1933
|
### export.py — delivery presets
|
|
1905
1934
|
```
|
|
1906
|
-
export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
|
|
1935
|
+
export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|dnxhr-lb|dnxhr-sq|dnxhr-hq|dnxhr-hqx|dnxhr-444|prores4444|prores4444xq|qtrle|vp9-alpha|hap-alpha|key-fill|h265|gif|copy
|
|
1907
1936
|
[--fit pad|crop] [--no-scale] [--allow-long] [--crf N | --max-mb MB] [--normalize] [-o OUT]
|
|
1908
1937
|
export.py INPUT --preset copy --repair [-o OUT]
|
|
1909
1938
|
export.py --list
|
|
1910
1939
|
```
|
|
1940
|
+
Editing masters (`prores`, `dnxhr-lb/sq/hq/hqx/444`, and the alpha presets) keep the source's size,
|
|
1941
|
+
rate and colour tags and read the written codec and profile back (`codec` verification step).
|
|
1942
|
+
The alpha presets `prores4444`, `prores4444xq` (16-bit alpha), `qtrle`, `vp9-alpha` (WebM, Opus)
|
|
1943
|
+
and `hap-alpha` (an ffmpeg built with snappy; width and height multiples of 4) refuse a source
|
|
1944
|
+
without alpha, take `--alpha-mode straight|premultiplied` (default straight, reported as
|
|
1945
|
+
`alpha_mode`), and compare the mean alpha of each written frame with the source's, frame by frame
|
|
1946
|
+
on 64x36 copies, each within 2 of 255 (a region smaller than the copy's pixels can lose its alpha
|
|
1947
|
+
unseen; `alpha` step; a different frame count falls back to the mean and the most transparent frame,
|
|
1948
|
+
`comparison.paired: false`). A VP9 source is decoded with libvpx-vp9 (the
|
|
1949
|
+
native decoder drops the alpha); a dry run decides from the pixel format and a VP9 stream's
|
|
1950
|
+
`alpha_mode` tag without decoding. Under `--hw` these presets stay on the CPU (VideoToolbox's ProRes
|
|
1951
|
+
is 422 HQ, with no alpha), and a note says so. PNG/EXR image sequences are not written by `export.py`.
|
|
1952
|
+
`--preset key-fill` writes a switcher's key/fill pair from an alpha source in one run: the fill
|
|
1953
|
+
(`-o`, the colour; `--alpha-mode premultiplied` premultiplies it) and the key (`<out stem>_key.mov`,
|
|
1954
|
+
the alpha as a luma matte: alpha 255 is video white, 0 black), both ProRes 422 HQ with the source's
|
|
1955
|
+
start timecode. It checks both have the same size, frame count, rate and video length (`key_fill_pair`)
|
|
1956
|
+
and the key's level against the source's alpha over every frame (`key_level`), and reports both
|
|
1957
|
+
SHA-256 hashes (`fill_sha256`, `key.sha256`). Both paths are guarded like `-o` (not the input, not
|
|
1958
|
+
an existing file without `--overwrite`, written through a temp file); the source's subtitle/data
|
|
1959
|
+
streams are not written (`non_av_streams.dropped`, `reason: key_fill`); `--max-mb` is refused.
|
|
1911
1960
|
`--repair` (2.24, `--preset copy` only) remuxes a damaged file: timestamps are regenerated
|
|
1912
1961
|
(`-fflags +genpts`), packets flagged corrupt are dropped (`+discardcorrupt`), demuxing goes on past
|
|
1913
1962
|
errors (`-err_detect ignore_err`) and negative timestamps are shifted to zero. The input is read,
|
package/scripts/_common/emit.py
CHANGED
|
@@ -852,6 +852,21 @@ def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Option
|
|
|
852
852
|
return seen
|
|
853
853
|
|
|
854
854
|
|
|
855
|
+
def _planned_outputs(commands: Sequence[str]) -> List[str]:
|
|
856
|
+
"""The file each planned command writes: its last argument, unless that is stdout, a pipe or
|
|
857
|
+
an option (a measurement's `-f null -`)."""
|
|
858
|
+
import shlex
|
|
859
|
+
out: List[str] = []
|
|
860
|
+
for line in commands:
|
|
861
|
+
try:
|
|
862
|
+
toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
|
|
863
|
+
except ValueError:
|
|
864
|
+
continue
|
|
865
|
+
if len(toks) > 1 and toks[-1] != "-" and not toks[-1].startswith(("pipe:", "-")):
|
|
866
|
+
out.append(toks[-1])
|
|
867
|
+
return out
|
|
868
|
+
|
|
869
|
+
|
|
855
870
|
def _plan_unbound_inputs(commands: Sequence[str], ctx: "Optional[Context]" = None) -> List[str]:
|
|
856
871
|
"""The inputs _plan_inputs() could not fingerprint because they did not exist when the plan
|
|
857
872
|
was made: a planned command's `-i` file (or a side input registered through
|
|
@@ -926,6 +941,13 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
|
|
|
926
941
|
refuse("input", "output_is_input" if named != output else "output_path_taken",
|
|
927
942
|
f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
|
|
928
943
|
f"({named!r}); give the plan its own path", output=path, other=named)
|
|
944
|
+
# and every file a planned command writes (its last argument): a tool with several outputs
|
|
945
|
+
# (loudness.py --versions, whose `output` is null; export.py's key file) names them only there,
|
|
946
|
+
# and a plan written over one of them is refused as an existing output when it is executed
|
|
947
|
+
for written in _planned_outputs(ctx.commands):
|
|
948
|
+
if os.path.realpath(written) == real:
|
|
949
|
+
refuse("input", "output_path_taken", f"--plan {path!r} is also a file this run writes ({written!r}); "
|
|
950
|
+
"give the plan its own path", output=path, other=written)
|
|
929
951
|
doc = {
|
|
930
952
|
"plan_version": PLAN_VERSION,
|
|
931
953
|
"created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
package/scripts/_contract.py
CHANGED
|
@@ -131,7 +131,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
131
131
|
{"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
|
|
132
132
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
133
133
|
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
|
|
134
|
-
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}],
|
|
134
|
+
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
135
135
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
136
136
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
137
137
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
@@ -197,8 +197,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
197
197
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
198
198
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
199
199
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
200
|
-
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores
|
|
201
|
-
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
200
|
+
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores and dnxhr-* use pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
201
|
+
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
202
202
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
|
|
203
203
|
{"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
|
|
204
204
|
{"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
|
|
@@ -276,7 +276,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
276
276
|
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
277
277
|
"broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
|
|
278
278
|
"metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
|
|
279
|
-
"loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
|
|
279
|
+
"loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --crossfade first writes one repeat losslessly (FFV1 + FLAC, a temporary file) and repeats that, so the output still has one lossy encode. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
|
|
280
280
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
281
281
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
282
282
|
"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"),
|
|
@@ -622,7 +622,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
622
622
|
"sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given; with --fix-drift each also carries drift_skipped_reason"},
|
|
623
623
|
"drift_skipped_reason": {"type": ["string", "null"], "enum": ["too_short", "low_confidence", None], "description": "--fix-drift, one SOURCE: why the drift correction was not applied (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was; also a diagnostics.skipped entry, drift_correction_skipped"}}
|
|
624
624
|
elif name == "look":
|
|
625
|
-
extra = {"outputs": {"type": "array", "items": {"type": "string"}}
|
|
625
|
+
extra = {"outputs": {"type": "array", "items": {"type": "string"}},
|
|
626
|
+
"guides": {"type": "array", "description": "--guides only: each outline drawn on the PNG, {guide: action|title, fraction (0.93 / 0.90 of the frame, SMPTE ST 2046-1), of: frame} or {guide: 4:3, window, action: 0.90, title: 0.80}; drawn on the preview only"}}
|
|
626
627
|
elif name == "render":
|
|
627
628
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
|
|
628
629
|
"cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
@@ -652,14 +653,19 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
652
653
|
elif name == "export":
|
|
653
654
|
extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
|
|
654
655
|
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
|
|
656
|
+
"alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
|
|
657
|
+
"key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
|
|
658
|
+
"fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
|
|
655
659
|
"trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
|
|
656
|
-
"non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
|
|
660
|
+
"non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|key_fill|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
|
|
657
661
|
"dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
|
|
658
662
|
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
659
663
|
elif name == "loudness":
|
|
660
664
|
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
|
|
661
665
|
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
662
|
-
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}
|
|
666
|
+
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"},
|
|
667
|
+
"versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
|
|
668
|
+
"outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
|
|
663
669
|
elif name == "color":
|
|
664
670
|
extra = {"dropped_non_av_streams": {"type": "boolean"},
|
|
665
671
|
"sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
|
|
@@ -668,6 +674,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
668
674
|
"measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
|
|
669
675
|
elif name == "loop":
|
|
670
676
|
extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
|
|
677
|
+
"crossfade": {"type": "number", "description": "--crossfade only: the fade used, in seconds, rounded to whole frames; one repeat is the clip minus this"},
|
|
678
|
+
"expected_duration": {"type": "number", "description": "seconds requested: --duration, or --times x one repeat (the clip, the clip minus the crossfade, or a boomerang round trip); the duration verification step compares the file against it within 1.5 frames"},
|
|
679
|
+
"seam": {"type": "object", "description": "not with --boomerang: the first seam of the written file measured (at: between_repeats, or end_to_start for one repeat; time; video_step and video_typical_step, the mean luma change across the seam and the median one between neighbouring frames on a 64x36 grey copy, 0-255; audio_level_step_db, the RMS level 20 ms after the seam minus 20 ms before). A measurement, not a pass/fail: a step far above the typical one is a visible jump"},
|
|
671
680
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
|
|
672
681
|
elif name == "cut":
|
|
673
682
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
package/scripts/audio.py
CHANGED
|
@@ -161,7 +161,7 @@ def main() -> int:
|
|
|
161
161
|
ap.add_argument("-o", "--output", help="output file (default: <name>_audio.<ext>)")
|
|
162
162
|
clean = ap.add_argument_group("clean-up")
|
|
163
163
|
clean.add_argument("--denoise", action="store_true", help="FFT noise reduction (afftdn, adaptive)")
|
|
164
|
-
clean.add_argument("--denoise-strength", type=float, default=25.0, help="noise floor in dB
|
|
164
|
+
clean.add_argument("--denoise-strength", type=float, default=25.0, help="afftdn noise floor in dB below full scale, 20..60 (default 25; ffmpeg accepts nf -80..-20, and tn=1 then tracks the floor from this starting point)")
|
|
165
165
|
clean.add_argument("--voice", nargs="?", const="medium", choices=["light", "medium", "strong"], default=None,
|
|
166
166
|
help="speech preset (default medium when the flag is given bare): light = highpass 80 Hz + gentle compression; "
|
|
167
167
|
"medium = highpass, de-esser, denoise, gentle compression; strong = medium plus a harder de-esser, a second "
|
|
@@ -305,8 +305,9 @@ def main() -> int:
|
|
|
305
305
|
if args.voice:
|
|
306
306
|
fx.append(VOICE_LEVELS[args.voice])
|
|
307
307
|
elif args.denoise:
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
# afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
|
|
309
|
+
if not 20 <= args.denoise_strength <= 60:
|
|
310
|
+
die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
|
|
310
311
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
311
312
|
if args.gain:
|
|
312
313
|
fx.append(f"volume={args.gain:g}dB")
|