ffmpeg-skill 3.2.1 → 3.2.3
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 +14 -11
- package/SKILL.md +6 -1
- package/docs/contract.md +21 -8
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +127 -7
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +153 -15
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +69 -17
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +99 -8
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
- package/scripts/scenes.py +46 -11
package/README.md
CHANGED
|
@@ -198,7 +198,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
198
198
|
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
199
199
|
4. **Machine-readable contract.** `contract --json` describes all 42 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 42 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`.
|
|
200
200
|
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.
|
|
201
|
-
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.
|
|
201
|
+
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -decoders / -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.
|
|
202
202
|
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.
|
|
203
203
|
8. **Verify the result.** The output is probed, and when the picture changed (captions, overlays, crops, colour, transitions) the agent runs `look.py` and inspects the PNG. The report is not finished until its `Look:` line names that image; audio-only jobs say `Look: not needed`. **"Inspects" means the calling agent's own vision, not a feature of this skill:** `look.py` only renders a PNG; nothing in this repository detects faces, products, subjects, or "the interesting part" of a frame or a scene. When a crop or reframe needs to keep a specific part of the frame (`fit.py --fit crop --crop-x/-y`, see [Tools](#tools)), it is the multimodal agent looking at that PNG and choosing the anchor — a non-visual caller (a script, a CLI user without eyes on the sheet) has to supply that decision itself, and the default is a plain centre crop. Likewise `scenes.py --highlights` ranks candidate scenes by a measured proxy (`--rank-by audio` or `--rank-by duration`), never by content; it is the agent that turns a look at the sheet into a judgement.
|
|
204
204
|
9. **One label per report.** A finished job is `Done:`, a failure or a refusal is `Failed:`, and a partial result is `Done:` with the shortfall named in `Notes:` — never a third label such as `Done (partially):`.
|
|
@@ -220,7 +220,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
220
220
|
|
|
221
221
|
| Tool | What it does |
|
|
222
222
|
|---|---|
|
|
223
|
-
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, an SDR HEVC source re-encodes as HEVC (`--no-keep-hevc` for x264), a single `.mp4`/`.mov` copy keeps its edit list, so the picture starts at `--start` unless a keyframe lands later (`start_snapped` then says so; `--no-edit-list` for 2.x's shifted start), `--vfr-guard` to choose the VFR check (`sampled` by default); reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse ; `--remove A-B,...` drops ranges and keeps the rest |
|
|
223
|
+
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--smart` for a frame-exact H.264 cut that re-encodes only the partial GOPs at the cut points (matched to the source, verified against its packets and frames) and copies the rest, an SDR HEVC source re-encodes as HEVC (`--no-keep-hevc` for x264), a single `.mp4`/`.mov` copy keeps its edit list, so the picture starts at `--start` unless a keyframe lands later (`start_snapped` then says so; `--no-edit-list` for 2.x's shifted start), `--vfr-guard` to choose the VFR check (`sampled` by default); reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse ; `--remove A-B,...` drops ranges and keeps the rest |
|
|
224
224
|
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` ; `--copy --transition none` joins clips with matching streams by stream copy, measured afterwards, and refuses a mismatch by name |
|
|
225
225
|
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech (`--margin`, 0.2 s each side); list or export the cut list, or a source-to-output time map (`--timemap`, JSON or CSV); reports the level at every cut and warns where one may land in a breath or a word's tail; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
|
|
226
226
|
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, 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 |
|
|
@@ -232,7 +232,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
232
232
|
| `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 |
|
|
233
233
|
| `straighten.py` | Rotate by an arbitrary angle for horizon correction (`--degrees`, `--fit crop/pad`) — distinct from `fit.py --rotate`'s exact 90-degree turns |
|
|
234
234
|
| `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 |
|
|
235
|
-
| `background.py` | Generate a solid-colour or two-colour gradient clip at an exact size/duration — no input file |
|
|
235
|
+
| `background.py` | Generate a solid-colour or two-colour gradient clip at an exact size/duration — no input file; `--transparent` writes a fully transparent canvas (ProRes 4444, QuickTime Animation, PNG or VP9 with alpha) to draw graphics on |
|
|
236
236
|
| `reverse.py` | Reverse playback (video and, unless `--no-audio`, audio) |
|
|
237
237
|
| `stabilize.py` | Two-pass motion stabilisation (`vidstabdetect`/`vidstabtransform`) |
|
|
238
238
|
| `sequence.py` | Numbered (`frame_%04d.png`) or glob-matched still images into a video |
|
|
@@ -257,10 +257,10 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
257
257
|
|
|
258
258
|
| Tool | What it does |
|
|
259
259
|
|---|---|
|
|
260
|
-
| `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area — with `--platform`, the destination's own left/right safe zone, which since 1.17.2 is also what the burnt ASS states as its side margins — with a phrase-aware breaker (`--wrap phrase
|
|
261
|
-
| `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing, `--place` to lay ranges of a layer at chosen times |
|
|
262
|
-
| `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs, social stickers, opening hook cards and meme captions drawn by FFmpeg from a brand kit; `--platform NAME` keeps them inside that destination's safe zone; `--text-render` routes shaping scripts through libass and `--emoji-assets` composites colour emoji |
|
|
263
|
-
| `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
|
|
260
|
+
| `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area — with `--platform`, the destination's own left/right safe zone, which since 1.17.2 is also what the burnt ASS states as its side margins — with a phrase-aware breaker (`--wrap phrase\|measured`) and `--max-lines`/`--min-duration`/`--offset`; `--mode mux` takes a repeated `--srt file:lang` for several language-tagged, toggleable tracks in one file; picks a font by script for non-Latin text (`--lang`); animated and word-by-word karaoke timed to the speech energy or real word timings; `--fit-size` shrinks the size until a cue fits `--max-lines` instead of splitting the sentence (on by default on the delivery-template path since 1.17.1, where the size comes from the platform table); says `caption text unchanged` when it burned the cues exactly as given; optional local transcription |
|
|
261
|
+
| `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing, `--place` to lay ranges of a layer at chosen times; `--keep-alpha` composites onto a transparent canvas or alpha clip and writes the result with its alpha; a VP9 layer with alpha keeps its transparency |
|
|
262
|
+
| `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs, social stickers, opening hook cards and meme captions drawn by FFmpeg from a brand kit; `--platform NAME` keeps them inside that destination's safe zone; `--text-render` routes shaping scripts through libass and `--emoji-assets` composites colour emoji; `--keep-alpha` renders a template on a transparent canvas as a per-element alpha layer |
|
|
263
|
+
| `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves); `--keep-alpha` grades an alpha clip and keeps its alpha, checked frame by frame; `--legalize bt709\|ebu-r103`: a legal-range clamp after the 4:2:0 conversion, measured on the written file |
|
|
264
264
|
|
|
265
265
|
**Delivery**
|
|
266
266
|
|
|
@@ -268,7 +268,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
268
268
|
|---|---|
|
|
269
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
|
-
| `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`) |
|
|
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; `--levels bt709\|ebu-r103` counts the luma and chroma samples outside the broadcast legal range, per clip and per window, with min/max Y/U/V; `--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 |
|
|
273
273
|
|
|
274
274
|
**Orchestration**
|
|
@@ -292,7 +292,7 @@ WAV, FLAC, MP3, M4A/AAC, OGG and Opus go through `probe`, `cut`, `join`, `silenc
|
|
|
292
292
|
- **Typed dynamics.** `audio.py --compress --comp-threshold -20 --comp-ratio 4`, `--limit --limit-ceiling -1`, `--gate --gate-threshold -45`. Each flag is one documented option of FFmpeg's `acompressor`, `alimiter` or `agate`, range-checked before ffmpeg runs; no filter string is accepted from the caller.
|
|
293
293
|
- **Loudness.** `loudness.py talk.wav -I -16 --tp -1.5 -o talk.m4a` for podcast levels; `check.py talk.m4a --platform podcast` measures LUFS and true peak and reports the chapter markers and channel count.
|
|
294
294
|
|
|
295
|
-
Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `
|
|
295
|
+
Picture tools (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `look`) refuse an audio file with "input has no video stream" instead of inventing a picture. `scenes.py` measures an audio file (`audio_only: true`, one scene): `--beats`, `--audio-peaks`, `--speech` and `--highlights` run on the sound, and `--sheet` / `--shots` are refused.
|
|
296
296
|
|
|
297
297
|
## Built for agents
|
|
298
298
|
|
|
@@ -372,7 +372,7 @@ npx ffmpeg-skill doctor # human-readable
|
|
|
372
372
|
npx ffmpeg-skill doctor --json # available / missing / missing_optional / unknown / detection / errors / tools / gpu_encoders / ffmpeg_security
|
|
373
373
|
```
|
|
374
374
|
|
|
375
|
-
`doctor` reads `ffmpeg -encoders`, `-filters` and `-bsfs` and resolves every capability the contract declares against this machine's build. Three states per capability: `available`, `missing`, `unknown`. Exit 0 when everything required is available, 1 when something required is missing, 2 when nothing is proven missing but a required capability is unknown. With detection on (the default), `contract --json` carries the same lists under `capabilities`. `doctor` also reports fonts: the default drawtext family, and `fonts.scripts` — one `available`/`missing`/`unknown` per writing system (ja, zh, ko, ar, he, hi, bn, ta, th, lo, ru, el, latin) with the file it would use — so "can this machine render Korean captions" is answered before the job, not after. `doctor --json`'s `tools` field folds that down to one answer per tool — `{"caption": {"usable": "no", "missing": ["filter:subtitles"], "fix": "..."}, ...}` — so "is `doctor` overall `ok`" and "can I run `caption.py` on this machine" are answered separately: a plain Homebrew `ffmpeg` is `ok` for tools that don't need `subtitles`/`drawtext`/`zscale`, while `caption`'s own `usable` is `"no"`.
|
|
375
|
+
`doctor` reads `ffmpeg -encoders`, `-decoders`, `-filters` and `-bsfs` and resolves every capability the contract declares against this machine's build. Three states per capability: `available`, `missing`, `unknown`. Exit 0 when everything required is available, 1 when something required is missing, 2 when nothing is proven missing but a required capability is unknown. With detection on (the default), `contract --json` carries the same lists under `capabilities`. `doctor` also reports fonts: the default drawtext family, and `fonts.scripts` — one `available`/`missing`/`unknown` per writing system (ja, zh, ko, ar, he, hi, bn, ta, th, lo, ru, el, latin) with the file it would use — so "can this machine render Korean captions" is answered before the job, not after. `doctor --json`'s `tools` field folds that down to one answer per tool — `{"caption": {"usable": "no", "missing": ["filter:subtitles"], "fix": "..."}, ...}` — so "is `doctor` overall `ok`" and "can I run `caption.py` on this machine" are answered separately: a plain Homebrew `ffmpeg` is `ok` for tools that don't need `subtitles`/`drawtext`/`zscale`, while `caption`'s own `usable` is `"no"`.
|
|
376
376
|
|
|
377
377
|
`doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). It never affects `ok` or any tool's `usable`. The one GPU path a tool uses is Apple VideoToolbox, opt-in: `--hw` on every re-encoding tool (h264/hevc/prores on the GPU, AV1 stays on SVT-AV1), `FFMPEG_SKILL_HW=1` to make it this machine's default for everything except `export.py`'s delivery presets (those take `--hw` explicitly, or `render.py --hw` for a whole project). It runs only on Apple Silicon and never by default, except on a build with no `libx264`/`libx265` (an LGPL ffmpeg that has only `h264_videotoolbox`/`hevc_videotoolbox`): there it is the only encoder, so every re-encoding tool and `export.py`'s presets use it without `--hw`, the result says `hw.source: "auto"`, and `doctor` lists the missing x264/x265 under `covered_by` instead of calling the tools unusable (`--no-hw` still asks for the CPU). It falls back to the CPU encoder — reported in the result's `hw.fallback`/`hw.notes` — only when VideoToolbox itself refuses a job (any other ffmpeg failure is reported as it happened, not retried); every result names the `encoder` that actually ran (`batch.py`, which encodes nothing itself: each `results` row), and a GPU encode adds a `notes` line that its quality is not CRF-equivalent. Measured on an M4 Max, 2–7× faster than x264/x265 `medium`; at matched SSIM (`tests/bench_vt.py`) 1.1–2.9× the bytes on SDR and 1.9–3.5× on HDR, which is why it is a flag and not the default (`docs/design-decisions.md`). `doctor --json`'s `hw` says whether this machine can run it (`platform_ok`) and whether it is the default here (`default_on`).
|
|
378
378
|
|
|
@@ -384,13 +384,16 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
384
384
|
|
|
385
385
|
- **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically. `cut.py` samples the frame timing before a copy and switches to frame-accurate mode when it is variable or too short to judge, because copy-cuts on VFR land on the wrong frame (`--vfr-guard average` restores 2.x's rule, re-encoding whenever the nominal and average rates differ; `--vfr-guard off` keeps the copy). Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
|
|
386
386
|
- **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1` (plus `--vfr-guard off` on a variable-frame-rate source), and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`. A `--segments` join can still be re-cut: open-GOP video, a later segment of B-frame `.mp4`/`.mov` video that starts between keyframes (start it on a keyframe, or write `.mkv`), or a join that fails its check. A single `.mp4`/`.mov` copy keeps its edit list by default (since 3.0.0), so the picture starts where asked: the keyframe's pre-roll is stored but hidden by an MP4 edit list, which a player that ignores edit lists shows (`--no-edit-list` starts the file at the keyframe instead).
|
|
387
|
+
- **Frame-exact without re-encoding the whole cut (H.264).** `cut.py --smart` re-encodes only from each cut point to the next keyframe and copies the GOPs between, matched to the source's profile, level, pixel format, B-frame depth and colour tags. It checks the result against the source (frame grid, copied packets, decoded frames). A source it cannot match (HEVC, 10-bit, interlaced, VFR, open GOPs) is re-encoded in full, and `smart.reason` says why. `probe.py --keyframes-near T` shows where the keyframes are.
|
|
387
388
|
- **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
|
|
388
389
|
- **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
|
|
390
|
+
- **Alpha is kept only when asked, and a lost alpha is not verified.** The re-encoding tools write opaque H.264 by default. On a source whose alpha is used (ProRes 4444, QuickTime Animation, PNG, VP9 with alpha), `color.py`, `graphics.py` and `overlay.py` take `--keep-alpha` (ProRes 4444 by default, `--alpha-codec qtrle|png|vp9`, `--alpha-mode straight|premultiplied`) and read the alpha back; without it the result says `verified: false` with a failing `alpha` step, and `--drop-alpha` records that an opaque output is wanted. `background.py --transparent` makes the canvas to draw graphics on.
|
|
391
|
+
- **Broadcast legal levels are measured, not assumed.** `check.py final.mxf --levels ebu-r103` (or `bt709`) counts every luma and chroma sample outside the legal range and FAILs when one frame has more than 1 % out (`--levels-tolerance`); the JSON `levels` adds min/max Y/U/V per `--levels-window`. `color.py --correct --legalize ebu-r103` clamps after the 4:2:0 conversion and measures what the encode left: a lossy encoder can push a few samples around a clamped edge back out, so the result says how many.
|
|
389
392
|
- **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
|
|
390
393
|
- **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
|
|
391
394
|
- **Phrase-aware caption breaking (1.16).** `caption.py`/`graphics.py --wrap phrase` (the default) never breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or punctuation pair on a line, prefers Japanese sentence ends and particles over a mid-word break, and never ends a line on an article or preposition. All four are penalties over break positions that already fit, so no line is widened and the line count never changes; `--wrap measured` restores 1.15's width-only wrap. The text itself is never rewritten or shortened. Since 1.16.1 a Thai run and a katakana word are never broken inside (Thai writes no space inside a phrase and there is no dictionary: the break goes where you put a space or `|`), and a line wider than the safe width is reported as `overlong` with the fix named.
|
|
392
395
|
- **Caption size fitted to the cue (1.17, reachable from the templates since 1.17.1).** At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and `--max-lines 2` used to cut it into consecutive cues — half the sentence arriving late. `caption.py --fit-size` (default `auto`) walks the size down until every cue fits, *before* laying the cues out, with a legibility floor of 4.5 % of the frame height (`--min-size`, default 13 ASS units). An explicit `--size` or a `brand.json` size is a statement about the look and is never overridden — which in 1.17.0 also silenced the fitter on every `render.py --template` run, since a template fills the size from the platform table; 1.17.1 marks that size as the default it is (`"fit_size": "on"` in the filled project), so the type shrinks and no cue is split. `--fit-size off` restores that earlier behaviour byte for byte, and the caption text is still never rewritten to make it fit.
|
|
393
|
-
- **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid.
|
|
396
|
+
- **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid. A music bed is measured as it is delivered, WAV/FLAC/MP3 with no picture; on a synthetic click track at 90 and 120 BPM every beat is within 1 ms (WAV/FLAC) or 9 ms (MP3) of its click, `tests/test_analysis.py` holds it to 20 ms.
|
|
394
397
|
- **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Built-in lists since 2.19: English, Japanese, Korean, Chinese, Spanish, German, French, Portuguese, Italian, Dutch, Polish, Russian and Turkish; any other language takes `--filler-words FILE`. Whisper stays optional: `--transcribe` with no engine installed refuses and names the five installs.
|
|
395
398
|
- **Throughput (1.17).** `batch.py --jobs N` runs several files at once, capped at `min(N, cpu_count, 8)` and sharing one `--timeout` budget rather than one per item; the per-item table keeps its order. `render.py --cache DIR` reuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them.
|
|
396
399
|
- **Audiogram (1.16).** `waveform.py --image cover.png` (or `render.py --template audiogram`) puts the waveform over a still plate for an episode that has no picture, with `--platform` for the frame, `--title` and burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented.
|
package/SKILL.md
CHANGED
|
@@ -96,13 +96,14 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
96
96
|
| "cut from 1:20 to 2:05", "trim the first 10 s" | `cut.py input.mp4 --start 1:20 --end 2:05` |
|
|
97
97
|
| "keep only these parts" | `cut.py in.mp4 --segments 0-1:00,1:30-2:00` |
|
|
98
98
|
| "remove the middle" | `cut.py in.mp4 --remove 1:00-1:30` |
|
|
99
|
+
| "frame-exact but keep the quality" (H.264) | `cut.py in.mp4 --start 1:20.4 --end 2:05.1 --smart` (re-encodes only the partial GOPs; `smart.reason` if it fell back) |
|
|
99
100
|
| "make it exactly 60 seconds" | `fit.py in.mp4 --duration 60` (speed; `--method trim`) |
|
|
100
101
|
| "cut it and make it HEVC / AV1 / ProRes" | `cut.py in.mp4 --start 0:10 --end 0:40 --codec hevc` |
|
|
101
102
|
| "cut out the pauses", "jump cuts" | `silence.py in.mp4` |
|
|
102
103
|
| "cut the ums and uhs" | `silence.py in.mp4 --filler --words words.json` |
|
|
103
104
|
| "just the real pauses, not inside a sentence" | `silence.py in.mp4 --speech-aware` |
|
|
104
105
|
| "a 60 s highlight from this hour" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` (then `cut.py --segments` with picks.txt's lines joined by commas) |
|
|
105
|
-
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats
|
|
106
|
+
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats`; a WAV/MP3 music bed too) |
|
|
106
107
|
| "speed up here, slow-mo there" | `speedramp.py in.mp4 --segment 0-3:1.0 --segment 3-4:0.25` |
|
|
107
108
|
| "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
108
109
|
| "smooth slow motion" | `fit.py in.mp4 --duration 2x --smooth interpolate` |
|
|
@@ -155,9 +156,12 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
155
156
|
| "a small proxy file" | `proxy.py in.mp4 --width 640` |
|
|
156
157
|
| "is this OK to upload?" | `check.py final.mp4 --platform reels` |
|
|
157
158
|
| "how much quality was lost" | `check.py final.mp4 --compare master.mp4` |
|
|
159
|
+
| "is it broadcast legal?" | `check.py final.mp4 --levels ebu-r103` (clamp: `color.py in.mp4 --correct --legalize ebu-r103`) |
|
|
158
160
|
| "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 -o report.html` |
|
|
159
161
|
| "title card", "Ken Burns on a photo" | `insert.py title.png --duration 3` (`--zoom in --pan right`) |
|
|
160
162
|
| "a blank colour clip" | `background.py -o bg.mp4 --duration 3 --color 0x101010` |
|
|
163
|
+
| "grade it but keep the alpha" | `color.py logo.mov --correct --exposure 0.3 --keep-alpha -o graded.mov` |
|
|
164
|
+
| "lower third as a transparent layer" | `background.py --transparent -o canvas.mov --duration 5 --width 1920 --height 1080`, then `graphics.py canvas.mov --template lower-third --name "Ada" --keep-alpha -o lt.mov` |
|
|
161
165
|
| "numbered frames into a video" | `sequence.py --dir frames --pattern "frame_%04d.png" --fps 24` |
|
|
162
166
|
| "waveform video for this track" | `waveform.py ep.wav -o waveform.mp4` |
|
|
163
167
|
| "add black at the start" | `pad.py clip.mp4 --start 1.5` |
|
|
@@ -183,6 +187,7 @@ One line each; open the linked `references/gotchas.md` section when the job is i
|
|
|
183
187
|
|
|
184
188
|
- HDR re-encoded through an SDR path goes flat. -> [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
|
|
185
189
|
- Log footage is tagged SDR and looks grey. -> [#log-footage](references/gotchas.md#log-footage)
|
|
190
|
+
- Re-encodes drop alpha unless `--keep-alpha`. -> [#alpha-channels](references/gotchas.md#alpha-channels)
|
|
186
191
|
- A `-c copy` cut can start on a wrong frame. -> [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
|
|
187
192
|
- VFR phone/screen clips go CFR on re-encode. -> [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
|
|
188
193
|
- Sync `confidence` under 0.3 is suspect. -> [#sync-multicam-and-drift](references/gotchas.md#sync-multicam-and-drift)
|
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.2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.2.3`) | 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.2.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.2.3", "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"},
|
|
@@ -191,7 +191,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
191
191
|
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`, `join`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both; for `join`, `--copy --transition none` copies both, every other join re-encodes both. Several visual tools (`fit`, `overlay`, `graphics`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
192
192
|
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
|
|
193
193
|
| `video_required` | refuses an input without a video stream ("input has no video stream") |
|
|
194
|
-
| | `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
194
|
+
| | `scenes` has `audio_only: true` and `video_required: false` after 3.2.0: an audio-only file is one scene, measured by `--beats`, `--audio-peaks`, `--speech` and `--highlights` (result key `audio_only: true`); `--sheet` and `--shots` refuse it. `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
195
195
|
| `deterministic_inputs`, `idempotency_hint` | see Repeatability |
|
|
196
196
|
| `mcp` | the MCP tool name and its positional arguments |
|
|
197
197
|
|
|
@@ -232,8 +232,10 @@ the loudness pass or, when loudness is skipped, one volumedetect pass). `check -
|
|
|
232
232
|
from one decode pass, `dropouts` (2.17) from one audio-only pass, and `flashes` and `first frame` (2.23) from one
|
|
233
233
|
video-only pass and a one-frame decode; without it the row set is unchanged.
|
|
234
234
|
`--compare REF` adds `similarity` (SSIM/PSNR against a reference) and `--expect-identity SIZE:MTIME_NS`
|
|
235
|
-
adds `identity`, against the `output_identity` every writing tool reports (2.24).
|
|
236
|
-
|
|
235
|
+
adds `identity`, against the `output_identity` every writing tool reports (2.24). `--levels
|
|
236
|
+
bt709|ebu-r103` adds `legal levels` (the share of luma and chroma samples outside the legal range,
|
|
237
|
+
counted in every frame; FAIL when one frame passes `--levels-tolerance`, 1 %) and the `levels`
|
|
238
|
+
document. Thresholds: `docs/design-decisions.md` ("check.py --content", "broadcast legal levels").
|
|
237
239
|
|
|
238
240
|
### Dry run
|
|
239
241
|
|
|
@@ -423,11 +425,11 @@ A project may now carry `"template"` (the name it was filled from) and `"frame":
|
|
|
423
425
|
|
|
424
426
|
## Capabilities
|
|
425
427
|
|
|
426
|
-
Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
|
|
428
|
+
Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `decoder:<name>`, `filter:<name>`, `bsf:<name>`,
|
|
427
429
|
`external:whisper`, `external:parakeet`. `capabilities.required` is the union of every tool's required list;
|
|
428
430
|
`optional` the union of the conditional ones. With detection (the default)
|
|
429
431
|
`available`, `missing` and `missing_optional` are added from `doctor`, which reads
|
|
430
|
-
`ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper or Parakeet. Pass `--static`
|
|
432
|
+
`ffmpeg -encoders / -decoders / -filters / -bsfs` and looks for a local whisper or Parakeet. Pass `--static`
|
|
431
433
|
to omit detection. Nothing from the environment other than those lists, the
|
|
432
434
|
ffmpeg/ffprobe/python versions and the paths of the ffmpeg/ffprobe binaries the tools run
|
|
433
435
|
(`ffmpeg_path`, `ffprobe_path`; a font file's path under `fonts` is the other one) is printed; no
|
|
@@ -789,6 +791,17 @@ Keys added after 3.1.8, additive (delivery masters and versions, #417-#423):
|
|
|
789
791
|
| `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
792
|
| `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
793
|
| `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` |
|
|
794
|
+
| `levels`; row `legal levels` | `check.py --levels bt709\|ebu-r103` | `{standard, reference, bit_depth, legal {y, c}, window_seconds, frames, out_of_range_pct {y, u, v, chroma}, brng_pct, worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows [{start, end, ...the same}]}`: `legal` is the inclusive code range at the file's bit depth, the shares are percentages of samples (chroma: Cb and Cr together, at their own resolution), counted exactly per frame, and `brng_pct` is signalstats' BRNG (pixels with any component outside the fixed nominal range) |
|
|
795
|
+
| `legalize`; step `legal_levels` | `color.py --legalize bt709\|ebu-r103` (with `--to-sdr`, `--lut` or `--correct`) | `{standard, reference, legal, output}`: `output` is the `levels` document of the written file; the step fails (`verified: false`, status `completed`) when one frame keeps more than 1 % of its luma or chroma samples out of range after the encode, with a `legalize_residual` warning whenever any sample is out |
|
|
796
|
+
| `smart`; `mode` `smart`; `reencode_reason` `smart` / `smart_fallback`; steps `smart_frames`, `smart_packets`, `smart_decode`, `smart_params` | `cut.py --smart` | `{applied, parts [{segment, kind: reencode\|copy, start, end, frames}], reencoded_frames, copied_frames, matched, encoder, checks {frames, grid_worst_seconds, copied_packets_equal, frames_decoded_equal, time_base}, time_base}`: only the partial GOPs at the cut points are re-encoded (libx264 matched to the source's profile, level, pixel format, reorder depth, size, SAR and colour tags), the rest is the source's packets, and the output is checked against the source before it is moved into place. When it cannot be made or does not verify: `{applied: false, reason, reason_code}`, `mode: accurate`, the whole cut re-encoded |
|
|
797
|
+
| `keyframes` | `probe.py --keyframes-near T[,T...]` | per time `{at, window, keyframes, before, after}`: the video keyframes within `--keyframe-window` seconds, and the nearest of those at or before / at or after it (`null` when none is within the window; widen `--keyframe-window`). A failed keyframe read fails the run (kind `ffmpeg`) |
|
|
798
|
+
|
|
799
|
+
Keys added for the alpha path beyond `export.py` (additive; the `alpha` step on an alpha source without `--keep-alpha` is the one change to an existing run, see below):
|
|
800
|
+
|
|
801
|
+
| key | tool | what it holds |
|
|
802
|
+
|---|---|---|
|
|
803
|
+
| `alpha_codec`, `alpha_mode`; step `alpha` | `color.py --correct/--lut --keep-alpha`, `graphics.py --keep-alpha`, `overlay.py --keep-alpha`, `background.py --transparent` | `alpha_codec`: the alpha line written (`--alpha-codec prores4444` (default), `qtrle`, `png`, `vp9`; the output must be a container that holds it, `.mov`/`.mkv`, `.mov` for qtrle, `.webm`/`.mkv` for vp9). `alpha_mode`: `straight` or `premultiplied` (`--alpha-mode`). The line keeps the source's colour tags and converts no matrix, as `--codec prores` does; `--codec` and `--keep-alpha` together are refused, and `--hw` leaves the line on the CPU. `alpha` step: `written {codec, pix_fmt, alpha_plane}` read back, `frames {expected, measured}`. `color.py`: the alpha is split off before the grade and merged back, so the written alpha must equal the source's frame by frame within 2 of 255 (`comparison {ok, paired, worst_delta, worst_frame}`). `graphics.py`/`overlay.py` (`property: alpha_at_least_source`): drawn layers add coverage, so every written frame's alpha must be at or above the source's within 2 of 255, the stream must store an alpha plane, and the output must not have turned opaque in every frame the source was transparent in (`reason`: `alpha_below_source`, `no_alpha_plane`, `opaque_everywhere`). `background.py --transparent`: every frame's alpha read back as 0 (`provenance: specification`). `--keep-alpha` on a source with no alpha plane is refused (`kind: input`) |
|
|
804
|
+
| `alpha_dropped`; step `alpha` with `reason: alpha_dropped` | `color.py --correct/--lut/--to-sdr` (and `--retag` when its copy falls back to a re-encode), `graphics.py`, `overlay.py` without `--keep-alpha` | on a source whose alpha is used (a frame below opaque; measured, streamed frame by frame, only when the source stores an alpha plane or a palette, so an opaque source runs and reports exactly as before; a stored plane whose alpha cannot be read counts as used and a kept step on it fails with `reason: source_alpha_unmeasured`; a VP9 source or layer with alpha needs the libvpx-vp9 decoder, `error.reason: decoder_missing` without it): the output's opaque line drops it, so the `alpha` step fails (`attribution: artifact`, a `hint`, an `alpha_dropped` warning) and `verified` is false. `--drop-alpha` says an opaque output is wanted: no step, `alpha_dropped: true` and a note. An `overlay.py --video` layer with alpha keeps it: a VP9 one is decoded with libvpx-vp9, an RGB one (qtrle, PNG) is converted to BT.709 with its alpha plane |
|
|
792
805
|
|
|
793
806
|
Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
794
807
|
|
|
@@ -924,7 +937,7 @@ dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ff
|
|
|
924
937
|
tool used to print to stderr only (emoji fallbacks, silent or short inputs, memory-heavy jobs,
|
|
925
938
|
`--strip-dovi` without Dolby Vision, `render.py`'s frame-from-preset and `--normalize` defaults).
|
|
926
939
|
`render.py`'s plan, check and cache records (`check_dry_run`, `check_output_unparsed`, `cache_entry_damaged`, ...),
|
|
927
|
-
`export.py`'s `loudness_not_measured`, `loudness_outside_spec`, `colour_not_converted`, `size_over_limit` and `repair_incomplete`, `multicam.py`'s
|
|
940
|
+
`export.py`'s `loudness_not_measured`, `loudness_outside_spec`, `colour_not_converted`, `size_over_limit` and `repair_incomplete`, `color.py`'s `legalize_residual`, `multicam.py`'s
|
|
928
941
|
`camera_not_started`, `report.py`'s `report_section_missing` and `batch.py`'s cache records
|
|
929
942
|
(`batch_cache_reset`, `batch_cache_output_changed`, ...) are listed there too. Every one of them
|
|
930
943
|
still prints its stderr line; the record is additive.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
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/gotchas.md
CHANGED
|
@@ -11,6 +11,7 @@ The one-line form each section below starts from (SKILL.md keeps a shorter line
|
|
|
11
11
|
|
|
12
12
|
- HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR; `hdr: true` is a real PQ/HLG/DV signal, `bt2020_or_hdr: true` also BT.2020 SDR. -> [#hdr-and-colour](#hdr-and-colour)
|
|
13
13
|
- Log footage (S-Log/V-Log/C-Log) is tagged SDR and looks grey: `probe.py --analyze`, then `color.py --lut` first. -> [#log-footage](#log-footage)
|
|
14
|
+
- Re-encodes write opaque H.264: an alpha source needs `--keep-alpha` (color/graphics/overlay), or the `alpha` step fails; `background.py --transparent` is the canvas. -> [#alpha-channels](#alpha-channels)
|
|
14
15
|
- A `-c copy` cut can start on a wrong/frozen frame; `cut.py` re-encodes past a 0.5 s snap, respect it; an .mp4/.mov copy's edit list hides the pre-roll (`--no-edit-list` off). -> [#keyframe-cuts](#keyframe-cuts)
|
|
15
16
|
- VFR phone/screen clips: re-encodes go CFR; `cut.py` re-encodes measured VFR (`--vfr-guard average`: 2.x rule); odd rate: `fit.py --fps`. -> [#variable-frame-rate](#variable-frame-rate)
|
|
16
17
|
- Sync/multicam `confidence` under 0.3 (or a huge offset) is suspect — check every camera; these align audio, never lip sync. -> [#sync-multicam-and-drift](#sync-multicam-and-drift)
|
|
@@ -57,6 +58,20 @@ Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
|
|
|
57
58
|
colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
|
|
58
59
|
do not tag BT.709 on BT.2020 pixels.
|
|
59
60
|
|
|
61
|
+
### Alpha channels
|
|
62
|
+
ProRes 4444, QuickTime Animation, PNG and VP9 can carry an alpha channel; H.264, the default line
|
|
63
|
+
of every re-encoding tool, cannot. `color.py --correct/--lut`, `graphics.py` and `overlay.py` take
|
|
64
|
+
`--keep-alpha` (ProRes 4444 by default; `--alpha-codec qtrle|png|vp9`, each in a container that holds
|
|
65
|
+
it) and read the alpha back after the write. Without it, a source whose alpha is used comes out
|
|
66
|
+
opaque and the result says `verified: false` with a failing `alpha` step (`alpha_dropped`): pass
|
|
67
|
+
`--drop-alpha` only when an opaque output is what you want. For a per-element layer with
|
|
68
|
+
transparency, start from `background.py --transparent --duration 5 --width 1920 --height 1080 -o canvas.mov`
|
|
69
|
+
and draw one element on it
|
|
70
|
+
with `graphics.py`/`overlay.py --keep-alpha`. An alpha line keeps the source's colour tags (as
|
|
71
|
+
`--codec prores` does). `--alpha-mode premultiplied` multiplies the written colour by the alpha;
|
|
72
|
+
sources are taken as straight. A VP9 layer with alpha is decoded with libvpx-vp9; FFmpeg's own VP9
|
|
73
|
+
decoder drops the alpha.
|
|
74
|
+
|
|
60
75
|
### Log footage
|
|
61
76
|
S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
|
|
62
77
|
`probe.py --analyze`; `looks_like_log: true` means apply the manufacturer's
|
package/references/scripts.md
CHANGED
|
@@ -52,6 +52,8 @@ Every row names the constant or flag it comes from, and a test holds the table t
|
|
|
52
52
|
| `--content` silence: a silent run shorter than this is not counted | 0.5 s | `_common.probe.CONTENT_SILENCE_MIN` | `check.py --content` |
|
|
53
53
|
| `--content` dropout: a gap of sound at or below | -80 dBFS | `_common.probe.CONTENT_DROPOUT_DB` | `check.py --content` |
|
|
54
54
|
| ... between (s) and 1 s long, with sound on both sides | 0.02 s | `_common.probe.CONTENT_DROPOUT_MIN` | `check.py --content` |
|
|
55
|
+
| `--levels`: FAIL when one frame has more than this share of its luma (or of its chroma) samples out of the legal range | 1.0 % | `check.py --levels-tolerance` | `check.py --levels` |
|
|
56
|
+
| `--legalize`: the written file may keep this share of one frame's luma (or chroma) samples out of range (step `legal_levels`) | 1.0 % | `color.LEGALIZE_TOLERANCE_PCT` | `color.py --legalize` |
|
|
55
57
|
| a beat grid below this confidence is not snapped to | 0.5 | `_common.decision.BEAT_MIN_CONFIDENCE` | `cut.py --snap beats`, `scenes.py --beats` |
|
|
56
58
|
<!-- thresholds:end -->
|
|
57
59
|
|
|
@@ -117,8 +119,11 @@ holds the written length to the maximum with no tolerance, as `check.py` does.
|
|
|
117
119
|
|
|
118
120
|
### probe.py — inspect
|
|
119
121
|
```
|
|
120
|
-
probe.py INPUT... [--compact] [--field duration|video.fps|...]
|
|
122
|
+
probe.py INPUT... [--compact] [--field duration|video.fps|...] [--keyframes-near T[,T...] [--keyframe-window 5]]
|
|
121
123
|
```
|
|
124
|
+
`--keyframes-near` adds `keyframes`: for each time, the video keyframes within `--keyframe-window`
|
|
125
|
+
seconds, and the nearest of those at or before it (`before`) and at or after it (`after`; either is
|
|
126
|
+
`null` when none is within the window), in seconds from the file's start. These are where a stream copy can start, and where `cut.py --smart` stops re-encoding.
|
|
122
127
|
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,bt2020_or_hdr,hdr_format,start_time}`
|
|
123
128
|
(`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
|
|
124
129
|
`bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
|
|
@@ -127,7 +132,7 @@ own clock, `null` when unknown). `--compact` gives one line per file.
|
|
|
127
132
|
|
|
128
133
|
### cut.py — cut / join segments
|
|
129
134
|
```
|
|
130
|
-
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,... | --remove A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list | --no-edit-list] [--keep-hevc | --no-keep-hevc] [--vfr-guard sampled|average|off] [-o OUT]
|
|
135
|
+
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,... | --remove A-B,C-D,...] [--accurate | --smart] [--declick-ms 20 | --no-declick] [--edit-list | --no-edit-list] [--keep-hevc | --no-keep-hevc] [--vfr-guard sampled|average|off] [-o OUT]
|
|
131
136
|
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
|
|
132
137
|
```
|
|
133
138
|
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
@@ -168,8 +173,39 @@ in the length. Since 3.0.0 `keyframe_snapped` equals `start_snapped` (2.x: true
|
|
|
168
173
|
for every stream copy, `precision` `packet`); `start_snapped` is measured: true when a copied picture starts more
|
|
169
174
|
than a frame from `--start`, or when its start could not be measured (an unknown start is
|
|
170
175
|
never claimed exact); `null` under `--dry-run`.
|
|
176
|
+
`--smart` is the keyframe-aware cut for H.264. A cut point between keyframes is re-encoded only
|
|
177
|
+
from that point to the next keyframe (and, at the end, from the last keyframe to the end point);
|
|
178
|
+
the whole GOPs between are stream-copied. The re-encoded parts are encoded with libx264 matched to
|
|
179
|
+
the source stream: profile, level, pixel format, reorder depth (B-frames), frame size, SAR and
|
|
180
|
+
colour tags. Every part is written as MPEG-TS, so each keyframe carries its own SPS/PPS and the
|
|
181
|
+
decoder switches parameter sets at every join. The parts are joined with the concat demuxer, each
|
|
182
|
+
with its exact length stated, and the output keeps the source's time base. The sound is cut from
|
|
183
|
+
the source on the same frame grid and encoded once (AAC).
|
|
184
|
+
|
|
185
|
+
Before it is moved into place, the result is checked:
|
|
186
|
+
- the frame count, and one frame per frame duration (no gap, no repeat);
|
|
187
|
+
- every copied packet against the source's (MD5 with the parameter-set and delimiter NAL units
|
|
188
|
+
removed);
|
|
189
|
+
- framemd5 of every re-encoded part against the same frames decoded from the output, and of the
|
|
190
|
+
first 300 frames of each copied part against the source's;
|
|
191
|
+
- the output's stream parameters and time base against the source's.
|
|
192
|
+
|
|
193
|
+
These are reported in `smart.checks` and as `smart_*` verification steps, and `mode` is `smart`.
|
|
194
|
+
When the source cannot be matched, the result is `mode: accurate`, `reencode_reason` holds
|
|
195
|
+
`smart_fallback` and `smart: {applied: false, reason, reason_code}`. This happens for:
|
|
196
|
+
- a source that is not H.264 8-bit 4:2:0 in Baseline/Main/High;
|
|
197
|
+
- an interlaced, variable-rate or HDR source;
|
|
198
|
+
- an open GOP at a copy boundary;
|
|
199
|
+
- a picture that starts after its sound at a segment's start, or more than 32 segments with sound;
|
|
200
|
+
- an ffmpeg write that fails (x264 cannot make the matched High profile at `--quality 0`);
|
|
201
|
+
- an output other than `.mp4`/`.mov`/`.m4v`/`.mkv`;
|
|
202
|
+
- any check failing.
|
|
203
|
+
|
|
204
|
+
`--smart` with `--accurate` is refused. `probe.py --keyframes-near T` lists the keyframes around a
|
|
205
|
+
time.
|
|
171
206
|
`reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
|
|
172
|
-
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback
|
|
207
|
+
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback`,
|
|
208
|
+
`smart`, `smart_fallback`);
|
|
173
209
|
`--segments` adds `segment_precision`; `precision` and `least_exact_precision` are the least exact one
|
|
174
210
|
(since 3.0.0; 2.x's `precision` was the whole run's, `packet` only when nothing re-encoded).
|
|
175
211
|
A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
|
|
@@ -422,10 +458,18 @@ zoomed — it needs `--zoom` (panning uses the extra image area a zoom exposes).
|
|
|
422
458
|
```
|
|
423
459
|
background.py -o OUT --duration T --width W --height H
|
|
424
460
|
[--color C | --gradient C1:C2 [--angle DEG]]
|
|
461
|
+
background.py -o OUT.mov --duration T --width W --height H --transparent
|
|
462
|
+
[--alpha-codec prores4444|qtrle|png|vp9] [--alpha-mode straight|premultiplied]
|
|
425
463
|
```
|
|
426
464
|
No input file: ffmpeg's own `color`/`gradients` source filters generate the
|
|
427
465
|
clip directly. For a title-card background, a placeholder layer, or a base
|
|
428
466
|
for `overlay.py` to composite onto. `--width`/`--height` must be even.
|
|
467
|
+
`--transparent` writes a canvas with alpha 0 in every pixel in an alpha line:
|
|
468
|
+
ProRes 4444 with 16-bit alpha (default; `.mov`/`.mkv`), `qtrle` (`.mov`), `png`
|
|
469
|
+
(`.mov`/`.mkv`) or `vp9` (`.webm`/`.mkv`). It is the base layer
|
|
470
|
+
`graphics.py --keep-alpha` and `overlay.py --keep-alpha` draw one element on, for
|
|
471
|
+
a per-element layer with transparency. Every written frame's alpha is read back
|
|
472
|
+
(`alpha` step, expected 0); `--color`/`--gradient` do not apply.
|
|
429
473
|
|
|
430
474
|
### reverse.py — reverse playback
|
|
431
475
|
```
|
|
@@ -1072,7 +1116,7 @@ already has a picture.
|
|
|
1072
1116
|
### scenes.py — scene changes and highlight candidates
|
|
1073
1117
|
```
|
|
1074
1118
|
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
|
|
1075
|
-
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json]
|
|
1119
|
+
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json] # INPUT may be audio-only
|
|
1076
1120
|
scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
1077
1121
|
```
|
|
1078
1122
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
@@ -1106,6 +1150,23 @@ on the grid. With `--beats` the file is decoded once, at 22050 Hz, and the
|
|
|
1106
1150
|
`supported + unsupported == len(beats)` always, and `supported_beats` is that
|
|
1107
1151
|
supported subset — the list `cut.py --snap beats` moves onto.
|
|
1108
1152
|
|
|
1153
|
+
**Audio-only input and timing (after 3.2.0).** INPUT may be a WAV, FLAC, MP3 or any
|
|
1154
|
+
file with no video stream: no scene detection runs, the file is one scene from 0 to
|
|
1155
|
+
its duration, and the document carries `"audio_only": true`. `--beats`,
|
|
1156
|
+
`--audio-peaks`, `--speech` and `--highlights` measure the sound; `--sheet` and
|
|
1157
|
+
`--shots` need a picture and are refused (`kind: input`). Three things set how close
|
|
1158
|
+
the grid is to the music, each of which was off by tens of ms before:
|
|
1159
|
+
`beat_grid.step_s` is the envelope's real step (220 samples at 22050 Hz = 9.977 ms
|
|
1160
|
+
for `--beat-step 0.01`; timing it as 10 ms stretched every time by 0.23 %, 68 ms over
|
|
1161
|
+
30 s); the tempo and phase are refitted by weighted least squares to the strongest
|
|
1162
|
+
onset at each grid point, so a tempo between two envelope steps (90 BPM = 0.6667 s)
|
|
1163
|
+
is not rounded to one (0.67 s, 3.3 ms a beat); and each onset is placed on the
|
|
1164
|
+
decoded samples (`onset_refinement: "pcm-window-ratio"`) instead of at the start of
|
|
1165
|
+
its 10 ms block. A grid point within 10 ms before 0 s is the beat at `0.0`; one further before is not reported (it would make `0.0` a snap target the grid does not mark). Measured on
|
|
1166
|
+
synthetic click tracks at 90 and 120 BPM (first click at 0, 0.137 and 0.25 s): every
|
|
1167
|
+
beat within 1 ms on WAV/FLAC, 9 ms on MP3 (the encoder's pre-echo), 6 ms through AAC;
|
|
1168
|
+
the test holds every click to 20 ms.
|
|
1169
|
+
|
|
1109
1170
|
With `--beats` the file is decoded once, at 22050 Hz, and the 0.5 s scene
|
|
1110
1171
|
envelope is derived from that same pass rather than from a second 8 kHz decode.
|
|
1111
1172
|
One consequence worth knowing: `scenes.py X --json` and `scenes.py X --beats
|
|
@@ -1149,7 +1210,7 @@ measurements that combine with each other and with `--beats`/`--highlights`:
|
|
|
1149
1210
|
|
|
1150
1211
|
### check.py — pre-delivery compliance
|
|
1151
1212
|
```
|
|
1152
|
-
check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--compare REF] [--expect-identity SIZE:MTIME_NS] [--json]
|
|
1213
|
+
check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--compare REF] [--levels bt709|ebu-r103 [--levels-window S] [--levels-tolerance PCT]] [--expect-identity SIZE:MTIME_NS] [--json]
|
|
1153
1214
|
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
|
|
1154
1215
|
```
|
|
1155
1216
|
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
@@ -1191,6 +1252,19 @@ never FAIL, because a camera flash or a strobe is real) and a one-frame decode a
|
|
|
1191
1252
|
(WARN when it is one flat colour, luma spread < 4/255: the frame a feed's autoplay shows first).
|
|
1192
1253
|
A render project enables it with `"check": {"platform":
|
|
1193
1254
|
"reels", "content": true}`.
|
|
1255
|
+
`--levels bt709|ebu-r103` (opt-in, one more decode of every frame) adds a `legal levels` row: the
|
|
1256
|
+
share of luma samples and of chroma (Cb and Cr) samples outside the standard's legal range, counted
|
|
1257
|
+
exactly in every frame (each plane through `lutyuv` as an out-of-range mask, its mean read by
|
|
1258
|
+
`signalstats`; chroma at its own subsampled resolution), the worst frame and its time, and min/max
|
|
1259
|
+
Y/U/V (a decode that fails part-way is `could not measure`, never a share of the frames read). `bt709` is the nominal range, 16-235 luma and 16-240 chroma at 8 bits (64-940 / 64-960 at
|
|
1260
|
+
10); `ebu-r103` is EBU R 103's tolerance, luma -1 %..103 % (8-bit 14-241) and chroma as far as RGB
|
|
1261
|
+
-5 %..105 % reaches (8-bit 5-251). Limits scale with the depth of the planes (nv12 is 8-bit) and round inward. The row
|
|
1262
|
+
FAILs when one frame has more than `--levels-tolerance` (1 %) of its luma or chroma samples out; it
|
|
1263
|
+
WARNs, unmeasured, on an RGB or grey picture, and a full-range file is counted against the
|
|
1264
|
+
limited-range limits with a note. The JSON `levels` document holds the same per clip and per
|
|
1265
|
+
`--levels-window` seconds (10; 0 for the clip only), plus `brng_pct` (signalstats' BRNG: pixels
|
|
1266
|
+
with any component outside the fixed nominal range). It counts Y'CbCr codes; it does not convert
|
|
1267
|
+
to RGB, so an RGB gamut error inside legal Y'CbCr codes is not counted.
|
|
1194
1268
|
|
|
1195
1269
|
### batch.py — same recipe over a folder, cached
|
|
1196
1270
|
```
|
|
@@ -1315,7 +1389,17 @@ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|st
|
|
|
1315
1389
|
[--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX]
|
|
1316
1390
|
[--wrap phrase|measured] [--text-render auto|ass|drawtext] [--write-ass OUT.ass]
|
|
1317
1391
|
[--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
|
|
1392
|
+
[--keep-alpha [--alpha-codec prores4444|qtrle|png|vp9] [--alpha-mode straight|premultiplied] | --drop-alpha]
|
|
1318
1393
|
```
|
|
1394
|
+
`--keep-alpha` draws the template on the input's alpha (a canvas from `background.py --transparent`,
|
|
1395
|
+
or any alpha clip) and writes it in an alpha line (see `background.py`; ProRes 4444 by default) with
|
|
1396
|
+
the source's colour tags kept: boxes and bars are composited as layers with their alpha, drawtext and
|
|
1397
|
+
libass (`alpha=1`) draw into the alpha, all at 4:4:4. The `alpha` step checks that the written
|
|
1398
|
+
stream stores an alpha plane, that no frame's alpha fell below the source's, and that the output
|
|
1399
|
+
did not go opaque in every frame the source was transparent in. Without `--keep-alpha` on a source
|
|
1400
|
+
whose alpha is used, the output is opaque and the `alpha` step fails (`alpha_dropped`);
|
|
1401
|
+
`--drop-alpha` says an opaque output is wanted. `--codec` with `--keep-alpha` is refused.
|
|
1402
|
+
|
|
1319
1403
|
Since 1.16 a label too wide for the frame is broken into lines by the same phrase-aware wrap
|
|
1320
1404
|
caption.py uses (`--wrap phrase|measured`; see caption.py above) instead of running off the edge —
|
|
1321
1405
|
the hook card, the meme lines and the sticker chip. A label that already fits is untouched.
|
|
@@ -1708,7 +1792,16 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
|
|
|
1708
1792
|
| --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
|
|
1709
1793
|
[--place SRC_START-SRC_END@AT ...]
|
|
1710
1794
|
[--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
|
|
1711
|
-
|
|
1795
|
+
[--keep-alpha [--alpha-codec prores4444|qtrle|png|vp9] [--alpha-mode straight|premultiplied] | --drop-alpha]
|
|
1796
|
+
```
|
|
1797
|
+
`--keep-alpha` composites onto the input's alpha (a `background.py --transparent` canvas or an alpha
|
|
1798
|
+
clip) and writes the result in an alpha line with the source's tags kept, checked as
|
|
1799
|
+
`graphics.py --keep-alpha` is. A `--video` layer with alpha keeps it: VP9 with alpha (`export.py
|
|
1800
|
+
--preset vp9-alpha`) is decoded with libvpx-vp9 (FFmpeg's own VP9 decoder drops the alpha), and an
|
|
1801
|
+
RGB layer (qtrle, PNG in .mov, e.g. a `graphics.py --keep-alpha` element) is converted to BT.709 with
|
|
1802
|
+
its alpha plane. Before, both covered the picture as an opaque rectangle. Without
|
|
1803
|
+
`--keep-alpha` on an input whose alpha is used, the `alpha` step fails (`alpha_dropped`) unless
|
|
1804
|
+
`--drop-alpha` is given.
|
|
1712
1805
|
`--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
|
|
1713
1806
|
(`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
|
|
1714
1807
|
sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
|
|
@@ -1800,11 +1893,26 @@ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|clip|linear|gamma] [--p
|
|
|
1800
1893
|
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
1801
1894
|
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy (checked with ffprobe)
|
|
1802
1895
|
color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
|
|
1896
|
+
color.py INPUT --to-sdr|--lut F|--correct ... --legalize bt709|ebu-r103 # legal-range clamp after the 4:2:0 conversion, measured
|
|
1803
1897
|
color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
|
|
1804
1898
|
[--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
|
|
1805
1899
|
[--levels-in-black 0..255] [--levels-in-white 0..255] [--levels-out-black 0..255] [--levels-out-white 0..255]
|
|
1806
1900
|
[--curves color_negative|cross_process|darker|increase_contrast|lighter|linear_contrast|medium_contrast|negative|strong_contrast|vintage] [-o OUT]
|
|
1807
|
-
|
|
1901
|
+
color.py INPUT --correct ... | --lut grade.cube ... -o OUT.mov [--keep-alpha [--alpha-codec prores4444|qtrle|png|vp9] [--alpha-mode straight|premultiplied] | --drop-alpha]
|
|
1902
|
+
```
|
|
1903
|
+
`--keep-alpha` (with `--correct` or `--lut`) grades an alpha clip and keeps its alpha: the alpha is
|
|
1904
|
+
split off before the grade, never touched, and merged back in front of the alpha line (ProRes 4444
|
|
1905
|
+
by default, `--alpha-codec qtrle|png|vp9`); the grade runs at 4:4:4 and the source's colour tags are
|
|
1906
|
+
kept (no BT.709 conversion, as `--codec prores`). The `alpha` step compares the written alpha with
|
|
1907
|
+
the source's frame by frame on 64x36 copies, each within 2 of 255. A source without an alpha plane is
|
|
1908
|
+
refused. Without `--keep-alpha`, a grade of a source whose alpha is used writes opaque H.264 and the
|
|
1909
|
+
`alpha` step fails (`reason: alpha_dropped`, `verified: false`); `--drop-alpha` makes it a note
|
|
1910
|
+
instead. `--retag`/`--strip-dovi` are stream copies and keep the alpha as they keep every pixel;
|
|
1911
|
+
when `--retag`'s copy cannot carry the tags it falls back to an H.264 re-encode, which holds no
|
|
1912
|
+
alpha, and on a source whose alpha is used that run's `alpha` step fails (`alpha_dropped`).
|
|
1913
|
+
A source that stores an alpha plane whose alpha cannot be read fails the step too
|
|
1914
|
+
(`source_alpha_unmeasured`), and a VP9 source with alpha needs the libvpx-vp9 decoder
|
|
1915
|
+
(`error.reason: decoder_missing` without it).
|
|
1808
1916
|
`--correct` is typed primary colour correction, no filter string ever accepted:
|
|
1809
1917
|
`--exposure`/`--contrast`/`--saturation`/`--gamma` (`exposure`/`eq` filters),
|
|
1810
1918
|
`--temperature`/`--tint`/`--lift`/`--gain` (`colortemperature`/`colorbalance`
|
|
@@ -1817,6 +1925,18 @@ script's own safe subset of what `ffmpeg -h filter=<name>` documents before
|
|
|
1817
1925
|
ffmpeg runs. `--json`'s `measurements` reports `analyze_levels()` (signalstats
|
|
1818
1926
|
luma/saturation) for input and output side by side.
|
|
1819
1927
|
|
|
1928
|
+
`--legalize bt709|ebu-r103` (with `--to-sdr`, `--lut` or `--correct`; `--correct` with no
|
|
1929
|
+
correction values clamps only) is the last filter before the encoder: the picture is made 8-bit
|
|
1930
|
+
4:2:0 (a full-range source is scaled to limited range first) and every luma and chroma sample is clamped (`lutyuv` `clip()`) into the limits `check.py
|
|
1931
|
+
--levels` counts against. Clamping after the subsampling matters: a clamp ahead of it lets the
|
|
1932
|
+
chroma filter's overshoot put samples back out. The written file is then measured the same way
|
|
1933
|
+
(`legalize.output`), and the `legal_levels` verification step fails when one frame keeps more than
|
|
1934
|
+
1 % of its luma or chroma samples out: the encoder's error around a hard-clamped area can move a
|
|
1935
|
+
few decoded samples a code or more past the limit (a `legalize_residual` warning gives the
|
|
1936
|
+
shares; a lower `--quality` value shrinks them). Refused with `--retag`/`--strip-dovi` (stream
|
|
1937
|
+
copies) and with `--codec` other than h264 or `--keep-alpha` (its alpha line), which would convert the
|
|
1938
|
+
clamped picture again; `--drop-alpha` combines with it.
|
|
1939
|
+
|
|
1820
1940
|
iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
|
|
1821
1941
|
`probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
|
|
1822
1942
|
tone-maps it from the HLG base layer. When the user wants to keep HDR but
|
|
@@ -68,8 +68,8 @@ from _common.emit import (
|
|
|
68
68
|
write_plan
|
|
69
69
|
)
|
|
70
70
|
from _common.probe import (
|
|
71
|
-
analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
|
|
72
|
-
_fraction, keyframes_near, classify_frame_timing, measure_frame_timing, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
71
|
+
analyze_levels, LEGAL_RANGE_CHOICES, LEGAL_RANGES, legal_limits, measure_signal_range, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
|
|
72
|
+
_fraction, keyframe_times, keyframes_near, classify_frame_timing, measure_frame_timing, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
73
73
|
measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output,
|
|
74
74
|
display_size, pixel_aspect, square_pixels, square_pixels_note
|
|
75
75
|
)
|
|
@@ -180,7 +180,7 @@ sys.modules[__name__].__class__ = _Facade
|
|
|
180
180
|
__all__ = [
|
|
181
181
|
"Any", "Dict", "Fraction", "List", "Optional", "Path", "Sequence", "Tuple", "argparse", "json", "math", "os",
|
|
182
182
|
"platform", "re", "shutil", "subprocess", "sys", "unicodedata",
|
|
183
|
-
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
183
|
+
"aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "LEGAL_RANGE_CHOICES", "LEGAL_RANGES", "legal_limits", "measure_signal_range", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
|
|
184
184
|
"audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
|
|
185
185
|
"brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
|
|
186
186
|
"char_script", "_check_existing_output", "refuse_existing_outputs", "guard_sidecar", "_check_no_overwrite_input", "_check_output_path", "child_args",
|
|
@@ -204,7 +204,7 @@ __all__ = [
|
|
|
204
204
|
"font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
|
|
205
205
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
206
206
|
"is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
|
|
207
|
-
"keyframes_near", "classify_frame_timing", "measure_frame_timing", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
207
|
+
"keyframe_times", "keyframes_near", "classify_frame_timing", "measure_frame_timing", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
208
208
|
"load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS", "NON_BT709_MATRICES",
|
|
209
209
|
"_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
|
|
210
210
|
"place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
|