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.
- package/README.md +68 -29
- package/SKILL.md +44 -39
- package/bin/install.js +1 -1
- package/docs/contract.md +51 -9
- package/package.json +4 -2
- package/references/gotchas.md +15 -0
- package/references/scripts.md +98 -9
- package/scripts/_common.py +6 -3
- package/scripts/_contract.py +8 -5
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +17 -1
- package/scripts/check.py +13 -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/overlay.py +37 -13
- package/scripts/render.py +313 -29
- 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/references/gotchas.md
CHANGED
|
@@ -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
|
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
|
```
|
|
@@ -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
|
|
490
|
-
[--
|
|
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
|
|
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]
|
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],
|
|
@@ -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)"},
|
|
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)
|
package/scripts/caption.py
CHANGED
|
@@ -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=
|
|
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")
|