ffmpeg-skill 1.13.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.
@@ -158,6 +158,21 @@ visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
158
158
  trim it drops two thirds of the words. Ask which, or propose a highlight cut with
159
159
  `scenes.py`.
160
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
+
161
176
  ### Dimensions and rotation
162
177
  `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
163
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
  ```
@@ -430,6 +437,56 @@ more than two steps or the user is likely to ask for changes: edit the JSON,
430
437
  re-render, and the result is reproducible. `--dry-run --json` prints the
431
438
  complete command plan for review.
432
439
 
440
+ ### Delivery templates — one command per destination (1.14)
441
+ ```
442
+ render.py --template tiktok INPUT [--cues cues.txt | --srt subs.srt] [--logo logo.png] [--title "..."]
443
+ [--brand brand.json] [--chapters chapters.txt] [--fit crop|pad|blur] [-o OUT]
444
+ render.py --template all INPUT ... # or a comma list: one delivery per destination + <stem>_pack.md
445
+ render.py --list-templates # the table below, from the running install
446
+ render.py --template tiktok INPUT --write-project project.json # fill it, edit it, render it later
447
+ ```
448
+ A template is a `render.py` project shipped in `templates/<name>.json` with `$INPUT`, `$OUTPUT`,
449
+ `$CUES`/`$SRT`, `$LOGO`, `$TITLE`, `$BRAND` and `$CHAPTERS` placeholders. Filling it substitutes
450
+ what the run was given and **drops any block whose placeholder has no value** — no `--logo` means
451
+ no overlay stage at all, not an overlay of nothing. The filled project then renders through the
452
+ normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
453
+ unknown name is refused (`kind: input`) with the list. Output defaults to
454
+ `<input>_<template>.mp4` **next to the input** (`.m4a` for an audio-only destination such as
455
+ `podcast`) — the same rule for one template and for a pack, whose `-o` names the directory.
456
+ Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts` = `shorts`,
457
+ `yt` = `youtube`, `instagram`/`ig` = `reels`, `twitter` = `x`, `fb` = `facebook`
458
+ (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
459
+ --platform`, `look.py --safe`, `render.py --template`).
460
+
461
+ Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
462
+ with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
463
+ reaches a pack's audio destination like it does the single-template form.
464
+
465
+ Each template's frame, duration limit, loudness target and safe zones come from the one delivery
466
+ table (`scripts/_platforms.py`). Safe zones are the fraction of the frame the app's own UI covers;
467
+ the template places captions, graphics **and the `--logo` overlay** clear of them, and
468
+ `caption.py --platform`, `graphics.py --platform` and `overlay.py --platform` apply them to a
469
+ hand-built step:
470
+
471
+ | template | frame | max duration | loudness | safe top | safe bottom | safe left | safe right |
472
+ |---|---|---|---|---|---|---|---|
473
+ | `tiktok` | 1080x1920 (9:16) | 600 s | -14 LUFS / -1 dBTP | 0.10 | 0.22 | 0.05 | 0.14 |
474
+ | `reels` | 1080x1920 (9:16) | 90 s | -14 LUFS / -1 dBTP | 0.08 | 0.20 | 0.05 | 0.12 |
475
+ | `shorts` | 1080x1920 (9:16) | 180 s | -14 LUFS / -1 dBTP | 0.06 | 0.18 | 0.05 | 0.12 |
476
+ | `youtube-shorts` | 1080x1920 (9:16) | 180 s | -14 LUFS / -1 dBTP | 0.06 | 0.18 | 0.05 | 0.12 |
477
+ | `youtube` | 1920x1080 (16:9) | 43200 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
478
+ | `x` | 1280x720 (16:9) | 140 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
479
+ | `linkedin` | 1080x1080 (1:1) | 600 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
480
+ | `facebook` | 1920x1080 (16:9) | 14400 s | -14 LUFS / -1 dBTP | 0.05 | 0.05 | 0.05 | 0.05 |
481
+ | `podcast` | audio only | — | -16 LUFS / -1 dBTP | 0.00 | 0.00 | 0.00 | 0.00 |
482
+
483
+ `podcast` is audio: silence trim, −16 LUFS / −1 dBTP, chapter markers when `--chapters` is given,
484
+ and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
485
+ linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) into
486
+ `<stem>_<platform>.mp4`, runs each platform's check and writes `<stem>_pack.md` with one row per
487
+ destination; `report.py --pack <stem>_pack.md` renders that table as a single HTML page. A pack
488
+ whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
489
+
433
490
  ### scenes.py — scene changes and highlight candidates
434
491
  ```
435
492
  scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
@@ -446,7 +503,7 @@ and why (energy, scene length).
446
503
 
447
504
  ### check.py — pre-delivery compliance
448
505
  ```
449
- check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
506
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--json]
450
507
  [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
451
508
  ```
452
509
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -458,7 +515,11 @@ line, not FAIL: name the platform when the file is a delivery for it.
458
515
  stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
459
516
  (PASS when the container carries at least one marker, WARN `none` otherwise —
460
517
  write them with `metadata.py --chapters`). Neither can FAIL a delivery, and
461
- neither appears for another platform.
518
+ neither appears for another platform. Since 1.14 the per-platform numbers (duration, aspects,
519
+ minimum height, fps, codecs, size, LUFS, true peak, SDR-only) come from the one delivery table
520
+ in `scripts/_platforms.py`, which `export.py` and the `render.py` templates read too -- so the
521
+ loudness a preset normalises to and the loudness this tool checks are the same value by
522
+ construction, not by two lists agreeing.
462
523
 
463
524
  ### batch.py — same recipe over a folder, cached
464
525
  ```
@@ -486,8 +547,10 @@ Inside this skill, call the scripts directly; the server is for other hosts.
486
547
 
487
548
  ### graphics.py — motion-graphics templates
488
549
  ```
489
- graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
490
- [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX] [-o OUT]
550
+ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug|sticker|hook|meme
551
+ [--name] [--title] [--subtitle] [--text] [--top] [--bottom] [--duration 3]
552
+ [--from N] [--start S] [--end E] [--position CORNER] [--margin PX] [--platform NAME]
553
+ [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX] [-o OUT]
491
554
  ```
492
555
  Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
493
556
  the frame's short side; colours, font and safe margin come from `--brand`.
@@ -499,6 +562,15 @@ the script fails the job). RTL shaping in drawtext depends on the ffmpeg build
499
562
  them does not); `caption.py` always shapes, because it renders through libass:
500
563
  `references/gotchas.md#fonts-by-script`.
501
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
+
502
574
  ### brand.json — one file for fonts, colours, logo, margins
503
575
  ```json
504
576
  {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
@@ -555,9 +627,13 @@ seen before, and fix or report what fails.
555
627
  look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
556
628
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
557
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
558
631
  ```
559
632
  Outputs PNG. View it with the Read tool (or any image viewer) and judge the
560
- frame like an editor would. Use `--compare` to show before/after to the user.
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.
561
637
 
562
638
  ### caption.py — subtitles (static, animated, karaoke)
563
639
  ```
@@ -635,8 +711,11 @@ stream selection would have picked.
635
711
  ```
636
712
  overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
637
713
  | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
638
- [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
714
+ [--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
639
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.
640
719
  Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
641
720
  playing. `--video` composites a second video as a picture-in-picture layer
642
721
  (same position/scale/opacity/time-range knobs as `--image`); only the main
@@ -772,7 +851,8 @@ integrated loudness ended more than 1 LU from the target because of it.
772
851
 
773
852
  ### export.py — delivery presets
774
853
  ```
775
- 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]
776
856
  export.py --list
777
857
  ```
778
858
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
@@ -784,6 +864,15 @@ that fixes it -- or pass `--normalize`, which runs that call on the written
784
864
  file itself (audio re-encoded, video copied; `loudness.normalized: true`) so a
785
865
  platform export is one command instead of export, loudness, export again.
786
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
+
787
876
  ### proxy.py — low-bitrate proxy for analysis/preview
788
877
  ```
789
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],
@@ -187,7 +188,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
187
188
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
188
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)"},
189
190
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
190
- {"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)"},
191
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`)"}],
192
194
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
193
195
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
@@ -197,10 +199,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
197
199
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
198
200
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
199
201
  "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
200
- 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"}],
201
204
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
202
- "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)"],
203
- 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"}],
204
207
  video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
205
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"],
206
209
  required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env python3
2
+ """One table per delivery destination (internal module, not a tool).
3
+
4
+ Before 1.14 the same destination was described three times: check.py held the
5
+ compliance spec (duration, aspect, codecs, loudness), export.py held the frame and
6
+ encoder settings, and nothing at all held the part of the frame the app's own UI
7
+ covers. A TikTok export therefore passed every check while its captions sat under
8
+ the description bar. This module is the single table the delivery tools read:
9
+
10
+ PLATFORMS[name] = {
11
+ "frame": {"w", "h", "aspect"} or None (audio-only destinations),
12
+ "fps": the frame rate a delivery is conformed to (None = leave alone),
13
+ "spec": check.py's compliance row values (max_duration, aspects,
14
+ min_height, fps_max, codecs, max_bytes, lufs, lufs_tol, tp,
15
+ sdr_only) -- the keys check.py's SPECS has always had,
16
+ "safe": the fraction of the frame each edge's UI covers
17
+ (top/bottom/left/right, 0..1) -- nothing readable goes there,
18
+ "caption": the caption defaults a template uses (size as a fraction of the
19
+ frame height, position, box, outline, animate),
20
+ "preset": the export.py preset that writes this destination's file,
21
+ "check": the check.py platform name a delivery is verified against.
22
+ }
23
+
24
+ Read by check.py (SPECS), export.py (PRESETS/PLATFORM_OF), render.py (templates),
25
+ caption.py and graphics.py (--platform margins) and look.py (--safe).
26
+
27
+ Safe zones are the app's own overlay, measured from each platform's published
28
+ design guidance: TikTok's caption/description block and the like/comment column,
29
+ Instagram's Reels UI, the Shorts player. They are deliberately generous -- a
30
+ caption 2 % too high is readable, a caption under the share button is not. The
31
+ feed destinations (YouTube, X, LinkedIn, Facebook) have no persistent overlay, so
32
+ they carry the conventional 5 % title-safe border instead.
33
+
34
+ ASS note: caption.py's --size and --margin are in the 288-line ASS script grid, so
35
+ a fraction of the frame height is that fraction * 288 (ass_units() below); the
36
+ burn scales it back to the real frame. graphics.py and look.py work in pixels.
37
+ """
38
+ from typing import Any, Dict, List, Optional
39
+
40
+ ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid
41
+
42
+
43
+ def ass_units(fraction: float) -> int:
44
+ """A fraction of the frame height as a caption.py --size / --margin value."""
45
+ return int(round(fraction * ASS_SCRIPT_HEIGHT))
46
+
47
+
48
+ # The edges a feed destination reserves: no app chrome, just the conventional title-safe border.
49
+ _SAFE_5 = {"top": 0.05, "bottom": 0.05, "left": 0.05, "right": 0.05}
50
+ _NO_SAFE = {"top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0}
51
+
52
+ # caption defaults: size is a fraction of the frame height (0.0833 = the 24 that every
53
+ # vertical job in this repo has used since 1.2), position/box/outline/animate as the
54
+ # caption.py flags of the same name.
55
+ _CAP_VERTICAL = {"size": 0.0833, "position": "bottom", "box": False, "outline": 2, "animate": "pop"}
56
+ _CAP_WIDE = {"size": 0.0694, "position": "bottom", "box": False, "outline": 2, "animate": "none"}
57
+
58
+ PLATFORMS: Dict[str, Dict[str, Any]] = {
59
+ "tiktok": {
60
+ "title": "TikTok",
61
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
62
+ "fps": 30,
63
+ "spec": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
64
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
65
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
66
+ # the description/caption block along the bottom, the like/comment/share column on the
67
+ # right, the status bar and the "Following | For You" tabs at the top
68
+ "safe": {"top": 0.10, "bottom": 0.22, "left": 0.05, "right": 0.14},
69
+ "caption": _CAP_VERTICAL,
70
+ "preset": "tiktok", "check": "tiktok",
71
+ },
72
+ "reels": {
73
+ "title": "Instagram Reels",
74
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
75
+ "fps": 30,
76
+ "spec": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60,
77
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
78
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
79
+ "safe": {"top": 0.08, "bottom": 0.20, "left": 0.05, "right": 0.12},
80
+ "caption": _CAP_VERTICAL,
81
+ "preset": "reels", "check": "reels",
82
+ },
83
+ "shorts": {
84
+ "title": "YouTube Shorts",
85
+ "frame": {"w": 1080, "h": 1920, "aspect": "9:16"},
86
+ "fps": 30,
87
+ "spec": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60,
88
+ "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3,
89
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
90
+ "safe": {"top": 0.06, "bottom": 0.18, "left": 0.05, "right": 0.12},
91
+ "caption": _CAP_VERTICAL,
92
+ "preset": "shorts", "check": "shorts",
93
+ },
94
+ "youtube": {
95
+ "title": "YouTube",
96
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
97
+ "fps": None,
98
+ "spec": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
99
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
100
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
101
+ "safe": _SAFE_5,
102
+ "caption": _CAP_WIDE,
103
+ "preset": "youtube", "check": "youtube",
104
+ },
105
+ "youtube-hdr": {
106
+ "title": "YouTube (HDR10)",
107
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
108
+ "fps": None,
109
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
110
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
111
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
112
+ "safe": _SAFE_5,
113
+ "caption": _CAP_WIDE,
114
+ "preset": "youtube-hdr", "check": "youtube",
115
+ },
116
+ "youtube-av1": {
117
+ "title": "YouTube (AV1)",
118
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
119
+ "fps": None,
120
+ "spec": dict({"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60,
121
+ "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3,
122
+ "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False}),
123
+ "safe": _SAFE_5,
124
+ "caption": _CAP_WIDE,
125
+ "preset": "youtube-av1", "check": "youtube",
126
+ },
127
+ "x": {
128
+ "title": "X (Twitter)",
129
+ "frame": {"w": 1280, "h": 720, "aspect": "16:9"},
130
+ "fps": 30,
131
+ "spec": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60,
132
+ "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2,
133
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
134
+ "safe": _SAFE_5,
135
+ "caption": _CAP_WIDE,
136
+ "preset": "x", "check": "x",
137
+ },
138
+ "linkedin": {
139
+ "title": "LinkedIn",
140
+ "frame": {"w": 1080, "h": 1080, "aspect": "1:1"},
141
+ "fps": 30,
142
+ "spec": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
143
+ "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3,
144
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
145
+ "safe": _SAFE_5,
146
+ "caption": _CAP_WIDE,
147
+ "preset": "linkedin", "check": "linkedin",
148
+ },
149
+ "facebook": {
150
+ "title": "Facebook",
151
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
152
+ "fps": 30,
153
+ "spec": {"max_duration": 240 * 60, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60,
154
+ "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3,
155
+ "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
156
+ "safe": _SAFE_5,
157
+ "caption": _CAP_WIDE,
158
+ "preset": "facebook", "check": "facebook",
159
+ },
160
+ "podcast": {
161
+ "title": "Podcast (audio)",
162
+ "frame": None,
163
+ "fps": None,
164
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
165
+ "codecs": None, "max_bytes": None,
166
+ "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
167
+ "safe": _NO_SAFE,
168
+ "caption": _CAP_WIDE,
169
+ "preset": None, "check": "podcast",
170
+ },
171
+ # Not destinations an app owns, but compliance targets check.py has always had.
172
+ "broadcast": {
173
+ "title": "Broadcast (EBU R128)",
174
+ "frame": {"w": 1920, "h": 1080, "aspect": "16:9"},
175
+ "fps": None,
176
+ "spec": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60,
177
+ "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None,
178
+ "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
179
+ "safe": _SAFE_5,
180
+ "caption": _CAP_WIDE,
181
+ "preset": "prores", "check": "broadcast",
182
+ },
183
+ "custom": {
184
+ "title": "Custom",
185
+ "frame": None,
186
+ "fps": None,
187
+ "spec": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None,
188
+ "codecs": None, "max_bytes": None,
189
+ "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
190
+ "safe": _NO_SAFE,
191
+ "caption": _CAP_WIDE,
192
+ "preset": None, "check": "custom",
193
+ },
194
+ }
195
+
196
+ # The canonical destination names.
197
+ PLATFORM_NAMES: List[str] = sorted(PLATFORMS)
198
+ # The spellings people actually write for those destinations. resolve() maps them onto the
199
+ # canonical name, and every tool's --platform/--safe/--preset accepts both, so
200
+ # `--platform youtube-shorts` and `--platform shorts` are the same request everywhere.
201
+ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "yt": "youtube",
202
+ "instagram": "reels", "ig": "reels", "twitter": "x", "fb": "facebook"}
203
+ # One vocabulary for the word "platform": check.py, caption.py, graphics.py and look.py all
204
+ # offer this list (review 12 found three different ones). It is the compliance targets -- the
205
+ # destinations a delivery is checked against -- plus every alias; youtube-hdr and youtube-av1
206
+ # are export presets of the youtube target, not separate destinations, so they are not in it.
207
+ PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
208
+
209
+
210
+ def has_frame(name: str) -> bool:
211
+ """True when this destination has a frame, and therefore a safe zone to place text inside."""
212
+ return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
213
+
214
+
215
+ def spec_of(name: str) -> Dict[str, Any]:
216
+ """check.py's compliance row values for a destination."""
217
+ return dict(PLATFORMS[name]["spec"])
218
+
219
+
220
+ def safe_of(name: str) -> Dict[str, float]:
221
+ return dict(PLATFORMS[name]["safe"])
222
+
223
+
224
+ def loudness_of(name: str) -> Dict[str, float]:
225
+ s = PLATFORMS[name]["spec"]
226
+ return {"lufs": s["lufs"], "lufs_tol": s["lufs_tol"], "tp": s["tp"]}
227
+
228
+
229
+ def safe_margins_px(name: str, width: int, height: int) -> Dict[str, int]:
230
+ """The safe zone in pixels for a frame of this size, as whole pixels per edge."""
231
+ s = PLATFORMS[name]["safe"]
232
+ return {"top": int(round(s["top"] * height)), "bottom": int(round(s["bottom"] * height)),
233
+ "left": int(round(s["left"] * width)), "right": int(round(s["right"] * width))}
234
+
235
+
236
+ def caption_defaults(name: str) -> Dict[str, Any]:
237
+ """caption.py flag values for this destination: --size/--margin in ASS units."""
238
+ cap = dict(PLATFORMS[name]["caption"])
239
+ safe = PLATFORMS[name]["safe"]
240
+ edge = safe["top"] if cap["position"].startswith("top") else safe["bottom"]
241
+ cap["size"] = ass_units(cap["size"])
242
+ cap["margin"] = ass_units(edge)
243
+ return cap
244
+
245
+
246
+ def resolve(name: Optional[str]) -> Optional[str]:
247
+ """Accept the spellings people write for a destination ('youtube-shorts', 'ig')."""
248
+ if not name:
249
+ return None
250
+ key = str(name).strip().lower()
251
+ return ALIASES.get(key, key)
@@ -40,6 +40,7 @@ import unicodedata
40
40
  from pathlib import Path
41
41
  from typing import List, Optional, Tuple
42
42
 
43
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
43
44
  from _common import STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
44
45
 
45
46
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
@@ -759,7 +760,10 @@ def main() -> int:
759
760
  sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
760
761
  sty.add_argument("--bold", action="store_true")
761
762
  sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
762
- sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
763
+ sty.add_argument("--margin", type=int, default=None, help="vertical margin from the edge in ASS units (default 30, or the --platform safe zone)")
764
+ sty.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
765
+ help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
766
+ "zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
763
767
  sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
764
768
  sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
765
769
  sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
@@ -792,6 +796,18 @@ def main() -> int:
792
796
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
793
797
  args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
794
798
  args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
799
+ # --platform: the margin is the fraction of the frame that platform's own UI covers
800
+ # (scripts/_platforms.py). An explicit --margin is the more specific statement and wins;
801
+ # without either, the historical default 30 is unchanged.
802
+ # every tool resolves the spellings people write ('youtube-shorts' is 'shorts') in one place
803
+ args.platform = resolve_platform(args.platform)
804
+ if args.margin is None and args.platform and PLATFORMS[args.platform].get("frame"):
805
+ edge = PLATFORMS[args.platform]["safe"]["top" if args.position.startswith("top") else "bottom"]
806
+ args.margin = ass_units(edge)
807
+ info(f"--platform {args.platform}: caption margin {args.margin} ASS units ({edge * 100:.0f}% of the frame height, "
808
+ f"clear of the app's own UI)")
809
+ if args.margin is None:
810
+ args.margin = 30
795
811
  # a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
796
812
  # to be applied anyway and then refused as "animation is burn only" -- ignore it there
797
813
  args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")