ffmpeg-skill 1.12.0 → 1.14.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.12.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.14.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.12.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.12.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.12.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.12.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.12.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.14.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.14.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.14.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.14.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.14.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.12.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.12.0", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.14.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.14.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.12.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.14.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>`,
@@ -354,6 +394,21 @@ runs a shell, evaluates strings, or executes anything other than the named scrip
354
394
 
355
395
  ## JSON output
356
396
 
397
+ Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
398
+ `--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
399
+ `--music` the `music_volume` plus a `duck` object naming the threshold (dB and
400
+ linear), ratio, attack and release actually used, or `null` when `--duck` was not
401
+ given. `loudness.py` reports `measured` (the input's loudnorm measurement,
402
+ including `input_lra`) and `targets` (the requested lufs / tp / lra).
403
+ `check.py --platform podcast` adds two informational rows to `checks`,
404
+ `channels` and `chapters`.
405
+
406
+ The MCP `audio` tool publishes `voice` as `{"type": "string", "enum": ["light",
407
+ "medium", "strong"]}`. A client that still sends the 1.12 boolean `{"voice":
408
+ true}` keeps working: `true` emits the bare `--voice`, which is `medium` — the
409
+ chain the flag has always produced. Send the string when you can; the boolean
410
+ is accepted at runtime and means `medium`.
411
+
357
412
  Success (`exit 0`): one document matching `output_schema`, always with
358
413
  `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
359
414
  file was written. `probe` prints its measurement document directly.
@@ -468,6 +523,8 @@ ffmpeg-skill contains no agent-specific code.
468
523
 
469
524
  ## Where things live
470
525
 
526
+ - `scripts/_platforms.py`: the delivery table (destinations, specs, safe zones) read by check/export/render/caption/graphics/look
527
+ - `templates/*.json`: the shipped delivery templates `render.py --template NAME` fills
471
528
  - `scripts/_contract.py`: the generator (`--json`, `--static`, `doctor`)
472
529
  - `bin/install.js`: `ffmpeg-skill contract` and `ffmpeg-skill doctor`
473
530
  - `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.12.0",
3
+ "version": "1.14.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
  },
@@ -74,6 +74,14 @@ clip measured at -40 LUFS or below is room tone, wind or nothing; raising it
74
74
  25 dB raises the noise, not the content. Leave the level, say so, and offer music
75
75
  or narration.
76
76
 
77
+ Do not add a speech gate in front of the measurement either: `loudnorm`'s EBU
78
+ R128 integrated loudness already applies the −70 LUFS absolute and −10 LU
79
+ relative gates, which drop the same quiet blocks a `silencedetect` pass would.
80
+ A speech-span gate was measured against the whole-file measurement on every
81
+ fixture in the repo, including one that is half digital silence, and moved the
82
+ result by at most 0.6 LU — inside `check.py`'s own ±1 LU tolerance — for the
83
+ cost of a second full decode. That is why `loudness.py` has no speech-gate flag.
84
+
77
85
  ## Text and framing
78
86
 
79
87
  ### Captions, fonts and text order
@@ -150,6 +158,21 @@ visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
150
158
  trim it drops two thirds of the words. Ask which, or propose a highlight cut with
151
159
  `scenes.py`.
152
160
 
161
+ ### Platform safe zones
162
+ Every vertical app draws its own UI over the delivery: TikTok covers roughly the
163
+ bottom 22 % (description and caption block), the right 14 % (like/comment/share
164
+ column) and the top 10 % (status bar and tabs); Reels 20/12/8 %; Shorts 18/12/6 %.
165
+ The feed destinations (YouTube, X, LinkedIn, Facebook) have no persistent overlay
166
+ and use the conventional 5 % title-safe border instead. A file can pass every
167
+ `check.py` row and still be unreadable because the caption sits under the
168
+ description.
169
+
170
+ The fractions live in one table (`scripts/_platforms.py`). `caption.py --platform
171
+ NAME`, `graphics.py --platform NAME` and `overlay.py --platform NAME` take their
172
+ margins from it, `render.py --template NAME` passes it to all three, and
173
+ `look.py --safe tiktok` shades the zones on a frame or contact sheet so you can
174
+ see what the app covers. An explicit `--margin` always wins.
175
+
153
176
  ### Dimensions and rotation
154
177
  `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
155
178
  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
  ```
@@ -404,8 +411,25 @@ so the rendered file meets the platform's loudness without a separate pass. Sinc
404
411
  1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
405
412
  and the project has no `loudness` stage; `"normalize": false` opts out.
406
413
 
414
+ `"audio": {"stems": {"dialogue": -2, "music": -18, "effects": -24}}` names one
415
+ level per element of the mix: `dialogue` is the main track's gain, `music` the
416
+ bed's level, `effects` the level of the third file `"audio": {"effects":
417
+ "sfx.wav"}` adds (never ducked). Each maps to the flag of the same meaning
418
+ (`--gain`, `--music-volume`, `--effects-volume`); an explicit flag next to a
419
+ stem wins, and a stems level with no file to apply it to (`effects` without
420
+ `"effects"`, `music` without `"music"`) is refused, `kind: input`.
421
+ `"audio": {"voice": "light"|"medium"|"strong"}` picks the voice strength
422
+ (`true` is `medium`).
423
+
424
+ `"chapters"` is a chapters file path, or an inline list of `{"at": TIME,
425
+ "title": STR}`; it runs `metadata.py` on the delivered file as the last stage
426
+ before `check`, so the markers are in the file that ships (streams copied). The
427
+ entries and the file path are validated before the first stage runs, and the
428
+ stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
429
+ other stage, so the plan lists `chapters` and the run does the same work.
430
+
407
431
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
408
- captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
432
+ captions → graphics → overlays → audio → loudness → export → chapters → check. Keys mirror the
409
433
  CLI flags of each script (see the docstring); a key `render.py` does not read -- at
410
434
  the top level or in any stage/clip object -- is refused (`kind: input`) naming the
411
435
  key and the nearest valid one, never silently ignored. Use it whenever an edit has
@@ -413,6 +437,56 @@ more than two steps or the user is likely to ask for changes: edit the JSON,
413
437
  re-render, and the result is reproducible. `--dry-run --json` prints the
414
438
  complete command plan for review.
415
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
+
416
490
  ### scenes.py — scene changes and highlight candidates
417
491
  ```
418
492
  scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
@@ -429,7 +503,7 @@ and why (energy, scene length).
429
503
 
430
504
  ### check.py — pre-delivery compliance
431
505
  ```
432
- 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]
433
507
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
434
508
  ```
435
509
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -437,6 +511,15 @@ step before reporting a deliverable; fix FAILs, mention WARNs. Without
437
511
  `--platform` the youtube spec is assumed and the judgement rows (duration,
438
512
  aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
439
513
  line, not FAIL: name the platform when the file is a delivery for it.
514
+ `--platform podcast` adds two informational rows: `channels` (PASS for mono or
515
+ stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
516
+ (PASS when the container carries at least one marker, WARN `none` otherwise —
517
+ write them with `metadata.py --chapters`). Neither can FAIL a delivery, and
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.
440
523
 
441
524
  ### batch.py — same recipe over a folder, cached
442
525
  ```
@@ -464,8 +547,10 @@ Inside this skill, call the scripts directly; the server is for other hosts.
464
547
 
465
548
  ### graphics.py — motion-graphics templates
466
549
  ```
467
- graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
468
- [--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] [-o OUT]
469
554
  ```
470
555
  Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
471
556
  the frame's short side; colours, font and safe margin come from `--brand`.
@@ -477,6 +562,15 @@ the script fails the job). RTL shaping in drawtext depends on the ffmpeg build
477
562
  them does not); `caption.py` always shapes, because it renders through libass:
478
563
  `references/gotchas.md#fonts-by-script`.
479
564
 
565
+ All three are usable from a `render.py` project too: a `graphics[]` entry takes `text`, `top`,
566
+ `bottom`, `duration`, `margin` and `platform` alongside the older keys.
567
+ 1.14 adds three social templates: `sticker` (`--text`, a filled chip that pops in at
568
+ `--position`), `hook` (`--title --duration 3`, the full-width opening card with a thin progress
569
+ bar along the top that empties as the card's time runs out) and `meme` (`--top` / `--bottom`,
570
+ upper-case white with a heavy black outline). `--platform NAME` takes each edge's margin from
571
+ that destination's safe zone (see "Delivery templates" above), so a sticker stays off TikTok's
572
+ like column; `--margin PX` sets all four edges and wins over `--platform`.
573
+
480
574
  ### brand.json — one file for fonts, colours, logo, margins
481
575
  ```json
482
576
  {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
@@ -533,9 +627,13 @@ seen before, and fix or report what fails.
533
627
  look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
534
628
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
535
629
  look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
630
+ look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
536
631
  ```
537
632
  Outputs PNG. View it with the Read tool (or any image viewer) and judge the
538
- frame like an editor would. Use `--compare` to show before/after to the user.
633
+ frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
634
+ destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
635
+ chrome -- on the sheet or the frame, so "is the caption readable" can be answered about the app
636
+ rather than about the file.
539
637
 
540
638
  ### caption.py — subtitles (static, animated, karaoke)
541
639
  ```
@@ -613,8 +711,11 @@ stream selection would have picked.
613
711
  ```
614
712
  overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
615
713
  | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
616
- [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
714
+ [--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
617
715
  ```
716
+ `--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
717
+ (`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
718
+ sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
618
719
  Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
619
720
  playing. `--video` composites a second video as a picture-in-picture layer
620
721
  (same position/scale/opacity/time-range knobs as `--image`); only the main
@@ -684,13 +785,42 @@ first on HDR or Log sources.
684
785
 
685
786
  ### audio.py — clean-up, music, ducking, layout
686
787
  ```
687
- audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
688
- [--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
788
+ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25]] [--gain dB]
789
+ [--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
790
+ [--duck-attack 20] [--duck-release 400]] [--music-loop]]
791
+ [--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
689
792
  [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
690
793
  ```
691
- `--voice` = highpass 80 Hz de-esser FFT denoise gentle compressor, the
692
- standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
693
- speech so music dips under dialogue and swells in pauses. `--downmix` uses the
794
+ `--voice` takes a strength; a bare `--voice` is `medium`, the chain it has always
795
+ produced. The exact filter chains:
796
+
797
+ | level | chain |
798
+ | --- | --- |
799
+ | `light` | `highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1` |
800
+ | `medium` | `highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2` |
801
+ | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled` |
802
+
803
+ `light` for a good room (rumble and level only, noise floor and sibilance left
804
+ alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
805
+ `strong` is the only level with a limiter, so it is the only one whose peaks
806
+ stop at −1 dBFS: `medium` (the default, and the chain a bare `--voice` gets) can
807
+ clip a hot source, since its make-up gain has nothing above it — normalise
808
+ afterwards with `loudness.py`, or use `strong`, which measures quieter and safer.
809
+ `--duck` uses a sidechain compressor keyed by the speech so music dips under
810
+ dialogue and swells in pauses:
811
+ `sidechaincompress=threshold=0.05:ratio=<amount/3, min 2>:attack=20:release=400:makeup=1`
812
+ by default. `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
813
+ `--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
814
+ threshold ducks on quieter speech, a shorter release brings the bed back faster.
815
+ `--json`'s `audio` block reports the settings the run actually used.
816
+ `--effects FILE` mixes a third track (sound effects, atmos) at
817
+ `--effects-volume` and is never ducked — effects are cut to the picture.
818
+ `--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
819
+ needs a real stereo source: it scales the side signal (L−R), so a mono track
820
+ duplicated to two channels has nothing to scale. A 1-channel input is refused
821
+ (`kind: input`) — `--stereo` duplicates it but does not widen it — and more than
822
+ two channels are refused unless `--downmix` is given too, in which case the
823
+ widening runs on the stereo fold-down. `--downmix` uses the
694
824
  ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
695
825
  leaves a 1-channel input untouched and downmixes >2 channels through
696
826
  swresample. Video is always stream-copied, and so is a subtitle/data track
@@ -701,6 +831,12 @@ Run `loudness.py` after this for final levels.
701
831
  ```
702
832
  loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
703
833
  ```
834
+ `--lra N` is the loudness-range target in LU (default 11): lower it to squeeze a
835
+ wide-dynamic mix into a phone speaker, raise it to leave a film mix alone.
836
+ `--json` reports the measured range on both sides — `measured.input_lra` for the
837
+ input, `result.input_lra` for the written file, with `targets` echoing the
838
+ requested lufs / tp / lra.
839
+
704
840
  Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
705
841
  the true-peak ceiling allows). Video and any subtitle/data track are
706
842
  stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
@@ -715,7 +851,8 @@ integrated loudness ended more than 1 LU from the target because of it.
715
851
 
716
852
  ### export.py — delivery presets
717
853
  ```
718
- export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
854
+ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
855
+ [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
719
856
  export.py --list
720
857
  ```
721
858
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
@@ -727,6 +864,15 @@ that fixes it -- or pass `--normalize`, which runs that call on the written
727
864
  file itself (audio re-encoded, video copied; `loudness.normalized: true`) so a
728
865
  platform export is one command instead of export, loudness, export again.
729
866
 
867
+ Since 1.14 each social destination is its own preset rather than an alias: `tiktok`
868
+ (1080x1920, max 600 s), `shorts` (1080x1920, max 180 s), `reels` (1080x1920, max 90 s),
869
+ `linkedin` (1080x1080), `facebook` (1920x1080), each with its platform's loudness spec from the
870
+ same table `check.py` reads, so `--normalize` and the check agree. `youtube-hdr` writes HEVC
871
+ Main10 keeping the source's own HDR10/HLG tags and refuses an SDR source (`kind: input`, hinting
872
+ at `--preset youtube`) rather than labelling SDR as HDR; `youtube-av1` encodes AV1 with
873
+ SVT-AV1 (libaom fallback) and refuses with `kind: missing_tool` on an ffmpeg built with
874
+ neither.
875
+
730
876
  ### proxy.py — low-bitrate proxy for analysis/preview
731
877
  ```
732
878
  proxy.py INPUT [--width W | --scale F] [--crf N] [--fps N] [--no-audio] [-o OUT]
@@ -140,7 +140,7 @@ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
140
140
  return str(max(vertical, horizontal))
141
141
 
142
142
 
143
- def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str:
143
+ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0) -> str:
144
144
  """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
145
145
 
146
146
  fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
@@ -148,14 +148,17 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str
148
148
  phone editor's "make it vertical" does with landscape footage (#139). Built as a small
149
149
  graph inside the -vf chain: split, one branch scaled to cover and cropped to the frame
150
150
  then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
151
- needed beyond the usual scale/pad set, and that is already required by redact.py."""
151
+ needed beyond the usual scale/pad set, and that is already required by redact.py.
152
+ `darken` > 0 also dims that background copy by that much brightness (eq), so the picture in
153
+ front reads as the subject instead of competing with a bright blurred copy of itself --
154
+ what `fit.py --fit blur` uses (1.14)."""
152
155
  if fill == "blur":
153
156
  # boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
154
157
  # <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
155
158
  radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
156
159
  return (f"split[__fitfg][__fitbg];"
157
160
  f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
158
- f"boxblur={radius}:2[__fitbgb];"
161
+ f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
159
162
  f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
160
163
  f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
161
164
  return f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={color}"
@@ -79,7 +79,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
79
79
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
80
80
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
81
81
  required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"},
82
- {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
82
+ {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
83
+ {"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"}],
83
84
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
84
85
  "crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
85
86
  required=FF + [X264, AAC], optional=[HDR_X265],
@@ -157,9 +158,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
157
158
  "multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
158
159
  required=FF + [X264, AAC], optional=[HDR_X265],
159
160
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
160
- "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
161
+ "audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)", "effects/atmos track (--effects)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
161
162
  required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
162
- {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"},
163
+ {"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
164
+ {"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
165
+ {"capability": "filter:extrastereo", "when": "--stereo-widen"},
163
166
  {"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
164
167
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
165
168
  "loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
@@ -185,7 +188,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
185
188
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
186
189
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
187
190
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
188
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"},
191
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
192
+ {"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
189
193
  {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
190
194
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
191
195
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
@@ -195,10 +199,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
195
199
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
196
200
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
197
201
  "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
198
- required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
202
+ required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"},
203
+ {"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
199
204
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
200
- "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)"], outputs=["final video artifact", "work directory of stage outputs (--keep / --work)"],
201
- required=FF, optional=[{"capability": "delegated", "when": "each stage runs cut / join / fit / caption / overlay / audio / loudness / export / check with their capabilities"}],
205
+ "render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)"],
206
+ required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
202
207
  video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
203
208
  "batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
204
209
  required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
@@ -411,7 +416,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
411
416
  extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file"},
412
417
  "notes": {"type": "array", "items": {"type": "string"}}}
413
418
  elif name == "loudness":
414
- extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
419
+ extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
420
+ "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
421
+ "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
415
422
  elif name == "cut":
416
423
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
417
424
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
@@ -426,7 +433,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
426
433
  elif name == "audio":
427
434
  extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
428
435
  "audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
429
- "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}}
436
+ "dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
437
+ "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
430
438
  props = dict(base)
431
439
  props.update(extra)
432
440
  required = ["status", "output", "dry_run", "commands"]