ffmpeg-skill 1.15.1 → 1.16.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 +5 -3
- package/SKILL.md +8 -7
- package/docs/contract.md +24 -9
- package/package.json +1 -1
- package/references/scripts.md +121 -8
- package/scripts/_common/__init__.py +21 -4
- package/scripts/_common/decision.py +106 -1
- package/scripts/_common/probe.py +92 -2
- package/scripts/_common/text.py +535 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +252 -200
- package/scripts/check.py +19 -0
- package/scripts/graphics.py +47 -8
- package/scripts/metadata.py +130 -5
- package/scripts/render.py +33 -6
- package/scripts/scenes.py +3 -61
- package/scripts/silence.py +3 -25
- package/scripts/waveform.py +199 -12
- package/templates/audiogram.json +31 -0
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
|
|
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.
|
|
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.
|
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` (
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.16.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`
|
|
@@ -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.
|
|
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.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.
|
|
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.0 | `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.0 | `--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.0 | 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.0 | `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.0 | `--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.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.16.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.16.0", "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.
|
|
131
|
+
| `supports_json_brief` | whether `--json-brief` exists (1.16.0): 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.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -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]
|
|
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 `$
|
|
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] [--
|
|
663
|
-
[--
|
|
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]
|
|
@@ -708,6 +772,55 @@ comes from, but nothing is written until the real run. `--min-duration` and
|
|
|
708
772
|
`--offset` also work with `--write-srt` alone; `--max-lines` does not, because
|
|
709
773
|
wrapping needs the input video's real frame size.
|
|
710
774
|
|
|
775
|
+
Phrase-aware breaking (1.16), `--wrap phrase` (default) — four rules over the
|
|
776
|
+
break positions that already fit, so a line is never widened and the line count
|
|
777
|
+
never changes: **R1** never inside a word, and a hyphenated token may break only
|
|
778
|
+
after its hyphen (never after a non-breaking `‑`, nor a leading/trailing
|
|
779
|
+
one); **R2** no line that is a lone digit, one or two punctuation characters, or
|
|
780
|
+
a single kana, checked at every boundary rather than only the last; **R3** for
|
|
781
|
+
Japanese and Chinese, a break is preferred after `。、!?」』)` and (Japanese
|
|
782
|
+
only) after a particle — a particle attaches to the word before it, so kinsoku
|
|
783
|
+
forbids opening a line with one — discouraged between a kanji stem and its
|
|
784
|
+
okurigana, and forbidden before a small kana, `ー` or a closing bracket; **R4**
|
|
785
|
+
for en/es/pt/fr/de/it, an article or preposition is kept with the phrase it
|
|
786
|
+
governs: the break before it is preferred and the break after it penalised (a
|
|
787
|
+
frozen table, matched case-folded; `--lang`, else the script detector, picks the
|
|
788
|
+
set, and with no language the union of the six is used). `--wrap measured` is 1.15's
|
|
789
|
+
width-only wrap exactly, kept so an older split can be reproduced. The result's
|
|
790
|
+
`caption` object carries the layout counts plus `wrap` and `phrase_breaks`.
|
|
791
|
+
graphics.py takes the same flag for the labels that can hold more than one line
|
|
792
|
+
(the hook card, the meme lines, the sticker chip). The breaker never rewrites,
|
|
793
|
+
shortens or translates the text: a cue that cannot fit `--max-lines` is split
|
|
794
|
+
into consecutive cues, as it always was.
|
|
795
|
+
|
|
796
|
+
Several languages in one file (1.16): `--srt` is repeatable and each file may
|
|
797
|
+
carry a `:lang` suffix — `--mode mux --srt en.srt:en --srt ja.srt:ja --srt
|
|
798
|
+
es.srt:es -o ep.mkv` writes one deliverable with three language-tagged,
|
|
799
|
+
toggleable streams and copies the video and audio bit for bit. The suffix splits
|
|
800
|
+
on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
|
|
801
|
+
token is not itself a file on disk, so `C:\subs\en.srt` and a file named
|
|
802
|
+
`a:b.srt` are never mangled; a single `--srt` with no suffix still takes
|
|
803
|
+
`--language`. `--track-title` names a track (repeated in `--srt` order;
|
|
804
|
+
otherwise a frozen display-name table, and a code the table does not know gets
|
|
805
|
+
the code itself — never a guessed or translated name), `--default-track LANG`
|
|
806
|
+
marks one for auto-selection. Every other new track is explicitly marked *not*
|
|
807
|
+
default, because ffmpeg otherwise flags the first one itself -- so with no
|
|
808
|
+
`--default-track` a Matroska file really does leave every track off. Two tracks with the same code, a
|
|
809
|
+
code that is not BCP-47-shaped, a `--default-track` no track carries, and more
|
|
810
|
+
than one `--srt` with `--mode burn` are all refused. **Container note:** `.mp4`
|
|
811
|
+
and `.mov` accept several `mov_text` tracks but many players show only the
|
|
812
|
+
first, and MPEG-4 stores an ISO-639-2 code — a two-letter one is silently
|
|
813
|
+
dropped, so this tool converts it (`en` → `eng`); Matroska keeps the code you
|
|
814
|
+
give. Past two tracks in an MPEG-4 container the result carries a note
|
|
815
|
+
recommending `.mkv`. Two further MPEG-4 limits are reported rather than papered
|
|
816
|
+
over: it has no per-track title the muxer writes back (so `tracks[].title` is
|
|
817
|
+
`null` there, with a note) and it always enables its first subtitle track
|
|
818
|
+
whatever disposition is asked for (so that track is reported `default: true`,
|
|
819
|
+
again with a note). `.mkv` has neither limit. The result gains `tracks` and `subtitle_tracks`; check.py
|
|
820
|
+
prints an informational `subtitles` row (WARN for an untagged stream, never
|
|
821
|
+
counted in `failed`). The skill never translates and never generates a second
|
|
822
|
+
language.
|
|
823
|
+
|
|
711
824
|
Fonts by script (1.12): with no `--font` and no font named in your brand file, the
|
|
712
825
|
script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
|
|
713
826
|
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,
|
|
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)
|