ffmpeg-skill 1.13.0 → 1.15.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/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.13.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.15.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.13.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.13.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.13.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.13.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.13.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 |
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.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.15.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.15.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.15.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.15.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.13.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.13.0", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.15.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.15.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.13.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 |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.15.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 |
@@ -264,6 +264,46 @@ downstream analysis/preview, distinct from `export.py`'s delivery
264
264
  presets) resolves to `proxy` - itself a mechanical resize + re-encode
265
265
  with no opinion on which asset should be proxied or what for.
266
266
 
267
+ ## Delivery table and templates (1.14)
268
+
269
+ `scripts/_platforms.py` is the one table every delivery tool reads. Per destination
270
+ (`tiktok`, `reels`, `shorts`, `youtube`, `youtube-hdr`, `youtube-av1`, `x`, `linkedin`,
271
+ `facebook`, `podcast`, plus the `broadcast` / `custom` compliance targets):
272
+
273
+ | field | meaning |
274
+ |---|---|
275
+ | `frame` | `{w, h, aspect}` the destination is delivered at, or `null` for an audio-only one |
276
+ | `fps` | the frame rate a delivery is conformed to (`null`: leave the source's alone) |
277
+ | `spec` | `check.py`'s row values: `max_duration`, `aspects`, `min_height`, `fps_max`, `codecs`, `max_bytes`, `lufs`, `lufs_tol`, `tp`, `sdr_only` |
278
+ | `safe` | the fraction of the frame the app's own UI covers, per edge (`top`, `bottom`, `left`, `right`) |
279
+ | `caption` | caption defaults a template uses: `size` (fraction of frame height), `position`, `box`, `outline`, `animate` |
280
+ | `preset` | the `export.py` preset that writes this destination |
281
+ | `check` | the `check.py` platform a delivery is verified against |
282
+
283
+ It is an internal module (leading underscore), not a tool: the public tool count is unchanged.
284
+ `check.py`'s `SPECS`, `export.py`'s `PRESETS` (each platform preset's frame and duration cap)
285
+ and `export.py`'s `PLATFORM_OF` are all built from it, so the loudness `export.py --normalize`
286
+ targets, the frame it writes, the cap it trims at and the spec `check.py` enforces are one
287
+ value. Two presets deliberately differ from their destination's row and say so in the code:
288
+ `youtube4k` delivers to YouTube at 2160p, and no `youtube*` preset trims at YouTube's 12-hour
289
+ limit (`check.py` reports it instead). `_platforms.resolve()` is the one alias map --
290
+ `youtube-shorts`/`yt-shorts` = `shorts`, `yt` = `youtube`, `instagram`/`ig` = `reels`,
291
+ `twitter` = `x`, `fb` = `facebook` -- and `check.py --platform`, `export.py --preset`,
292
+ `caption.py`/`graphics.py`/`overlay.py --platform`, `look.py --safe` and
293
+ `render.py --template` all accept those spellings.
294
+
295
+ New in the same release, all additive: `export.py --preset tiktok|shorts|linkedin|facebook`
296
+ (real presets, not aliases of `reels`/`youtube`), `--preset youtube-hdr` (HEVC Main10 keeping
297
+ the source's HDR tags; `kind: input` on an SDR source) and `--preset youtube-av1`
298
+ (`kind: missing_tool` when the build has neither SVT-AV1 nor libaom); `caption.py --platform`
299
+ and `graphics.py --platform` / `--margin` and `overlay.py --platform` (margins from the safe
300
+ zone, an explicit `--margin`/`--position` wins); `look.py --safe NAME`; `fit.py --fit blur`; `report.py --pack`;
301
+ `graphics.py --template sticker|hook|meme`; and `render.py --template NAME INPUT`
302
+ (`--cues/--srt/--logo/--title/--brand/--chapters/--fit/-o/--write-project/--list-templates`),
303
+ which fills a `templates/<name>.json` project shipped with the skill. `--template all` or a
304
+ comma-separated list renders every named destination and writes a `<stem>_pack.md` table.
305
+ A project may now carry `"template"` (the name it was filled from) and `"frame": {"fit": ...}`.
306
+
267
307
  ## Capabilities
268
308
 
269
309
  Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
@@ -321,7 +361,8 @@ typeface the caller didn't ask for.
321
361
 
322
362
  Since 1.12 the same field also carries `scripts`: one entry per writing system the tools detect,
323
363
  `{"ja": {"status": "available"|"missing"|"unknown", "file": "/path/to/font.ttc"|null}, "zh": ...,
324
- "ko": ..., "ar": ..., "he": ..., "hi": ..., "th": ..., "ru": ..., "el": ...}`. It answers "which
364
+ "ko": ..., "ar": ..., "he": ..., "hi": ..., "bn": ..., "ta": ..., "th": ..., "lo": ..., "ru": ...,
365
+ "el": ...}` (`bn`, `ta` and `lo` were added in 1.15). It answers "which
325
366
  languages can this machine actually render", which no filter or encoder capability asks:
326
367
  `available` means `fc-list :lang=<code>` (Linux/macOS) or a known system font file (Windows) covers
327
368
  the script, `missing` means fontconfig knows none, `unknown` means there is no working fontconfig to
@@ -338,6 +379,25 @@ line to one screen width; a longer explanation, and the per-script files, are `-
338
379
  `--lang`/`--language` (caption, graphics) is the hint that says whether Han-only text is Chinese,
339
380
  Japanese or Korean.
340
381
 
382
+ Since 1.15 `fonts` also carries `emoji`: `{"mode": "color"|"png"|"mono"|"none", "color_font": "Noto
383
+ Color Emoji"|null, "color_font_file": "..."|null, "libass_color": true|false|null, "assets":
384
+ "/path"|null, "detail": "...", "fix": "..."}`. `libass_color` comes from a **render probe** — one
385
+ 64x64 frame with an emoji cue through `subtitles=`, chroma-tested — because an installed colour
386
+ emoji family proves nothing: Noto Color Emoji installs cleanly on builds whose libass still draws a
387
+ monochrome outline. `null` means the probe was not run: `contract --json --static` (and every other
388
+ static/JSON-only path) skips it, exactly as it skips the rest of the environment detection. `mode`
389
+ is `color` when the probe says colour, else `png` when an emoji assets directory resolves, else
390
+ `mono` when some installed face has a glyph, else `none`. Informational like the rest of `fonts`:
391
+ it never moves `ok` or any tool's `usable`.
392
+
393
+ `caption.py`, `graphics.py` and `overlay.py` gained `--emoji auto|color|png|mono|none`,
394
+ `--emoji-assets DIR`, `--emoji-scale FLOAT` and `--emoji-max N` in 1.15; `graphics.py` also gained
395
+ `--text-render auto|ass|drawtext` and `--write-ass PATH`. New success keys: `emoji`
396
+ (`{"mode", "count", "clusters", "assets", "missing", "overlays"}`) on `caption.py` and
397
+ `graphics.py`, and `text_renderer` (`"ass"`|`"drawtext"`), `script` and `ass` (the generated file,
398
+ when one was written) on `graphics.py`. `--json-brief` carries `emoji.mode` and `emoji.count` only.
399
+ All additive: `contract_version` stays 1.0.
400
+
341
401
  ## Invocation
342
402
 
343
403
  Structured arguments are the canonical way to call a tool, on the CLI or through MCP.
@@ -483,6 +543,8 @@ ffmpeg-skill contains no agent-specific code.
483
543
 
484
544
  ## Where things live
485
545
 
546
+ - `scripts/_platforms.py`: the delivery table (destinations, specs, safe zones) read by check/export/render/caption/graphics/look
547
+ - `templates/*.json`: the shipped delivery templates `render.py --template NAME` fills
486
548
  - `scripts/_contract.py`: the generator (`--json`, `--static`, `doctor`)
487
549
  - `bin/install.js`: `ffmpeg-skill contract` and `ffmpeg-skill doctor`
488
550
  - `tests/test_contract.py`: schema, consistency (scripts = MCP = installer), MCP inputSchema derived from the contract (equality, determinism, drift, round trips), dry-run, JSON shapes, verification policy, real-media run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "bin/",
31
31
  "scripts/",
32
+ "templates/",
32
33
  "mcp/",
33
34
  "references/scripts.md",
34
35
  "references/devices.md",
@@ -42,7 +43,8 @@
42
43
  "scripts": {
43
44
  "test": "python3 tests/test_all.py && python3 tests/test_contract.py",
44
45
  "release-check": "bash tests/release_check.sh",
45
- "demo": "bash examples/make_demo.sh",
46
+ "demo": "python3 demos/build.py",
47
+ "demo:pipeline": "bash examples/make_demo.sh",
46
48
  "contract": "python3 scripts/_contract.py --json",
47
49
  "doctor": "python3 scripts/_contract.py doctor"
48
50
  },
@@ -109,7 +109,7 @@ libass and drawtext draw an empty box per character they have no glyph for, and
109
109
  ffmpeg still exits 0 — a video full of tofu is the classic "it worked" failure.
110
110
  Since 1.12 `caption.py`, `graphics.py` and `overlay.py --text` detect the script
111
111
  of the text they are about to draw (Japanese, Chinese, Korean, Arabic, Hebrew,
112
- Devanagari, Thai, Cyrillic, Greek) and resolve a font file that covers it,
112
+ Devanagari, Bengali, Tamil, Thai, Lao, Cyrillic, Greek) and resolve a font file that covers it,
113
113
  printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
114
114
  **No font for the script is a failed job** (`kind: input`), not a warning.
115
115
 
@@ -145,6 +145,75 @@ printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
145
145
  letterforms. Nothing in the tools checks this, so on an unknown machine a
146
146
  caption is the safe place for Arabic/Hebrew.
147
147
 
148
+ **Shaping is a second problem, and it is not the same one.** A font that covers
149
+ the script still has to be *shaped*: Devanagari matras are reordered, Thai and
150
+ Lao marks re-cluster. drawtext does bidi and Arabic joining correctly on a build
151
+ compiled with fribidi (so Arabic and Hebrew are already right today), but it
152
+ never reorders or re-clusters, because it does not use harfbuzz on any build.
153
+ libass does. Since 1.15 `graphics.py` therefore renders a shaping script
154
+ (`hi bn ta te kn ml gu pa si th lo km my`) through libass automatically — one
155
+ generated `<output>_gfx.ass` next to the file, `text_renderer: "ass"` in the
156
+ JSON — and `--text-render auto|ass|drawtext` overrides it; `--text-render
157
+ drawtext` with such a script is a refusal, not a wrong frame. Latin, CJK and
158
+ Arabic output is unchanged. `overlay.py --text` has no ASS route yet and refuses
159
+ a shaping script, naming `caption.py`/`graphics.py`.
160
+
161
+ ### Emoji
162
+ Colour emoji need **either** a libass that can draw colour glyphs **or** a
163
+ directory of PNGs — and on most builds only the second one exists.
164
+
165
+ - **An installed colour emoji font proves nothing.** Noto Color Emoji installs
166
+ cleanly on Ubuntu and libass on the distro ffmpeg still renders a monochrome
167
+ outline (`Glyph 0x1F389 not found, broken font? Trying all charmaps`). The
168
+ only honest test is a render, which is what `doctor` runs:
169
+ `doctor --json` → `.fonts.emoji` (`mode`: `color` / `png` / `mono` / `none`,
170
+ plus `libass_color` from that render). `contract --json --static` is the way
171
+ to get the contract without running it; `doctor` itself always probes.
172
+ - **drawtext cannot load an emoji font at all.** Not a degraded render: a
173
+ CBDT/sbix face fails filter initialisation outright (`Could not set font size
174
+ to 48 pixels: invalid library handle`, and at the font's own strike
175
+ `Monocromatic (1bpp) fonts are not supported.`) and no file is written. That
176
+ is why the colour route is a PNG overlay, never a drawtext font.
177
+ - **PNGs**: `--emoji-assets DIR`, a directory of files named by code point in
178
+ the Twemoji/Noto convention — lowercase hex joined by `-`: `1f389.png`,
179
+ `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png`. Twemoji's `assets/72x72`
180
+ (CC-BY 4.0) and Noto Emoji's `png/128` (OFL/Apache-2.0) are the two people
181
+ already have. **The skill never downloads anything**: a missing directory is
182
+ `kind: input`, never a silent fetch. The same directory can come from
183
+ `brand.json` (`styles.caption.emoji_assets`) or `FFMPEG_SKILL_EMOJI_ASSETS`.
184
+ - **Mixed lines are the normal case.** The text stays in the ASS with an
185
+ invisible placeholder reserving exactly the emoji's box, and the PNG is
186
+ composited on top after the `ass=` filter — libass keeps the shaping and the
187
+ karaoke, including inside a `\kf` run (the placeholder is its own
188
+ zero-duration segment).
189
+ - **What counts as an emoji.** A cluster only starts at an emoji *base*.
190
+ U+200D ZWJ and U+200C ZWNJ are ordinary Indic/Persian orthography (`क्‍ष` is
191
+ ka + virama + ZWJ + ssa) and are glue only *between* two emoji; a VS16 or a
192
+ skin-tone modifier after a plain letter is not an emoji either. U+FE0E
193
+ (VS15) asks for the character, so `❤︎` is text and `❤️` is an emoji.
194
+ - **Placement tolerance.** The position comes from the same averaged em table
195
+ the wrap uses, so an emoji at the start or end of a line is exact and one in
196
+ the middle of a Latin line drifts by the accumulated rounding of the
197
+ characters before it — **measured at 17 px, 0.28 em, about 3 % of the line
198
+ width** on a 24 px caption over a 1280-wide frame, always landing inside the
199
+ reserved gap rather than on a glyph. It never leaves the safe area. An RTL
200
+ line (Arabic, Hebrew) is laid out right-to-left, so the position is measured
201
+ from the rendered end of the line, not the logical prefix.
202
+ - **Degraded paths are honest, not silent.** `mode: mono` means libass draws
203
+ whatever glyph the text font has (it has a fallback chain), exits 0, and says
204
+ so in a `warning:` line and in `notes` — so a `graphics.py` run that would
205
+ otherwise use drawtext, which loads one font file and has **no** fallback
206
+ chain, is routed to libass rather than reporting a monochrome glyph it would
207
+ actually draw as an empty box; a run pinned to `--text-render drawtext`
208
+ degrades to `none` and says so. `mode: none` strips the cluster from the drawn
209
+ text (a missing decoration must not fail a delivery) but refuses a
210
+ `graphics.py` template whose text is *only* emoji — that frame would be blank.
211
+ `--emoji-max` (default 60) caps the overlay count; `--emoji-max 0` means none.
212
+ - **`--animate` moves the PNG too.** The overlay gets a matching alpha fade, so
213
+ the emoji arrives and leaves with the line instead of popping in.
214
+ - `overlay.py --text` has no PNG route: `--emoji png|color` there names
215
+ `caption.py`/`graphics.py` instead.
216
+
148
217
  ### Reframing, fps and duration
149
218
  `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot
150
219
  loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y`
@@ -158,6 +227,21 @@ visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
158
227
  trim it drops two thirds of the words. Ask which, or propose a highlight cut with
159
228
  `scenes.py`.
160
229
 
230
+ ### Platform safe zones
231
+ Every vertical app draws its own UI over the delivery: TikTok covers roughly the
232
+ bottom 22 % (description and caption block), the right 14 % (like/comment/share
233
+ column) and the top 10 % (status bar and tabs); Reels 20/12/8 %; Shorts 18/12/6 %.
234
+ The feed destinations (YouTube, X, LinkedIn, Facebook) have no persistent overlay
235
+ and use the conventional 5 % title-safe border instead. A file can pass every
236
+ `check.py` row and still be unreadable because the caption sits under the
237
+ description.
238
+
239
+ The fractions live in one table (`scripts/_platforms.py`). `caption.py --platform
240
+ NAME`, `graphics.py --platform NAME` and `overlay.py --platform NAME` take their
241
+ margins from it, `render.py --template NAME` passes it to all three, and
242
+ `look.py --safe tiktok` shades the zones on a frame or contact sheet so you can
243
+ see what the app covers. An explicit `--margin` always wins.
244
+
161
245
  ### Dimensions and rotation
162
246
  `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
163
247
  automatically. Phone footage often carries a `rotation` tag; `probe.py` reports
@@ -29,6 +29,7 @@ between tools.
29
29
  - silence.py — remove dead air / jump cuts
30
30
  - join.py — concatenate with transitions
31
31
  - render.py — the whole edit in one project.json
32
+ - delivery templates — one command per destination (`--template`)
32
33
  - scenes.py — scene changes and highlight candidates
33
34
  - check.py — pre-delivery compliance
34
35
  - batch.py — same recipe over a folder, cached
@@ -76,7 +77,7 @@ re-encode, the result's `lossless_alternative` names the nearest keyframe
76
77
  ### fit.py — target duration and/or aspect, rotate/flip
77
78
  ```
78
79
  fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
79
- [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--height H] [--pad-color black] [--pad-fill color|blur [--pad-blur 20]]]
80
+ [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop|blur [--width W] [--height H] [--pad-color black] [--pad-fill color|blur [--pad-blur 20]]]
80
81
  [--rotate 90|180|270] [--flip h|v] [--fps N] [-o OUT]
81
82
  ```
82
83
  `speed` retimes video and audio together (pitch-preserving `atempo`); it
@@ -94,7 +95,13 @@ automatically even without it.
94
95
  `--pad-fill blur` fills the letterbox/pillarbox bars with a blurred, scaled-to-cover copy
95
96
  of the frame (the look every phone editor gives landscape footage posted as a Short/Reel)
96
97
  instead of the solid `--pad-color`; `--pad-blur` is the blur radius. `export.py --fit pad`
97
- takes the same two flags.
98
+ takes the same two flags. `--fit blur` (1.14) is the same fill named in one word and with the
99
+ background dimmed (`eq brightness=-0.15`) so the picture in front reads as the subject: the
100
+ whole frame is kept (nothing cropped), the borders are a blurred copy of it rather than black.
101
+ A delivery template asks for it as `"frame": {"aspect": "9:16", "fit": "blur"}`, or
102
+ `render.py --template tiktok clip.mp4 --fit blur`. The dimming is applied to SDR sources only:
103
+ an `eq` on PQ/HLG code values is not the −15 % perceptual dim it is on SDR, so an HDR source
104
+ keeps a blurred but undimmed background (and is never silently tone-mapped); `info` says so.
98
105
 
99
106
  ### crop.py — crop to an exact pixel rectangle
100
107
  ```
@@ -430,6 +437,56 @@ more than two steps or the user is likely to ask for changes: edit the JSON,
430
437
  re-render, and the result is reproducible. `--dry-run --json` prints the
431
438
  complete command plan for review.
432
439
 
440
+ ### Delivery templates — one command per destination (1.14)
441
+ ```
442
+ 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]
444
+ render.py --template all INPUT ... # or a comma list: one delivery per destination + <stem>_pack.md
445
+ render.py --list-templates # the table below, from the running install
446
+ render.py --template tiktok INPUT --write-project project.json # fill it, edit it, render it later
447
+ ```
448
+ 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
450
+ what the run was given and **drops any block whose placeholder has no value** — no `--logo` means
451
+ no overlay stage at all, not an overlay of nothing. The filled project then renders through the
452
+ normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
453
+ unknown name is refused (`kind: input`) with the list. Output defaults to
454
+ `<input>_<template>.mp4` **next to the input** (`.m4a` for an audio-only destination such as
455
+ `podcast`) — the same rule for one template and for a pack, whose `-o` names the directory.
456
+ Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts` = `shorts`,
457
+ `yt` = `youtube`, `instagram`/`ig` = `reels`, `twitter` = `x`, `fb` = `facebook`
458
+ (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
459
+ --platform`, `look.py --safe`, `render.py --template`).
460
+
461
+ Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
462
+ with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
463
+ reaches a pack's audio destination like it does the single-template form.
464
+
465
+ Each template's frame, duration limit, loudness target and safe zones come from the one delivery
466
+ table (`scripts/_platforms.py`). Safe zones are the fraction of the frame the app's own UI covers;
467
+ the template places captions, graphics **and the `--logo` overlay** clear of them, and
468
+ `caption.py --platform`, `graphics.py --platform` and `overlay.py --platform` apply them to a
469
+ hand-built step:
470
+
471
+ | template | frame | max duration | loudness | safe top | safe bottom | safe left | safe right |
472
+ |---|---|---|---|---|---|---|---|
473
+ | `tiktok` | 1080x1920 (9:16) | 600 s | -14 LUFS / -1 dBTP | 0.10 | 0.22 | 0.05 | 0.14 |
474
+ | `reels` | 1080x1920 (9:16) | 90 s | -14 LUFS / -1 dBTP | 0.08 | 0.20 | 0.05 | 0.12 |
475
+ | `shorts` | 1080x1920 (9:16) | 180 s | -14 LUFS / -1 dBTP | 0.06 | 0.18 | 0.05 | 0.12 |
476
+ | `youtube-shorts` | 1080x1920 (9:16) | 180 s | -14 LUFS / -1 dBTP | 0.06 | 0.18 | 0.05 | 0.12 |
477
+ | `youtube` | 1920x1080 (16:9) | 43200 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
478
+ | `x` | 1280x720 (16:9) | 140 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
479
+ | `linkedin` | 1080x1080 (1:1) | 600 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
480
+ | `facebook` | 1920x1080 (16:9) | 14400 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
481
+ | `podcast` | audio only | — | -16 LUFS / -1 dBTP | 0.00 | 0.00 | 0.00 | 0.00 |
482
+
483
+ `podcast` is audio: silence trim, −16 LUFS / −1 dBTP, chapter markers when `--chapters` is given,
484
+ and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
485
+ linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) into
486
+ `<stem>_<platform>.mp4`, runs each platform's check and writes `<stem>_pack.md` with one row per
487
+ destination; `report.py --pack <stem>_pack.md` renders that table as a single HTML page. A pack
488
+ whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
489
+
433
490
  ### scenes.py — scene changes and highlight candidates
434
491
  ```
435
492
  scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
@@ -446,7 +503,7 @@ and why (energy, scene length).
446
503
 
447
504
  ### check.py — pre-delivery compliance
448
505
  ```
449
- check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
506
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--json]
450
507
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
451
508
  ```
452
509
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -458,7 +515,11 @@ line, not FAIL: name the platform when the file is a delivery for it.
458
515
  stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
459
516
  (PASS when the container carries at least one marker, WARN `none` otherwise —
460
517
  write them with `metadata.py --chapters`). Neither can FAIL a delivery, and
461
- neither appears for another platform.
518
+ neither appears for another platform. Since 1.14 the per-platform numbers (duration, aspects,
519
+ minimum height, fps, codecs, size, LUFS, true peak, SDR-only) come from the one delivery table
520
+ in `scripts/_platforms.py`, which `export.py` and the `render.py` templates read too -- so the
521
+ loudness a preset normalises to and the loudness this tool checks are the same value by
522
+ construction, not by two lists agreeing.
462
523
 
463
524
  ### batch.py — same recipe over a folder, cached
464
525
  ```
@@ -486,18 +547,50 @@ Inside this skill, call the scripts directly; the server is for other hosts.
486
547
 
487
548
  ### graphics.py — motion-graphics templates
488
549
  ```
489
- graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
490
- [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX] [-o OUT]
550
+ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|sticker|hook|meme
551
+ [--name] [--title] [--subtitle] [--text] [--top] [--bottom] [--duration 3]
552
+ [--from N] [--start S] [--end E] [--position CORNER] [--margin PX] [--platform NAME]
553
+ [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX]
554
+ [--text-render auto|ass|drawtext] [--write-ass OUT.ass]
555
+ [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
491
556
  ```
492
557
  Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
493
558
  the frame's short side; colours, font and safe margin come from `--brand`.
494
559
  Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
495
560
  Non-Latin `--name`/`--title`/`--subtitle` text picks a font file by script the
496
561
  same way `caption.py` does (`--lang XX` disambiguates Han-only text; no font for
497
- the script fails the job). RTL shaping in drawtext depends on the ffmpeg build
498
- (`--enable-libfribidi`/`--enable-libharfbuzz` shape it correctly, a build without
499
- them does not); `caption.py` always shapes, because it renders through libass:
500
- `references/gotchas.md#fonts-by-script`.
562
+ the script fails the job). Arabic and Hebrew are already correct through drawtext
563
+ on a build with `--enable-libfribidi` (bidi + joining). What drawtext cannot do on
564
+ any build is reorder and re-cluster — Devanagari matras, Thai/Lao mark stacking
565
+ because it does not use harfbuzz. Since 1.15 `--text-render auto` (the default)
566
+ therefore routes those scripts through libass: the template's geometry is written
567
+ as a generated `<output>_gfx.ass` (`--write-ass PATH` names it) and burned with
568
+ `ass=`, reported as `text_renderer: "ass"` with `script` and `ass` in the JSON.
569
+ Latin/CJK/Arabic frames are pixel-identical to 1.14 (the drawtext command
570
+ line is not: since 1.15 every drawn label is passed as `textfile=<tmp>:expansion=none`
571
+ rather than `text=`, so a `--dry-run` compared against 1.14 differs by design). `--text-render ass` forces the
572
+ route; `--text-render drawtext` with a shaping script is refused by name rather
573
+ than rendering a wrong frame. See `references/gotchas.md#fonts-by-script`.
574
+ `--emoji*` works as on `caption.py` below; a template whose text is *only* emoji
575
+ and that this machine can draw none of is `kind: input`, because that frame would
576
+ be blank. `--emoji none` strips the clusters from the drawn text, and a job whose
577
+ emoji would fall to `mode: mono` is routed through libass (which has a font
578
+ fallback chain) instead of drawtext (which does not, and would draw an empty box).
579
+
580
+ Every drawn label goes to drawtext as `textfile=<path>:expansion=none`. The file
581
+ is UTF-8, mode 0600, in a private per-run temp directory created with
582
+ `tempfile.mkdtemp()`, written only when the command that names it actually runs
583
+ (so `--dry-run` and the ASS route write nothing) and removed when the process
584
+ ends. A plan printed by `--dry-run` therefore names a path that does not exist.
585
+
586
+ All three are usable from a `render.py` project too: a `graphics[]` entry takes `text`, `top`,
587
+ `bottom`, `duration`, `margin` and `platform` alongside the older keys.
588
+ 1.14 adds three social templates: `sticker` (`--text`, a filled chip that pops in at
589
+ `--position`), `hook` (`--title --duration 3`, the full-width opening card with a thin progress
590
+ bar along the top that empties as the card's time runs out) and `meme` (`--top` / `--bottom`,
591
+ upper-case white with a heavy black outline). `--platform NAME` takes each edge's margin from
592
+ that destination's safe zone (see "Delivery templates" above), so a sticker stays off TikTok's
593
+ like column; `--margin PX` sets all four edges and wins over `--platform`.
501
594
 
502
595
  ### brand.json — one file for fonts, colours, logo, margins
503
596
  ```json
@@ -555,9 +648,13 @@ seen before, and fix or report what fails.
555
648
  look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
556
649
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
557
650
  look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
651
+ look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
558
652
  ```
559
653
  Outputs PNG. View it with the Read tool (or any image viewer) and judge the
560
- frame like an editor would. Use `--compare` to show before/after to the user.
654
+ frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
655
+ destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
656
+ chrome -- on the sheet or the frame, so "is the caption readable" can be answered about the app
657
+ rather than about the file.
561
658
 
562
659
  ### caption.py — subtitles (static, animated, karaoke)
563
660
  ```
@@ -566,7 +663,8 @@ caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
566
663
  [--max-lines N] [--min-duration S]
567
664
  [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
568
665
  [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
569
- [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
666
+ [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass]
667
+ [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
570
668
  caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
571
669
  ```
572
670
  Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`,
@@ -580,7 +678,18 @@ word from `--color` to `--highlight-color` across the cue; `--karaoke-timing
580
678
  energy` (default) follows the speech loudness in the audio, `even` splits the
581
679
  cue equally (word timing is derived, not transcribed). The ASS is kept next to the
582
680
  user can hand-tune timings and re-run with `--ass`.
583
- Readable by default (1.12): every cue is wrapped to the safe area (90 % of the
681
+ Emoji (1.15): `--emoji-assets DIR` is a directory of PNGs named by code point
682
+ (`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji/Noto
683
+ convention), also read from `brand.json` `styles.caption.emoji_assets` and
684
+ `FFMPEG_SKILL_EMOJI_ASSETS`. With one, the cue text keeps its place in the ASS
685
+ with an invisible placeholder reserving the emoji's box and each PNG is
686
+ composited on top (`--emoji-scale` sizes the box, `--emoji-max` caps the count).
687
+ Without one the run still succeeds and says `emoji: {"mode": "mono"}` plus a
688
+ warning; `--emoji none` strips them; `--emoji color` insists on a colour-capable
689
+ libass and refuses otherwise. Nothing is ever downloaded. What this machine can
690
+ do: `doctor --json` → `.fonts.emoji`. Details: `references/gotchas.md#emoji`.
691
+
692
+ Readable by default (1.12, rebalanced in 1.15): every cue is wrapped to the safe area (90 % of the
584
693
  frame width) at the chosen `--size`, measured per script — CJK and Thai count a
585
694
  full em per character, Latin per character from a table read off DejaVu Sans (so
586
695
  an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
@@ -634,9 +743,13 @@ stream selection would have picked.
634
743
  ### overlay.py — logo, image, title, video picture-in-picture, chroma key
635
744
  ```
636
745
  overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
746
+ [--emoji auto|mono|none] [--emoji-assets DIR]
637
747
  | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
638
- [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
748
+ [--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
639
749
  ```
750
+ `--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
751
+ (`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
752
+ sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
640
753
  Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
641
754
  playing. `--video` composites a second video as a picture-in-picture layer
642
755
  (same position/scale/opacity/time-range knobs as `--image`); only the main
@@ -646,6 +759,14 @@ input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
646
759
  `--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
647
760
  only at `--end`, so a logo with no `--end` stays to the last frame.
648
761
 
762
+ Since 1.15 the drawn text goes to drawtext in a **file** (`textfile=`,
763
+ `expansion=none`), so `'` and `%` survive verbatim — `--text "it's 100% done"`
764
+ used to lose both. `overlay.py` still draws through drawtext, which cannot shape
765
+ Devanagari/Thai-class scripts and cannot load a colour emoji font: a shaping
766
+ script is refused by name pointing at `caption.py`/`graphics.py`, and
767
+ `--emoji png|color` is refused the same way (`--emoji mono`, the default here,
768
+ draws whatever glyph the text font has; `--emoji none` strips them).
769
+
649
770
  ### sync.py — offset detection, alignment, drift correction
650
771
  ```
651
772
  sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
@@ -772,7 +893,8 @@ integrated loudness ended more than 1 LU from the target because of it.
772
893
 
773
894
  ### export.py — delivery presets
774
895
  ```
775
- export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
896
+ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
897
+ [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
776
898
  export.py --list
777
899
  ```
778
900
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
@@ -784,6 +906,15 @@ that fixes it -- or pass `--normalize`, which runs that call on the written
784
906
  file itself (audio re-encoded, video copied; `loudness.normalized: true`) so a
785
907
  platform export is one command instead of export, loudness, export again.
786
908
 
909
+ Since 1.14 each social destination is its own preset rather than an alias: `tiktok`
910
+ (1080x1920, max 600 s), `shorts` (1080x1920, max 180 s), `reels` (1080x1920, max 90 s),
911
+ `linkedin` (1080x1080), `facebook` (1920x1080), each with its platform's loudness spec from the
912
+ same table `check.py` reads, so `--normalize` and the check agree. `youtube-hdr` writes HEVC
913
+ Main10 keeping the source's own HDR10/HLG tags and refuses an SDR source (`kind: input`, hinting
914
+ at `--preset youtube`) rather than labelling SDR as HDR; `youtube-av1` encodes AV1 with
915
+ SVT-AV1 (libaom fallback) and refuses with `kind: missing_tool` on an ffmpeg built with
916
+ neither.
917
+
787
918
  ### proxy.py — low-bitrate proxy for analysis/preview
788
919
  ```
789
920
  proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]