ffmpeg-skill 3.2.2 → 3.3.0

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 CHANGED
@@ -20,6 +20,7 @@ No cloud · No API keys · Python standard library</p>
20
20
 
21
21
  <p align="center">
22
22
  <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
23
+ <a href="https://scorecard.dev/viewer/?uri=github.com/kajisho5/ffmpeg-skill"><img src="https://api.scorecard.dev/projects/github.com/kajisho5/ffmpeg-skill/badge" alt="OpenSSF Scorecard"></a>
23
24
  <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
24
25
  <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-6.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 6.1, 7.1, 8 and 9 tested in CI"></a>
25
26
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
@@ -198,7 +199,7 @@ These are the rules the skill file gives the agent and the code enforces.
198
199
  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
200
  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
201
  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.
202
+ 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
203
  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
204
  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
205
  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 +221,7 @@ These are the rules the skill file gives the agent and the code enforces.
220
221
 
221
222
  | Tool | What it does |
222
223
  |---|---|
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 |
224
+ | `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
225
  | `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
226
  | `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
227
  | `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 +233,7 @@ These are the rules the skill file gives the agent and the code enforces.
232
233
  | `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
234
  | `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
235
  | `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 |
236
+ | `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
237
  | `reverse.py` | Reverse playback (video and, unless `--no-audio`, audio) |
237
238
  | `stabilize.py` | Two-pass motion stabilisation (`vidstabdetect`/`vidstabtransform`) |
238
239
  | `sequence.py` | Numbered (`frame_%04d.png`) or glob-matched still images into a video |
@@ -257,10 +258,10 @@ These are the rules the skill file gives the agent and the code enforces.
257
258
 
258
259
  | Tool | What it does |
259
260
  |---|---|
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 |
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) |
261
+ | `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 |
262
+ | `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 |
263
+ | `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 |
264
+ | `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
265
 
265
266
  **Delivery**
266
267
 
@@ -268,7 +269,7 @@ These are the rules the skill file gives the agent and the code enforces.
268
269
  |---|---|
269
270
  | `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
271
  | `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`) |
272
+ | `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
273
  | `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
273
274
 
274
275
  **Orchestration**
@@ -372,7 +373,7 @@ npx ffmpeg-skill doctor # human-readable
372
373
  npx ffmpeg-skill doctor --json # available / missing / missing_optional / unknown / detection / errors / tools / gpu_encoders / ffmpeg_security
373
374
  ```
374
375
 
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"`.
376
+ `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
377
 
377
378
  `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
379
 
@@ -384,8 +385,11 @@ The short list for humans. The agent-facing version, with the reasoning, is the
384
385
 
385
386
  - **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
387
  - **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).
388
+ - **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
389
  - **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
390
  - **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.
391
+ - **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.
392
+ - **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
393
  - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
390
394
  - **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
395
  - **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.
@@ -592,6 +596,16 @@ claude plugin marketplace add kajisho5/ffmpeg-skill # older v
592
596
  claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
593
597
  ```
594
598
 
599
+ The same repository installs as a plugin in two more hosts (checked against Codex CLI 0.167 and Gemini CLI 0.68):
600
+
601
+ ```bash
602
+ codex plugin marketplace add kajisho5/ffmpeg-skill # Codex: add the marketplace,
603
+ codex plugin add ffmpeg-skill@ffmpeg-skill # then the plugin
604
+ gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI
605
+ ```
606
+
607
+ Those hosts find a plugin's skill only at `skills/ffmpeg-skill/SKILL.md`, a generated copy of the root `SKILL.md` (`.github/scripts/build_plugin_skill.py`; a test fails when it is stale). A Cursor plugin manifest (`.cursor-plugin/plugin.json`) is in the repository for the Cursor Marketplace; it follows Cursor's reference and has not been through their review yet.
608
+
595
609
  The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
596
610
 
597
611
  Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
@@ -626,7 +640,7 @@ FFmpeg itself:
626
640
 
627
641
  ## Stability
628
642
 
629
- 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
643
+ 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. Releases are batched: related fixes and features ship together as one version rather than one version per merged pull request, so a dependent project sees fewer, larger steps. Pin a tagged version, not `main`. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
630
644
 
631
645
  ## Development
632
646
 
package/SKILL.md CHANGED
@@ -96,6 +96,7 @@ 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` |
@@ -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/bin/install.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * npx ffmpeg-skill --uninstall # remove from the selected targets
15
15
  * npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
16
16
  * npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
17
+ * npx ffmpeg-skill mcp # stdio MCP server (python3 mcp/server.py); registry clients pass `mcp`
17
18
  *
18
19
  * Already installed? re-run `npx ffmpeg-skill` to refresh ~/.claude/skills/ffmpeg-skill
19
20
  * Copies are not updated automatically.
@@ -98,7 +99,8 @@ function warnMissingPython(platform, spawn, warn) {
98
99
  }
99
100
 
100
101
  function main() {
101
- // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
102
+ // `contract` / `doctor` are answered by scripts/_contract.py. `mcp` is the stdio server
103
+ // (server.json's packageArguments). Everything else installs.
102
104
  if (args[0] === 'contract' || args[0] === 'doctor') {
103
105
  const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
104
106
  if (py.missing) {
@@ -107,6 +109,14 @@ if (args[0] === 'contract' || args[0] === 'doctor') {
107
109
  }
108
110
  process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
109
111
  }
112
+ if (args[0] === 'mcp') {
113
+ const py = runWithPython([path.join(ROOT, 'mcp', 'server.py'), ...args.slice(1)], process.platform, spawnSync);
114
+ if (py.missing) {
115
+ console.error(`error: could not find a Python 3.10+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.10+ and ensure it is on PATH.`);
116
+ process.exit(127);
117
+ }
118
+ process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
119
+ }
110
120
 
111
121
  const home = os.homedir();
112
122
  const targets = [];
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.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.3.0`) | 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.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.3.0", "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"},
@@ -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). Thresholds:
236
- `docs/design-decisions.md` ("check.py --content").
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
 
@@ -905,6 +918,19 @@ that is so: once a tool has written its deliverable and runs a further step on i
905
918
  says it is kept without the step that was stopped, and the document carries it as `output`
906
919
  (3.1.6, #415).
907
920
 
921
+ **Source carry-over.** Every tool that writes a media file compares what its first input carried
922
+ besides the picture and the sound with the file it wrote, and says so in `source_carry:
923
+ {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
924
+ `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
925
+ `timecode`. The key is present only when the source had at least one of them, so a result for a
926
+ plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
927
+ or more inputs have a picture or sound (which one's items the output should carry is the tool's
928
+ choice). An item bound to a stream the output does not have at all is not compared (a sound-only
929
+ extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
930
+ it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
931
+ `source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
932
+ `dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
933
+
908
934
  **Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
909
935
  skipped -- is recorded as it happens (a stderr line too) and lands in the document as
910
936
  `diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
@@ -924,7 +950,7 @@ dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ff
924
950
  tool used to print to stderr only (emoji fallbacks, silent or short inputs, memory-heavy jobs,
925
951
  `--strip-dovi` without Dolby Vision, `render.py`'s frame-from-preset and `--normalize` defaults).
926
952
  `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
953
+ `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
954
  `camera_not_started`, `report.py`'s `report_section_missing` and `batch.py`'s cache records
929
955
  (`batch_cache_reset`, `batch_cache_output_changed`, ...) are listed there too. Every one of them
930
956
  still prints its stderr line; the record is additive.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.2.2",
3
+ "version": "3.3.0",
4
+ "mcpName": "io.github.kajisho5/ffmpeg-skill",
4
5
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
6
  "keywords": [
6
7
  "ffmpeg",
@@ -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)
@@ -53,10 +54,31 @@ on a stream copy as on a re-encode
53
54
  (`cut.py` reports it: `dropped_audio_streams`, an `audio_tracks_dropped` warning). Keep ProRes masters
54
55
  at source colour: `export.py --preset prores` does not retag.
55
56
 
57
+ What else the source carried is compared on every write: `source_carry: {kept, dropped}` lists
58
+ subtitles, chapters, a cover picture, surround channels, `creation_time`, the audio language tag
59
+ and the start timecode, and a lost subtitle track, chapter list, cover picture or surround layout
60
+ is a `source_items_dropped` warning. Read it before telling the user the file is complete; to
61
+ carry the items over, use the tool that keeps them (`export.py` keeps subtitle tracks,
62
+ `metadata.py` writes chapters and tags).
63
+
56
64
  Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
57
65
  colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
58
66
  do not tag BT.709 on BT.2020 pixels.
59
67
 
68
+ ### Alpha channels
69
+ ProRes 4444, QuickTime Animation, PNG and VP9 can carry an alpha channel; H.264, the default line
70
+ of every re-encoding tool, cannot. `color.py --correct/--lut`, `graphics.py` and `overlay.py` take
71
+ `--keep-alpha` (ProRes 4444 by default; `--alpha-codec qtrle|png|vp9`, each in a container that holds
72
+ it) and read the alpha back after the write. Without it, a source whose alpha is used comes out
73
+ opaque and the result says `verified: false` with a failing `alpha` step (`alpha_dropped`): pass
74
+ `--drop-alpha` only when an opaque output is what you want. For a per-element layer with
75
+ transparency, start from `background.py --transparent --duration 5 --width 1920 --height 1080 -o canvas.mov`
76
+ and draw one element on it
77
+ with `graphics.py`/`overlay.py --keep-alpha`. An alpha line keeps the source's colour tags (as
78
+ `--codec prores` does). `--alpha-mode premultiplied` multiplies the written colour by the alpha;
79
+ sources are taken as straight. A VP9 layer with alpha is decoded with libvpx-vp9; FFmpeg's own VP9
80
+ decoder drops the alpha.
81
+
60
82
  ### Log footage
61
83
  S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
62
84
  `probe.py --analyze`; `looks_like_log: true` means apply the manufacturer's
@@ -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
  ```
@@ -1166,7 +1210,7 @@ measurements that combine with each other and with `--beats`/`--highlights`:
1166
1210
 
1167
1211
  ### check.py — pre-delivery compliance
1168
1212
  ```
1169
- 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]
1170
1214
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
1171
1215
  ```
1172
1216
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -1208,6 +1252,19 @@ never FAIL, because a camera flash or a strobe is real) and a one-frame decode a
1208
1252
  (WARN when it is one flat colour, luma spread < 4/255: the frame a feed's autoplay shows first).
1209
1253
  A render project enables it with `"check": {"platform":
1210
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.
1211
1268
 
1212
1269
  ### batch.py — same recipe over a folder, cached
1213
1270
  ```
@@ -1332,7 +1389,17 @@ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|st
1332
1389
  [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX]
1333
1390
  [--wrap phrase|measured] [--text-render auto|ass|drawtext] [--write-ass OUT.ass]
1334
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]
1335
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
+
1336
1403
  Since 1.16 a label too wide for the frame is broken into lines by the same phrase-aware wrap
1337
1404
  caption.py uses (`--wrap phrase|measured`; see caption.py above) instead of running off the edge —
1338
1405
  the hook card, the meme lines and the sticker chip. A label that already fits is untouched.
@@ -1725,7 +1792,16 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
1725
1792
  | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
1726
1793
  [--place SRC_START-SRC_END@AT ...]
1727
1794
  [--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
1728
- ```
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.
1729
1805
  `--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
1730
1806
  (`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
1731
1807
  sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
@@ -1817,11 +1893,26 @@ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|clip|linear|gamma] [--p
1817
1893
  color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
1818
1894
  color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy (checked with ffprobe)
1819
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
1820
1897
  color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
1821
1898
  [--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
1822
1899
  [--levels-in-black 0..255] [--levels-in-white 0..255] [--levels-out-black 0..255] [--levels-out-white 0..255]
1823
1900
  [--curves color_negative|cross_process|darker|increase_contrast|lighter|linear_contrast|medium_contrast|negative|strong_contrast|vintage] [-o OUT]
1824
- ```
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).
1825
1916
  `--correct` is typed primary colour correction, no filter string ever accepted:
1826
1917
  `--exposure`/`--contrast`/`--saturation`/`--gamma` (`exposure`/`eq` filters),
1827
1918
  `--temperature`/`--tint`/`--lift`/`--gain` (`colortemperature`/`colorbalance`
@@ -1834,6 +1925,18 @@ script's own safe subset of what `ffmpeg -h filter=<name>` documents before
1834
1925
  ffmpeg runs. `--json`'s `measurements` reports `analyze_levels()` (signalstats
1835
1926
  luma/saturation) for input and output side by side.
1836
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
+
1837
1940
  iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
1838
1941
  `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
1839
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",