ffmpeg-skill 1.15.1 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -213,13 +213,13 @@ These are the rules the skill file gives the agent and the code enforces.
213
213
  | `reverse.py` | Reverse playback (video and, unless `--no-audio`, audio) |
214
214
  | `stabilize.py` | Two-pass motion stabilisation (`vidstabdetect`/`vidstabtransform`) |
215
215
  | `sequence.py` | Numbered (`frame_%04d.png`) or glob-matched still images into a video |
216
- | `waveform.py` | Render an audio track as a waveform or spectrum visualization video (`showwaves`/`showspectrum`) — for audio-only inputs with no picture worth showing |
216
+ | `waveform.py` | Render an audio track as a waveform or spectrum visualization video (`showwaves`/`showspectrum`) — for audio-only inputs with no picture worth showing; `--image` puts the visualisation over a still plate (an audiogram), with `--platform`, `--title` and burnt-in captions |
217
217
  | `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
218
218
  | `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
219
219
  | `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
220
220
  | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
221
221
  | `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
222
- | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit |
222
+ | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit; `--auto-chapters` proposes the markers from measured pauses and scene cuts and names them `Chapter N` for you to rename |
223
223
  | `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
224
224
 
225
225
  **Audio**
@@ -234,7 +234,7 @@ These are the rules the skill file gives the agent and the code enforces.
234
234
 
235
235
  | Tool | What it does |
236
236
  |---|---|
237
- | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area by measured width with `--max-lines`/`--min-duration`/`--offset`; 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; optional local transcription |
237
+ | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area 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; optional local transcription |
238
238
  | `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 |
239
239
  | `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 |
240
240
  | `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) |
@@ -361,6 +361,8 @@ The short list for humans. The agent-facing version, with the reasoning, is the
361
361
  - **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.
362
362
  - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
363
363
  - **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.
364
+ - **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.
365
+ - **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.
364
366
  - **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji/Noto naming, `1f389.png`) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour — `doctor --json`'s `fonts.emoji` answers that from a render probe. Without assets the run still succeeds and reports `mode: mono`. Nothing is ever downloaded.
365
367
  - **Indic and Thai text shaped correctly in titles and lower-thirds (1.15).** `graphics.py` renders Devanagari, Bengali, Tamil, Thai and Lao through libass automatically (`text_renderer: "ass"`), because drawtext never reorders matras or re-clusters marks; Arabic and Hebrew were already correct on a fribidi build. `--text-render drawtext` with such a script is refused, never rendered wrongly.
366
368
  - **Non-Latin text picks a font by script (1.12).** Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (`kind: input`) instead of rendering boxes. `doctor --json`'s `fonts.scripts` says which languages this machine can render; `--lang ja|ko` disambiguates Han-only text; an explicit `--font`/`--font-file` is always kept.
@@ -405,6 +407,7 @@ type on every OS.
405
407
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
406
408
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
407
409
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
410
+ | **90 / 90** | 1.16.0 run (2026-09-14, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 30 runs, chapters and subtitle streams re-probed, check.py re-run on every delivery output) on the set grown to 90 prompts (audiogram, auto chapters, multi-language tracks, caption breaking): routing 90/90, honest refusals and failures 90/90 with 0 false successes and 0 raw ffmpeg calls, report format 89/90 (one `Done (partially):`), user's language 90/90 by regex (89/90 by Opus), audiogram 2/2 with the cover behind the waveform and nothing fetched, auto chapters 2/2 with `Chapter N` titles only, delivery 16/16 platform pass, trigger set 45/45, Opus quality mean 4.17. The honest part: the phrase breaker never gets to act at the platform caption sizes (a five-word cue does not fit two lines at TikTok size, so the split is byte-identical to 1.15.1), Thai still breaks inside words, and a katakana word was split — 1.16.1 is the patch and the finding is written up in `evals/results/iteration-17.json` |
408
411
  | **82 / 82** | 1.15.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 28 runs, stills extracted inside the emoji window, check.py re-run on every delivery output) on the set grown to 82 prompts (emoji captions and title cards, a Hindi and a Thai lower-third): routing 82/82, honest refusals and failures 82/82 with 0 false successes and 0 raw ffmpeg calls, report format 82/82 (both iteration-15 label defects closed: `dl8` and `he2` now carry one `Failed:`), user's language 82/82 by regex (81/82 by Opus: one Spanish report with three English labels), non-Latin glyphs 11/11 (Devanagari through `graphics.py` is fixed; Thai lower-third and captions correct), emoji visible in colour in 3/3 runs given PNG assets and reported monochrome in the one that was not, visual check 23/24, delivery 12/13 one encode and 13/13 platform pass, trigger set 40/40, Opus quality mean 4.68. Still open: the caption breaker splits phrases (`dl1`, `dl4` unchanged) — queued for 1.16.0. Tokens per run flat at 73.3k on the same 76. Details in `evals/results/iteration-16.json` |
409
412
  | **76 / 76** | 1.14.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 26 runs, check.py re-run on every delivery output): routing 76/76, honest refusals and failures 76/76 with 0 false successes and 0 raw ffmpeg calls, report format 76/76, user's language 76/76 by regex (75/76 by Opus: one Spanish report with three English labels), visual check 18/18, trigger set 38/38, Opus quality mean 4.58. The delivery templates did their job: 12 of 13 delivery requests went through `render.py --template`, finished in one encode (was 3 of 7) and all 13 pass their platform check (was 7 of 8). Tokens per run flat at 73.4k. Details in `evals/results/iteration-15.json` |
410
413
  | **76 / 76** | 1.13.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader) on the set grown to 76 prompts: 18 in Thai, Hindi, Hebrew, Russian, Greek, Vietnamese, Indonesian, Turkish and Italian, and 8 delivery requests (TikTok, Reels, Shorts, LinkedIn, Douyin, podcast): routing 76/76, honest refusals and failures 76/76 with 0 false successes and 0 raw ffmpeg calls, report format 76/76, user's language 76/76 across seventeen languages, visual check 18/18, trigger set 38/38, Opus quality mean 4.65 over the 26 new runs. One real defect found: Hindi through `graphics.py` (drawtext) comes out wrong-shaped even though the font covers Devanagari; captions through libass are fine (queued for 1.15.0). Four delivery runs spent a second encode for loudness, which 1.14.0's templates address. Tokens per run flat at 72.3k. Details in `evals/results/iteration-14.json` |
package/SKILL.md CHANGED
@@ -7,13 +7,13 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
7
7
 
8
8
  Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request and `--help` on the one script you are about to run is the cheapest full flag list. The reference files cost as much to read as this file does, so open one only when it answers a question you actually have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end).
9
9
 
10
- Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (that document trimmed to status/output/verified, a compact `summary` and the command count — prefer it on every writing step); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (consent to replace an existing output — warned today, refused from 2.0); `--plan FILE` (the dry run as a plan document `render.py FILE` executes later, refusing if an input changed). Every re-encoding tool also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale, replaces the deprecated `--crf`): unset, SDR is x264 and HDR is x265 Main10; `prores` needs an explicit `-o NAME.mov`, `h264` refuses an HDR source (`color.py --to-sdr` first).
10
+ Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (the same trimmed to status/output/verified plus a `summary` — prefer it on writing steps); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (the dry run as a plan `render.py FILE` runs later, refusing if an input changed). Every re-encoding tool also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale, replaces the deprecated `--crf`): unset, SDR is x264 and HDR is x265 Main10; `prores` needs an explicit `-o NAME.mov`, `h264` refuses an HDR source (`color.py --to-sdr` first).
11
11
 
12
- Writing tools run nothing under `--dry-run`; `probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness` and `stabilize` may still run ffmpeg/ffprobe to measure — they just don't write their artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` ignores the flag. Per-tool: `contract --json`'s `dry_run` field.
12
+ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe — they just don't write their artifact, nor side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per-tool: `contract --json`'s `dry_run` field.
13
13
 
14
14
  ## Workflow (always follow this order)
15
15
 
16
- 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails on its own with `kind: missing_tool` or an ffmpeg error naming the filter/encoder (`No such filter: 'subtitles'`). Run `python3 <skill-dir>/scripts/_contract.py doctor` (also `npx ffmpeg-skill doctor`; there is no doctor.py) after such a failure, or when the user asks what the machine can do: read `ok` and the tool's `usable`, and report the missing capability (usually `libass`, `zscale` or an encoder) rather than rediscovering it at runtime. `contract --json`'s tool schema is for a *planning* agent choosing a tool from an abstract goal, not for this workflow.
16
+ 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails on its own with `kind: missing_tool` or an ffmpeg error naming the filter/encoder (`No such filter: 'subtitles'`). Run `python3 <skill-dir>/scripts/_contract.py doctor` (also `npx ffmpeg-skill doctor`) after such a failure, or when the user asks what the machine can do: read `ok` and the tool's `usable`, and report the missing capability (usually `libass`, `zscale` or an encoder) rather than rediscovering it at runtime. `contract --json`'s tool schema is for a *planning* agent choosing a tool from an abstract goal, not for this workflow.
17
17
  1. **Probe what you must plan from.** Run `probe.py` on each input you plan the edit from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
18
18
  2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
19
19
  3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (dimensions there can be a placeholder — `docs/contract.md`). Use it before long encodes and to report exact facts. `--fast` is preview quality (x264 veryfast), `--progress` prints percent/ETA on stderr. Never point `-o` at a file you did not create in this job unless the user asked for it to be replaced; pass `--overwrite` only then.
@@ -57,7 +57,7 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
57
57
 
58
58
  This table and `doctor --json`'s `tools` list are the source of truth for what exists: name only a script you have seen in one of them (there is no `doctor.py`, no `trim.py`, no `subtitle.py`).
59
59
 
60
- Timestamp flags `--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff`, with `@fps` naming the rate (`00:01:02:15@29.97`); flags that are a length rather than a point in time (`--min-silence`, `--margin`, `--min-keep`, `--fade`) are plain seconds. Use the timecode forms when the user pastes an NLE cue sheet, so nothing is converted by hand.
60
+ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files) take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or SMPTE `hh:mm:ss:ff` with `@fps` (`00:01:02:15@29.97`) paste an NLE cue sheet as it is; length flags (`--min-silence`, `--margin`, `--fade`) are plain seconds.
61
61
 
62
62
  | User says | Do |
63
63
  |-----------|----|
@@ -87,16 +87,17 @@ Timestamp flags — `--start`, `--end`, `--at`, `--from`, `--duration`, `--offse
87
87
  | "speed up here, slow-mo there" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
88
88
  | "loop this clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
89
89
  | "cut to the product shot 0:12-0:16", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at`; `--audio b\|mix`) |
90
- | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; a `render.py` project spells it `"chapters"`) |
90
+ | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; a `render.py` project spells it `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts, titled `Chapter N` for you to rename |
91
91
  | "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
92
92
  | "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
93
93
  | "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
94
94
  | "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
95
- | "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux` |
95
+ | "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux`; repeat `--srt file:lang` for several languages, `.mkv` for more than two |
96
96
  | "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
97
97
  | "a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
98
98
  | "webcam clip in the corner", "picture-in-picture" | `overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480` |
99
99
  | "remove the green screen" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
100
+ | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; give an image or a colour, nothing is fetched |
100
101
  | "sync the lav mic", "line up two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
101
102
  | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` podcast, `-I -23` broadcast; `--lra N` for the range) |
102
103
  | "cut this and make it HEVC / AV1 / ProRes" (output codec named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec`/`--quality` on any re-encoding tool; ProRes needs `-o NAME.mov`) |
@@ -149,7 +150,7 @@ Audio is a first-class input: `probe.py`, `cut.py`, `silence.py`, `loudness.py`,
149
150
 
150
151
  ## Report format
151
152
 
152
- Reply in the language the request itself is written in — the user's own sentences, not a language the request talks about (a request for subtitles in another language is still answered in the language it was written in) and not the language of a tool's error text or file names. Keep the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English: they read like log fields across languages. Everything around them — the sentences, any question, any explanation of a judgement call — is in the user's language. Never default to English because the tool names are English, and never drift because the job was short or the report is a failure: a one-line "file does not exist" is in the request's language too. A mid-conversation switch follows the user's latest message. English `Done:`/`Steps:` sentences with one word of the user's language in `Notes:` is an English report — the descriptions are in the user's language even when the values are technical.
153
+ Reply in the language the request itself is written in — the user's own sentences, not a language the request talks about (a request for subtitles in another language is still answered in the language it was written in). Keep the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English: they read like log fields across languages. The *lines* they head are not: `Done:`, `Steps:` and `Check:` carry the user's language just as `Look:` and `Notes:` do — a Spanish prompt answered with English `Done:`/`Steps:` sentences is an English report. Everything around them — the sentences, any question, any explanation of a judgement call — is in the user's language. Never default to English because the tool names are English, and never drift because the job was short or the report is a failure: a one-line "file does not exist" is in the request's language too. A mid-conversation switch follows the user's latest message.
153
154
 
154
155
  Finish every job with this shape (numbers from `--json` or `probe.py`/`check.py`, not memory):
155
156
 
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 (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.15.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.16.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.15.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.15.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.15.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.15.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.15.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.16.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.16.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.16.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.16.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.16.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.15.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.15.1", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.16.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.16.1", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -128,7 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
128
128
  | `output_schema` | what `--json` prints on stdout |
129
129
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
130
130
  | `supports_json` | whether `--json` exists |
131
- | `supports_json_brief` | whether `--json-brief` exists (1.15.1): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.16.1): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
132
132
  | `mutates_input` | always `false`: no tool overwrites its input |
133
133
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
134
134
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -423,6 +423,21 @@ including `input_lra`) and `targets` (the requested lufs / tp / lra).
423
423
  `check.py --platform podcast` adds two informational rows to `checks`,
424
424
  `channels` and `chapters`.
425
425
 
426
+ Per-tool keys added in 1.16, all additive (no 1.15 key is removed, renamed or
427
+ given a different type):
428
+
429
+ | key | tool | what it holds |
430
+ |---|---|---|
431
+ | `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not |
432
+ | `tracks`, `subtitle_tracks` | `caption.py --mode mux` | one entry per subtitle stream in the output — `{index, file, language, title, codec, default, cues, kept_from_input}`; a stream the input already carried has `file: null` and `kept_from_input: true`. `subtitle_tracks` is the total. Every field describes the file as written, not as asked for: an MPEG-4 output reports `title: null` (the muxer stores none) and `default: true` on its first track (the muxer always enables it), each with a `notes` line |
433
+ | `auto_chapters` | `metadata.py --auto-chapters` | `{source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}`. `titles` is always `"placeholder"`: the machine-readable form of "the skill did not name these". Each chapter carries its `evidence` (`start`, `silence`, `scene`, or `silence+scene` with the span, its length and the cut time) |
434
+ | `audiogram` | `waveform.py` (every run) | `{style, background, image, position, vis_height, platform, captions, title, stages, verified}`. `background` is `"image"` or `"color"`; `verified` is true when the render probes at the asked-for frame size, frame rate and within 0.05 s of the source audio, and is `false` under `--dry-run`, where nothing was rendered to verify |
435
+
436
+ `check.py` also gains an informational `subtitles` row on **every** platform:
437
+ `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
438
+ does not (or when there are none). Like `channels` and `chapters` it is never
439
+ counted in `failed` — no platform refuses a delivery over it.
440
+
426
441
  The MCP `audio` tool publishes `voice` as `{"type": "string", "enum": ["light",
427
442
  "medium", "strong"]}`. A client that still sends the 1.12 boolean `{"voice":
428
443
  true}` keeps working: `true` emits the bare `--voice`, which is `medium` — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.15.1",
3
+ "version": "1.16.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -256,7 +256,11 @@ frame is refused here, not discovered from an opaque ffmpeg error).
256
256
  ### waveform.py — audio waveform/spectrum visualization video
257
257
  ```
258
258
  waveform.py INPUT [--style waveform|spectrum] [--width W] [--height H] [--fps N]
259
- [--color C] [--background C] [--waveform-mode M] [--split-channels] [-o OUT]
259
+ [--color C] [--background C] [--waveform-mode M] [--split-channels]
260
+ [--image PATH] [--image-fit cover|contain|blur]
261
+ [--position bottom|centre|top|strip] [--vis-height FRAC] [--opacity 0..1]
262
+ [--platform NAME] [--srt FILE | --text FILE] [--title TEXT] [--brand brand.json]
263
+ [-o OUT]
260
264
  ```
261
265
  Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
262
266
  `showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
@@ -265,6 +269,29 @@ mixes at the cost of being less immediately readable. The output always
265
269
  carries the audio it visualizes. For an audio-only input (no video stream
266
270
  needed) or any file with an audio track worth visualizing.
267
271
 
272
+ **Audiogram (1.16).** `--image PATH` puts a local still behind the
273
+ visualisation, which is what turns a podcast episode into something postable.
274
+ `--image-fit cover` (default) scales to cover and centre-crops; `contain` pads
275
+ with `--background`; `blur` uses fit.py's blurred-pad plate. `--position`
276
+ places the band (`strip`, the default, is a band of `--vis-height` -- a
277
+ fraction of the frame, default 0.35 -- along the bottom, the podcast
278
+ convention); `--opacity` fades the visualisation over the plate. `--platform`
279
+ takes the frame size and fps from the delivery table and refuses a destination
280
+ with no frame (`podcast`) -- the still is fed at that rate, so the plate cannot
281
+ quietly decide the output's frame rate, and the run verifies the rate it
282
+ announced along with the frame size and the duration. `--image` must be a file
283
+ ffmpeg can actually decode: one that is not is refused (`kind: input`) before
284
+ any encode starts. `--title` draws one label through graphics.py's
285
+ sticker template and `--srt`/`--text` burns captions by running caption.py
286
+ afterwards -- both as second processes, so neither of those code paths is
287
+ re-implemented here. `--image` must be a readable local file: a URL is refused
288
+ (`kind: input`), nothing is fetched, and the skill never invents cover art --
289
+ give an image or a colour. Without any of these flags the command line is
290
+ byte-identical to 1.15's. Every run's result carries an `audiogram` object (style,
291
+ background, image, position, vis_height, platform, captions, title, stages,
292
+ verified). `render.py --template audiogram` is the one-call form; it is
293
+ deliberately not part of `--template all`.
294
+
268
295
  ### freeze.py — hold a frame for N seconds
269
296
  ```
270
297
  freeze.py INPUT --hold T [--at T] [--mode insert|extend] [-o OUT]
@@ -338,7 +365,10 @@ output; the result says so with `dropped_non_av_streams: true`.
338
365
 
339
366
  ### metadata.py — chapter markers and container tags, streams copied
340
367
  ```
341
- metadata.py INPUT [--chapters chapters.txt | --clear-chapters]
368
+ metadata.py INPUT [--chapters chapters.txt | --clear-chapters | --auto-chapters]
369
+ [--min-chapter S] [--max-chapters N] [--from silence|scenes|both]
370
+ [--silence-threshold dB] [--silence-min S] [--scene-threshold N]
371
+ [--chapters-out FILE] [--description-out FILE]
342
372
  [--title T] [--artist A] [--album A] [--comment C] [--date D] [--genre G] [-o OUT]
343
373
  ```
344
374
  `chapters.txt` holds one chapter per line, `TIME TITLE` (cut.py's time syntax:
@@ -352,6 +382,27 @@ silently dropping them. Tags alone are written to any container that has them.
352
382
  `--clear-chapters` removes existing markers; an empty tag value (`--comment ""`)
353
383
  clears that tag.
354
384
 
385
+ **Proposed chapters (1.16).** `--auto-chapters` measures the file's own
386
+ structure instead of reading a file: silencedetect for the pauses (`--from
387
+ silence`), scdet for the scene cuts (`--from scenes`), both by default. A
388
+ chapter starts where speech resumes; a scene cut within 1 s of one is the same
389
+ event and merges to `silence+scene` evidence, which `--max-chapters` never
390
+ drops before a single-evidence marker. `--min-chapter` (default 60, a
391
+ long-form default) is the shortest chapter, and nothing is proposed within it
392
+ of the end of the file. **Two detectors mean two full decodes of the input**
393
+ (`notes` says so); `--from silence` is the cheap path. Every title is
394
+ `Chapter N` and the result says `"titles": "placeholder"`: the skill proposes
395
+ where a chapter starts, it cannot know what is in one -- naming them is the
396
+ caller's job, and asking this skill to do it is a refusal.
397
+ `--chapters-out FILE` writes the proposal in this tool's own `--chapters`
398
+ format, so the titles can be edited and fed straight back;
399
+ `--description-out FILE` writes the YouTube block (`00:00 Chapter 1` per
400
+ line, rounded down to the second). The output is still `-c copy`. An input
401
+ with no video degrades to `--from silence` with a note; an explicit `--from
402
+ scenes` on it is refused, as is `--from silence` on an input with no audio.
403
+ The result gains `auto_chapters` (source, min_chapter, proposed, kept,
404
+ titles, chapters with their evidence, description_block, files).
405
+
355
406
  ### grid.py — composite clips into a grid
356
407
  ```
357
408
  grid.py CLIP1 CLIP2 [...] --cols N --rows N [--cell-width W] [--cell-height H]
@@ -440,13 +491,13 @@ complete command plan for review.
440
491
  ### Delivery templates — one command per destination (1.14)
441
492
  ```
442
493
  render.py --template tiktok INPUT [--cues cues.txt | --srt subs.srt] [--logo logo.png] [--title "..."]
443
- [--brand brand.json] [--chapters chapters.txt] [--fit crop|pad|blur] [-o OUT]
494
+ [--brand brand.json] [--chapters chapters.txt] [--image cover.png] [--fit crop|pad|blur] [-o OUT]
444
495
  render.py --template all INPUT ... # or a comma list: one delivery per destination + <stem>_pack.md
445
496
  render.py --list-templates # the table below, from the running install
446
497
  render.py --template tiktok INPUT --write-project project.json # fill it, edit it, render it later
447
498
  ```
448
499
  A template is a `render.py` project shipped in `templates/<name>.json` with `$INPUT`, `$OUTPUT`,
449
- `$CUES`/`$SRT`, `$LOGO`, `$TITLE`, `$BRAND` and `$CHAPTERS` placeholders. Filling it substitutes
500
+ `$CUES`/`$SRT`, `$LOGO`, `$TITLE`, `$BRAND`, `$CHAPTERS` and `$IMAGE` placeholders. Filling it substitutes
450
501
  what the run was given and **drops any block whose placeholder has no value** — no `--logo` means
451
502
  no overlay stage at all, not an overlay of nothing. The filled project then renders through the
452
503
  normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
@@ -479,6 +530,7 @@ hand-built step:
479
530
  | `linkedin` | 1080x1080 (1:1) | 600 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
480
531
  | `facebook` | 1920x1080 (16:9) | 14400 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
481
532
  | `podcast` | audio only | — | -16 LUFS / -1 dBTP | 0.00 | 0.00 | 0.00 | 0.00 |
533
+ | `audiogram` | 1920x1080 (16:9), or `--platform` / `--image` | 43200 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
482
534
 
483
535
  `podcast` is audio: silence trim, −16 LUFS / −1 dBTP, chapter markers when `--chapters` is given,
484
536
  and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
@@ -487,6 +539,13 @@ linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) int
487
539
  destination; `report.py --pack <stem>_pack.md` renders that table as a single HTML page. A pack
488
540
  whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
489
541
 
542
+ `audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
543
+ **first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
544
+ then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
545
+ colour in the project — this skill fetches nothing and invents no cover art — and it is
546
+ deliberately **not** part of `--template all`, whose destinations all assume a source that
547
+ already has a picture.
548
+
490
549
  ### scenes.py — scene changes and highlight candidates
491
550
  ```
492
551
  scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
@@ -551,9 +610,13 @@ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|st
551
610
  [--name] [--title] [--subtitle] [--text] [--top] [--bottom] [--duration 3]
552
611
  [--from N] [--start S] [--end E] [--position CORNER] [--margin PX] [--platform NAME]
553
612
  [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX]
554
- [--text-render auto|ass|drawtext] [--write-ass OUT.ass]
613
+ [--wrap phrase|measured] [--text-render auto|ass|drawtext] [--write-ass OUT.ass]
555
614
  [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
556
615
  ```
616
+ Since 1.16 a label too wide for the frame is broken into lines by the same phrase-aware wrap
617
+ caption.py uses (`--wrap phrase|measured`; see caption.py above) instead of running off the edge —
618
+ the hook card, the meme lines and the sticker chip. A label that already fits is untouched.
619
+
557
620
  Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
558
621
  the frame's short side; colours, font and safe margin come from `--brand`.
559
622
  Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
@@ -658,9 +721,10 @@ rather than about the file.
658
721
 
659
722
  ### caption.py — subtitles (static, animated, karaoke)
660
723
  ```
661
- caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
662
- [--mode burn|mux] [--audio-stream N] [--fps N] [--lang XX] [--offset TIME]
663
- [--max-lines N] [--min-duration S]
724
+ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
725
+ [--mode burn|mux] [--srt FILE:LANG ...] [--track-title T ...] [--default-track LANG]
726
+ [--audio-stream N] [--fps N] [--lang XX] [--offset TIME]
727
+ [--max-lines N] [--min-duration S] [--wrap phrase|measured]
664
728
  [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
665
729
  [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
666
730
  [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass]
@@ -694,9 +758,13 @@ frame width) at the chosen `--size`, measured per script — CJK and Thai count
694
758
  full em per character, Latin per character from a table read off DejaVu Sans (so
695
759
  an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
696
760
  Arabic/Hebrew 0.6, Devanagari 0.7, and a combining mark nothing at all —
697
- breaking between characters for CJK/Thai and at spaces otherwise, but never
698
- between a character and the combining marks that belong to it (Thai tone marks
699
- and vowel signs, Devanagari matras, Arabic and Hebrew points). A cue that would need more than `--max-lines` (default 2) is split
761
+ breaking between characters for CJK (a katakana word stays whole) and at spaces
762
+ otherwise, but never between a character and the combining marks that belong
763
+ to it (Devanagari matras, Arabic and Hebrew points). Thai (1.16.1) is never
764
+ broken inside a run: it writes no space inside a phrase and the wrapper has no
765
+ dictionary, so the break goes where you put a space or a `|`, and a run with
766
+ none stays long on its own line — `caption.py` counts such lines as `overlong`
767
+ and says so. A cue that would need more than `--max-lines` (default 2) is split
700
768
  into consecutive cues sharing its time; a cue shorter than `--min-duration`
701
769
  (default 1.0 s) is held longer, never past the next cue's start; `--offset
702
770
  TIME` shifts every cue (seconds, `mm:ss`, `hh:mm:ss.ms` or `hh:mm:ss:ff`, a
@@ -708,6 +776,55 @@ comes from, but nothing is written until the real run. `--min-duration` and
708
776
  `--offset` also work with `--write-srt` alone; `--max-lines` does not, because
709
777
  wrapping needs the input video's real frame size.
710
778
 
779
+ Phrase-aware breaking (1.16), `--wrap phrase` (default) — four rules over the
780
+ break positions that already fit, so a line is never widened and the line count
781
+ never changes: **R1** never inside a word, and a hyphenated token may break only
782
+ after its hyphen (never after a non-breaking `‑`, nor a leading/trailing
783
+ one); **R2** no line that is a lone digit, one or two punctuation characters, or
784
+ a single kana, checked at every boundary rather than only the last; **R3** for
785
+ Japanese and Chinese, a break is preferred after `。、!?」』)` and (Japanese
786
+ only) after a particle — a particle attaches to the word before it, so kinsoku
787
+ forbids opening a line with one — discouraged between a kanji stem and its
788
+ okurigana, and forbidden before a small kana, `ー` or a closing bracket; **R4**
789
+ for en/es/pt/fr/de/it, an article or preposition is kept with the phrase it
790
+ governs: the break before it is preferred and the break after it penalised (a
791
+ frozen table, matched case-folded; `--lang`, else the script detector, picks the
792
+ set, and with no language the union of the six is used). `--wrap measured` is 1.15's
793
+ width-only wrap exactly, kept so an older split can be reproduced. The result's
794
+ `caption` object carries the layout counts plus `wrap` and `phrase_breaks`.
795
+ graphics.py takes the same flag for the labels that can hold more than one line
796
+ (the hook card, the meme lines, the sticker chip). The breaker never rewrites,
797
+ shortens or translates the text: a cue that cannot fit `--max-lines` is split
798
+ into consecutive cues, as it always was.
799
+
800
+ Several languages in one file (1.16): `--srt` is repeatable and each file may
801
+ carry a `:lang` suffix — `--mode mux --srt en.srt:en --srt ja.srt:ja --srt
802
+ es.srt:es -o ep.mkv` writes one deliverable with three language-tagged,
803
+ toggleable streams and copies the video and audio bit for bit. The suffix splits
804
+ on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
805
+ token is not itself a file on disk, so `C:\subs\en.srt` and a file named
806
+ `a:b.srt` are never mangled; a single `--srt` with no suffix still takes
807
+ `--language`. `--track-title` names a track (repeated in `--srt` order;
808
+ otherwise a frozen display-name table, and a code the table does not know gets
809
+ the code itself — never a guessed or translated name), `--default-track LANG`
810
+ marks one for auto-selection. Every other new track is explicitly marked *not*
811
+ default, because ffmpeg otherwise flags the first one itself -- so with no
812
+ `--default-track` a Matroska file really does leave every track off. Two tracks with the same code, a
813
+ code that is not BCP-47-shaped, a `--default-track` no track carries, and more
814
+ than one `--srt` with `--mode burn` are all refused. **Container note:** `.mp4`
815
+ and `.mov` accept several `mov_text` tracks but many players show only the
816
+ first, and MPEG-4 stores an ISO-639-2 code — a two-letter one is silently
817
+ dropped, so this tool converts it (`en` → `eng`); Matroska keeps the code you
818
+ give. Past two tracks in an MPEG-4 container the result carries a note
819
+ recommending `.mkv`. Two further MPEG-4 limits are reported rather than papered
820
+ over: it has no per-track title the muxer writes back (so `tracks[].title` is
821
+ `null` there, with a note) and it always enables its first subtitle track
822
+ whatever disposition is asked for (so that track is reported `default: true`,
823
+ again with a note). `.mkv` has neither limit. The result gains `tracks` and `subtitle_tracks`; check.py
824
+ prints an informational `subtitles` row (WARN for an untagged stream, never
825
+ counted in `failed`). The skill never translates and never generates a second
826
+ language.
827
+
711
828
  Fonts by script (1.12): with no `--font` and no font named in your brand file, the
712
829
  script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
713
830
  Thai, Cyrillic, Greek) picks a font file that covers it, logged as `font: <file>
@@ -66,11 +66,13 @@ from _common.emit import (
66
66
  PLAN_VERSION, print_json, _result_v2, _set_current_ctx, _V2_HANDLED, write_plan
67
67
  )
68
68
  from _common.probe import (
69
- analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, fingerprint, _fraction, keyframes_near,
69
+ analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
70
+ _fraction, keyframes_near, SCORE_RE, SIL_RE,
70
71
  measured_level_dbfs, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
71
72
  )
72
73
  from _common.decision import (
73
74
  aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
75
+ description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
74
76
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
75
77
  fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
76
78
  signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
@@ -87,6 +89,13 @@ from _common.text import (
87
89
  font_for_script, FONT_INSTALL_HINT, fonts_dir_covers_script, has_emoji, _is_emoji_base, _is_emoji_char,
88
90
  _is_mark, _KEYCAP, _KEYCAP_BASES, LANGUAGE_NAMES, LATIN_EM, LEADING_VOWELS, _libass_color_probe, needs_shaping,
89
91
  NO_SPACE_SCRIPTS, PREFERRED_FAMILIES, resolve_emoji_assets, _SCRIPT_FONT_CACHE, _script_font_entry,
92
+ _atoms, best_break, _bare_word, break_penalty, _break_spaced, _cut_penalty, _fix_orphans, _fix_weak_lines,
93
+ _function_words, FUNCTION_WORDS, _HYPHENS, _is_hiragana, _is_ideograph, _is_kana, _is_weak_line,
94
+ JA_NO_LINE_END, JA_NO_LINE_START, JA_PARTICLE_WORDS, JA_PARTICLES, JA_SENTENCE_END, _join, ORPHAN_MIN_EM,
95
+ PENALTY_FORBIDDEN, PENALTY_FUNCTION_WORD, PENALTY_FUNCTION_WORD_START, PENALTY_IDEOGRAPHS,
96
+ PENALTY_NEUTRAL, PENALTY_OKURIGANA,
97
+ PENALTY_PARTICLE, PENALTY_SENTENCE_END, _rebalance, _rebalance_phrase, SAFE_WIDTH_FRACTION, _split_hyphens,
98
+ _particle_ends, _particle_starts, wrap_text, wrap_variants, WRAP_MODES,
90
99
  script_font_for_text, script_font_status, _script_font_uncached, _SCRIPT_RANGES, SCRIPTS, _SHAPING_BUILD_CACHE,
91
100
  SHAPING_SCRIPTS, text_width_em, _VS15, _VS16, WINDOWS_FONTS, _ZWJ
92
101
  )
@@ -153,12 +162,12 @@ sys.modules[__name__].__class__ = _Facade
153
162
  __all__ = [
154
163
  "Any", "Dict", "Fraction", "List", "Optional", "Path", "Sequence", "Tuple", "argparse", "json", "math", "os",
155
164
  "platform", "re", "shutil", "subprocess", "sys", "unicodedata",
156
- "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "_aspect_string",
165
+ "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
157
166
  "audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
158
167
  "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
159
168
  "char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
160
169
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
161
- "concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono",
170
+ "concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
162
171
  "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw",
163
172
  "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
164
173
  "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
@@ -183,5 +192,13 @@ __all__ = [
183
192
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
184
193
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "_V2_HANDLED", "validate_color", "verify_output",
185
194
  "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
186
- "_x264_raw", "_ZWJ"
195
+ "_x264_raw", "_ZWJ",
196
+ "_atoms", "best_break", "_bare_word", "break_penalty", "_break_spaced", "_cut_penalty", "_fix_orphans",
197
+ "_fix_weak_lines", "_function_words", "FUNCTION_WORDS", "_HYPHENS", "_is_hiragana", "_is_ideograph",
198
+ "_is_kana", "_is_weak_line", "JA_NO_LINE_END", "JA_NO_LINE_START", "JA_PARTICLE_WORDS", "JA_PARTICLES",
199
+ "PENALTY_FUNCTION_WORD_START",
200
+ "JA_SENTENCE_END", "_join", "ORPHAN_MIN_EM", "PENALTY_FORBIDDEN", "PENALTY_FUNCTION_WORD",
201
+ "PENALTY_IDEOGRAPHS", "PENALTY_NEUTRAL", "PENALTY_OKURIGANA", "PENALTY_PARTICLE", "PENALTY_SENTENCE_END",
202
+ "_rebalance", "_rebalance_phrase", "SAFE_WIDTH_FRACTION", "_split_hyphens", "_particle_ends",
203
+ "_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES"
187
204
  ]
@@ -10,7 +10,7 @@ import json
10
10
  import os
11
11
  import argparse
12
12
  from pathlib import Path
13
- from typing import Any, Dict, List, Optional
13
+ from typing import Any, Dict, List, Optional, Sequence
14
14
  from _common.color import bt709_tag_args, _sdr_bt709
15
15
  from _common.emit import die
16
16
  from _common.runner import CODECS, STATE, ffmpeg_encoders
@@ -413,3 +413,108 @@ def brand_caption_style(brand: Dict[str, Any]) -> Dict[str, Any]:
413
413
  style["color"] = style.pop("colour")
414
414
  style.pop("colour", None)
415
415
  return style
416
+
417
+
418
+ # ------------------------------------------------------------------- chapter proposal (1.16)
419
+
420
+ def fmt_chapter_time(t: float) -> str:
421
+ """The YouTube description convention: `00:00`, `03:12`, `1:02:03` past the hour, always
422
+ rounded DOWN to the second so the timestamp never lands after the moment it names."""
423
+ total = max(0, int(t))
424
+ h, rem = divmod(total, 3600)
425
+ m, sec = divmod(rem, 60)
426
+ if h:
427
+ return f"{h}:{m:02d}:{sec:02d}"
428
+ return f"{m:02d}:{sec:02d}"
429
+
430
+
431
+ def _evidence_rank(ev: Dict[str, Any]) -> tuple:
432
+ """How strong a candidate is, for the drop order: both detectors beat a silence, a silence
433
+ beats a scene cut, and within a kind the longer pause / higher score wins."""
434
+ kind = ev.get("kind")
435
+ tier = {"start": 3, "silence+scene": 2, "silence": 1, "scene": 0}.get(kind, 0)
436
+ within = float(ev.get("silence_length") or ev.get("score") or 0.0)
437
+ return (tier, within)
438
+
439
+
440
+ def propose_chapters(duration: float, silences: "Sequence", scene_cuts: "Sequence", *,
441
+ min_chapter: float = 60.0, max_chapters: int = 0,
442
+ source: str = "both") -> "List[Dict[str, Any]]":
443
+ """Chapter markers proposed from measured structure. Pure: the detectors' outputs go in,
444
+ a list of `{"at", "title", "evidence"}` comes out, and nothing is decoded here.
445
+
446
+ A chapter starts where speech RESUMES, so a silence contributes its `end`, not its midpoint.
447
+ A scene cut within 1 s of such a point is the same event seen twice and is merged into one
448
+ candidate with `kind: "silence+scene"`, which the `--max-chapters` cap never drops before a
449
+ single-evidence one. Candidates closer than `min_chapter` to the one already kept are dropped,
450
+ stronger evidence winning; so is anything inside `min_chapter` of the end of the file.
451
+
452
+ Every title is `Chapter N`. The function never looks at, and never invents, content: naming a
453
+ chapter needs knowing what is said in it, which is the calling agent's job, not this skill's.
454
+ """
455
+ duration = float(duration or 0.0)
456
+ min_chapter = max(0.0, float(min_chapter))
457
+ candidates: "List[Dict[str, Any]]" = []
458
+ if source in ("silence", "both"):
459
+ for span in silences or []:
460
+ start, end = float(span[0]), span[1]
461
+ if end is None or end == float("inf"):
462
+ continue
463
+ end = float(end)
464
+ candidates.append({"at": end, "evidence": {
465
+ "kind": "silence", "silence": [round(start, 3), round(end, 3)],
466
+ "silence_length": round(end - start, 3)}})
467
+ if source in ("scenes", "both"):
468
+ for cut in scene_cuts or []:
469
+ cut = float(cut)
470
+ if cut <= 0.0:
471
+ continue # scenes.py always reports 0.0 as the first cut; that is the start
472
+ candidates.append({"at": cut, "evidence": {"kind": "scene", "scene_at": round(cut, 3)}})
473
+
474
+ # merge a scene cut that stands within 1 s of a silence end: one event, two witnesses
475
+ candidates.sort(key=lambda c: c["at"])
476
+ merged: "List[Dict[str, Any]]" = []
477
+ for cand in candidates:
478
+ prior = merged[-1] if merged else None
479
+ if prior and abs(cand["at"] - prior["at"]) <= 1.0 and \
480
+ {prior["evidence"]["kind"], cand["evidence"]["kind"]} == {"silence", "scene"}:
481
+ ev = dict(prior["evidence"])
482
+ ev.update(cand["evidence"])
483
+ ev["kind"] = "silence+scene"
484
+ silence_first = prior["evidence"]["kind"] == "silence"
485
+ prior["at"] = prior["at"] if silence_first else cand["at"]
486
+ prior["evidence"] = ev
487
+ continue
488
+ merged.append(dict(cand))
489
+
490
+ kept: "List[Dict[str, Any]]" = [{"at": 0.0, "evidence": {"kind": "start"}}]
491
+ for cand in merged:
492
+ if duration and cand["at"] >= duration - min_chapter:
493
+ continue
494
+ last = kept[-1]
495
+ if cand["at"] - last["at"] < min_chapter:
496
+ # too close to the marker already kept: keep whichever the evidence supports better,
497
+ # never replacing the 0.0 start
498
+ if last["evidence"]["kind"] != "start" and \
499
+ _evidence_rank(cand["evidence"]) > _evidence_rank(last["evidence"]) and \
500
+ (len(kept) < 2 or cand["at"] - kept[-2]["at"] >= min_chapter):
501
+ kept[-1] = dict(cand)
502
+ continue
503
+ kept.append(dict(cand))
504
+
505
+ if max_chapters and len(kept) > max_chapters:
506
+ # drop the weakest evidence first, never index 0, then put the survivors back in order
507
+ order = sorted(range(1, len(kept)),
508
+ key=lambda i: (_evidence_rank(kept[i]["evidence"]), -kept[i]["at"]))
509
+ drop = set(order[:len(kept) - max_chapters])
510
+ kept = [c for i, c in enumerate(kept) if i not in drop]
511
+
512
+ for n, chapter in enumerate(kept, start=1):
513
+ chapter["at"] = round(chapter["at"], 3)
514
+ chapter["title"] = f"Chapter {n}"
515
+ return kept
516
+
517
+
518
+ def description_block(chapters: "Sequence") -> str:
519
+ """The YouTube description form of a chapter list: `00:00 Chapter 1` per line."""
520
+ return "\n".join(f"{fmt_chapter_time(c['at'])} {c['title']}" for c in chapters)