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/README.md +72 -32
- package/SKILL.md +51 -43
- package/bin/install.js +1 -1
- package/docs/contract.md +66 -9
- package/package.json +4 -2
- package/references/gotchas.md +23 -0
- package/references/scripts.md +160 -14
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +17 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +17 -1
- package/scripts/check.py +28 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +93 -8
- package/scripts/look.py +35 -0
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +37 -13
- package/scripts/render.py +376 -39
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.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.
|
|
92
|
-
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.
|
|
91
|
+
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.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.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
},
|
package/references/gotchas.md
CHANGED
|
@@ -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
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
468
|
-
[--
|
|
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]
|
|
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`
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
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]
|
package/scripts/_common.py
CHANGED
|
@@ -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}"
|
package/scripts/_contract.py
CHANGED
|
@@ -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)"},
|
|
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": "
|
|
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"]
|