ffmpeg-skill 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -29
- package/SKILL.md +46 -41
- package/bin/install.js +1 -1
- package/docs/contract.md +72 -10
- package/package.json +4 -2
- package/references/gotchas.md +85 -1
- package/references/scripts.md +146 -15
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common.py +596 -37
- package/scripts/_contract.py +27 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +343 -93
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +381 -20
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +72 -15
- 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/scripts/graphics.py
CHANGED
|
@@ -9,6 +9,10 @@ Templates:
|
|
|
9
9
|
progress thin progress bar along the bottom that fills over the clip (or --start/--end)
|
|
10
10
|
countdown big numbers counting down from --from to 0 (--start/--end define the window)
|
|
11
11
|
bug persistent text bug (--title) in a corner, e.g. "@handle" or "LIVE"
|
|
12
|
+
sticker rounded filled chip of --text that pops in at --position (the social sticker)
|
|
13
|
+
hook full-width opening title card (--title) for --duration seconds with a thin
|
|
14
|
+
progress bar along the top -- the TikTok/Shorts opener
|
|
15
|
+
meme white upper-case --top / --bottom lines with a heavy black outline
|
|
12
16
|
|
|
13
17
|
Examples:
|
|
14
18
|
python3 graphics.py talk.mp4 --template lower-third --name "Ada Lovelace" --title "Analyst" --start 2 --end 8
|
|
@@ -17,14 +21,27 @@ Examples:
|
|
|
17
21
|
python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
|
|
18
22
|
python3 graphics.py talk.mp4 --template lower-third --name "김민준" --title "감독" --lang ko
|
|
19
23
|
python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
|
|
24
|
+
python3 graphics.py reel.mp4 --template sticker --text "NEW" --position top-right --platform tiktok
|
|
25
|
+
python3 graphics.py reel.mp4 --template hook --title "How I cut this in one command" --duration 3
|
|
26
|
+
python3 graphics.py clip.mp4 --template meme --top "when the render" --bottom "finally finishes"
|
|
20
27
|
"""
|
|
21
28
|
import argparse
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
22
31
|
import sys
|
|
23
32
|
from typing import List, Optional
|
|
24
33
|
|
|
25
|
-
from
|
|
34
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
|
+
from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
|
+
color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
|
|
37
|
+
ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
|
|
38
|
+
drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
39
|
+
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
40
|
+
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
41
|
+
EMOJI_ASSET_HINT, text_width_em, drawtext_shaping)
|
|
42
|
+
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
26
43
|
|
|
27
|
-
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
44
|
+
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
|
|
28
45
|
|
|
29
46
|
|
|
30
47
|
def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
@@ -53,12 +70,20 @@ def main() -> int:
|
|
|
53
70
|
ap.add_argument("--template", choices=TEMPLATES, required=True)
|
|
54
71
|
ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
|
|
55
72
|
ap.add_argument("--name", help="lower-third: name line")
|
|
56
|
-
ap.add_argument("--title", help="title / chapter / bug text, or lower-third second line")
|
|
73
|
+
ap.add_argument("--title", help="title / chapter / bug / hook text, or lower-third second line")
|
|
74
|
+
ap.add_argument("--text", help="sticker: the chip's text")
|
|
75
|
+
ap.add_argument("--top", help="meme: upper line")
|
|
76
|
+
ap.add_argument("--bottom", help="meme: lower line")
|
|
77
|
+
ap.add_argument("--duration", type=float, default=3.0, help="hook: seconds the opening card stays up (default 3)")
|
|
57
78
|
ap.add_argument("--subtitle", help="title: smaller second line")
|
|
58
79
|
ap.add_argument("--from", dest="count_from", type=int, default=5, help="countdown start number (default 5)")
|
|
59
80
|
ap.add_argument("--start", help="show from (default 0)")
|
|
60
81
|
ap.add_argument("--end", help="hide after (default end of clip)")
|
|
61
|
-
ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug (default bottom-left / top-right)")
|
|
82
|
+
ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug/sticker (default bottom-left / top-right / top-right)")
|
|
83
|
+
ap.add_argument("--margin", type=int, default=None, help="distance from the frame edge in px (default: brand safe_margin, or the --platform safe zone)")
|
|
84
|
+
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
|
|
85
|
+
help="keep the graphic out of this destination's UI: margins become the platform's safe zone "
|
|
86
|
+
"(TikTok's description bar and like column, the Reels/Shorts chrome). An explicit --margin wins")
|
|
62
87
|
ap.add_argument("--primary", help="override brand primary colour RRGGBB")
|
|
63
88
|
ap.add_argument("--text-color", help="override text colour RRGGBB")
|
|
64
89
|
ap.add_argument("--font")
|
|
@@ -66,12 +91,33 @@ def main() -> int:
|
|
|
66
91
|
ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
|
|
67
92
|
"text is Chinese, Japanese or Korean when a font is picked by script")
|
|
68
93
|
ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
|
|
94
|
+
ap.add_argument("--text-render", choices=["auto", "ass", "drawtext"], default="auto",
|
|
95
|
+
help="which renderer draws the template's text: 'auto' (default) uses libass for "
|
|
96
|
+
"scripts drawtext cannot shape (Devanagari, Bengali, Tamil, Thai, Lao ...) and for "
|
|
97
|
+
"emoji overlays, and drawtext for everything else -- Latin/CJK/Arabic frames are "
|
|
98
|
+
"pixel-identical to 1.14 (the drawtext command itself changed: the label is "
|
|
99
|
+
"passed as textfile=, not text=); 'ass' always uses libass; 'drawtext' forces the old renderer and is "
|
|
100
|
+
"refused for a script it cannot shape")
|
|
101
|
+
ap.add_argument("--write-ass", metavar="PATH",
|
|
102
|
+
help="where to save the generated ASS when the libass route is used (default: <output stem>_gfx.ass)")
|
|
103
|
+
emo = ap.add_argument_group("emoji (1.15)")
|
|
104
|
+
emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
|
|
105
|
+
help="how emoji in the template text are drawn (see caption.py --emoji)")
|
|
106
|
+
emo.add_argument("--emoji-assets", metavar="DIR",
|
|
107
|
+
help="directory of emoji PNGs named by code point (1f389.png); nothing is ever downloaded")
|
|
108
|
+
emo.add_argument("--emoji-scale", type=float, default=1.0,
|
|
109
|
+
help="emoji box as a multiple of the line's font size (default 1.0)")
|
|
110
|
+
emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build (default 60)")
|
|
69
111
|
ap.add_argument("--crf", type=int, default=18)
|
|
70
112
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
71
113
|
add_common(ap)
|
|
72
114
|
args = ap.parse_args()
|
|
73
115
|
apply_common(args)
|
|
74
116
|
|
|
117
|
+
args.platform = resolve_platform(args.platform)
|
|
118
|
+
if args.platform and not PLATFORMS[args.platform].get("frame"):
|
|
119
|
+
info(f"--platform {args.platform}: this destination has no frame and no app chrome; margins unchanged")
|
|
120
|
+
args.platform = None
|
|
75
121
|
brand = load_brand(args.brand)
|
|
76
122
|
primary = color_hex(args.primary or brand["colors"]["primary"])
|
|
77
123
|
text_c = color_hex(args.text_color or brand["colors"]["text"])
|
|
@@ -85,7 +131,7 @@ def main() -> int:
|
|
|
85
131
|
args.lang = args.lang or (brand.get("lang") if args.brand else None)
|
|
86
132
|
# a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
|
|
87
133
|
_script, script_file, _family = script_font_for_text(
|
|
88
|
-
" ".join(t for t in (args.name, args.title, args.subtitle) if t),
|
|
134
|
+
" ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
|
|
89
135
|
lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
|
|
90
136
|
fo = font_opts(brand, args.font, args.font_file, script_file)
|
|
91
137
|
|
|
@@ -101,6 +147,19 @@ def main() -> int:
|
|
|
101
147
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
102
148
|
W, H = H, W
|
|
103
149
|
dur = meta.get("duration") or 0.0
|
|
150
|
+
# Per-edge margins. Without --margin/--platform every edge is the brand safe margin, exactly
|
|
151
|
+
# as before; --margin sets all four; --platform takes each edge from the destination's safe
|
|
152
|
+
# zone (scripts/_platforms.py), which is what keeps a sticker off TikTok's like column.
|
|
153
|
+
m_top = m_bottom = m_left = m_right = margin
|
|
154
|
+
if args.margin is not None:
|
|
155
|
+
if args.margin < 0:
|
|
156
|
+
die(f"--margin must be >= 0, got {args.margin}")
|
|
157
|
+
m_top = m_bottom = m_left = m_right = margin = args.margin
|
|
158
|
+
elif args.platform:
|
|
159
|
+
px = safe_margins_px(args.platform, W, H)
|
|
160
|
+
m_top, m_bottom, m_left, m_right = px["top"], px["bottom"], px["left"], px["right"]
|
|
161
|
+
margin = m_left
|
|
162
|
+
info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
|
|
104
163
|
fps = meta["video"].get("fps")
|
|
105
164
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
106
165
|
e = time_arg(args.end, "--end", fps) if args.end else dur
|
|
@@ -111,6 +170,92 @@ def main() -> int:
|
|
|
111
170
|
filters: List[str] = []
|
|
112
171
|
fade_a = f"if(lt(t,{s:.3f}+0.3),(t-{s:.3f})/0.3,if(gt(t,{e:.3f}-0.3),({e:.3f}-t)/0.3,1))"
|
|
113
172
|
|
|
173
|
+
# --- which renderer draws the text (1.15) --------------------------------------------------
|
|
174
|
+
# drawtext does bidi and Arabic joining on a fribidi build, but it never reorders or
|
|
175
|
+
# re-clusters (no harfbuzz), so Indic matras and Thai/Lao mark stacking come out wrong on
|
|
176
|
+
# every build. Those scripts -- and any run that has to reserve a gap for an emoji PNG -- go
|
|
177
|
+
# through libass instead; everything else keeps the exact drawtext graph 1.14 produced.
|
|
178
|
+
all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t)
|
|
179
|
+
shaping = needs_shaping(_script)
|
|
180
|
+
emoji_assets = resolve_emoji_assets(args.emoji_assets, None, brand if args.brand else None)
|
|
181
|
+
emoji_mode = None
|
|
182
|
+
emoji_overlays: List[dict] = []
|
|
183
|
+
if has_emoji(all_text):
|
|
184
|
+
support = emoji_support(emoji_assets, probe=True)
|
|
185
|
+
emoji_mode = support["mode"] if args.emoji == "auto" else args.emoji
|
|
186
|
+
if args.emoji == "color" and not support["libass_color"]:
|
|
187
|
+
die("--emoji color: this ffmpeg renders emoji monochrome through libass "
|
|
188
|
+
f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono", kind="input")
|
|
189
|
+
if args.emoji == "png" and not emoji_assets:
|
|
190
|
+
die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
|
|
191
|
+
if args.text_render == "drawtext" and shaping:
|
|
192
|
+
die(f"{LANGUAGE_NAMES.get(_script, _script)} text cannot be shaped by drawtext on any ffmpeg "
|
|
193
|
+
"build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
|
|
194
|
+
"--text-render drawtext to render it through libass, or draw it with caption.py",
|
|
195
|
+
kind="input")
|
|
196
|
+
route = "ass" if (args.text_render == "ass" or
|
|
197
|
+
(args.text_render == "auto" and (shaping or emoji_mode == "png"))) else "drawtext"
|
|
198
|
+
# drawtext loads exactly ONE font file and has no fallback chain, so "whatever glyph the text
|
|
199
|
+
# font has" for an emoji is an empty box on DejaVu Sans and on every script font: reporting
|
|
200
|
+
# mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
|
|
201
|
+
# fallback chain, so an auto run routes there instead; a run that pinned --text-render
|
|
202
|
+
# drawtext degrades to "none" (strip) and says so rather than drawing tofu.
|
|
203
|
+
if emoji_mode == "mono" and route == "drawtext":
|
|
204
|
+
if args.text_render == "auto":
|
|
205
|
+
route = "ass"
|
|
206
|
+
else:
|
|
207
|
+
emoji_mode = "none"
|
|
208
|
+
info("emoji: --text-render drawtext has no font fallback chain, so the cluster would "
|
|
209
|
+
"be drawn as an empty box -- stripped from the text instead "
|
|
210
|
+
"(--text-render auto renders it monochrome through libass)")
|
|
211
|
+
if emoji_mode == "mono":
|
|
212
|
+
info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
213
|
+
"--emoji-assets DIR for colour). " + support["detail"])
|
|
214
|
+
if emoji_mode == "none":
|
|
215
|
+
if not "".join(ch for ch in all_text if char_script(ch) != "emoji").strip():
|
|
216
|
+
die("the template's text is nothing but emoji and this machine can draw none of them "
|
|
217
|
+
"(no glyph, no --emoji-assets DIR): that frame would be blank, which is not a "
|
|
218
|
+
"delivery -- " + EMOJI_ASSET_HINT, kind="input")
|
|
219
|
+
|
|
220
|
+
def _strip_emoji(text):
|
|
221
|
+
if not text:
|
|
222
|
+
return text
|
|
223
|
+
for _i, cl in emoji_clusters(text):
|
|
224
|
+
text = text.replace(cl, "")
|
|
225
|
+
return re.sub(r"[ \t]{2,}", " ", text).strip()
|
|
226
|
+
|
|
227
|
+
for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
|
|
228
|
+
setattr(args, _attr, _strip_emoji(getattr(args, _attr, None)))
|
|
229
|
+
all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text,
|
|
230
|
+
args.top, args.bottom) if t)
|
|
231
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
232
|
+
elements: List[dict] = []
|
|
233
|
+
|
|
234
|
+
def ass_font_family() -> "Optional[str]":
|
|
235
|
+
explicit = args.font_file or brand.get("font_file")
|
|
236
|
+
if explicit:
|
|
237
|
+
return font_family_of_file(explicit) or args.font or brand.get("font")
|
|
238
|
+
if script_file:
|
|
239
|
+
return font_family_of_file(script_file) or font_family_for_script(_script)
|
|
240
|
+
return args.font or brand.get("font", "DejaVu Sans")
|
|
241
|
+
|
|
242
|
+
def ass_fonts_dir() -> "Optional[str]":
|
|
243
|
+
explicit = args.font_file or brand.get("font_file")
|
|
244
|
+
if explicit:
|
|
245
|
+
return os.path.dirname(os.path.abspath(explicit))
|
|
246
|
+
if script_file:
|
|
247
|
+
return os.path.dirname(os.path.abspath(script_file))
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def add_text(text, drawtext, *, target=None, **el):
|
|
251
|
+
"""One line of template text: a drawtext filter on the old route, an ASS element on the
|
|
252
|
+
new one. The geometry is computed identically either way."""
|
|
253
|
+
if route != "ass":
|
|
254
|
+
(filters if target is None else target).append(drawtext)
|
|
255
|
+
return
|
|
256
|
+
el["text"] = text
|
|
257
|
+
elements.append(el)
|
|
258
|
+
|
|
114
259
|
extra_inputs: List[str] = []
|
|
115
260
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
116
261
|
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
@@ -123,18 +268,38 @@ def main() -> int:
|
|
|
123
268
|
pad = int(base * 0.02)
|
|
124
269
|
bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
|
|
125
270
|
bar_w = int(base * 0.62)
|
|
126
|
-
y0 = H -
|
|
271
|
+
y0 = H - m_bottom - bar_h
|
|
127
272
|
# slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
|
|
128
|
-
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{
|
|
273
|
+
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
129
274
|
fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
|
|
130
275
|
fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
|
|
131
276
|
fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
|
|
132
277
|
fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
278
|
+
tx_pad = int(base * 0.035)
|
|
279
|
+
tx = f"({x_expr})+{tx_pad}"
|
|
280
|
+
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
281
|
+
draws = []
|
|
282
|
+
for text, fs, colour, ty in ((args.name, h1, text_c, y0 + pad),
|
|
283
|
+
(args.title, h2, primary, y0 + pad + h1 + pad // 2)):
|
|
284
|
+
if not text:
|
|
285
|
+
continue
|
|
286
|
+
draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
|
|
287
|
+
f"fontcolor={ff_color(colour)}:x='{tx}':y={ty}:{en}")
|
|
288
|
+
if route == "ass":
|
|
289
|
+
# the same three phases the bar itself slides through, as \move Dialogues
|
|
290
|
+
elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
|
|
291
|
+
align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
|
|
292
|
+
outline_color="000000", start=s, end=min(e, s + 0.4),
|
|
293
|
+
move=(x_off, ty, x_rest, ty, 0, 400), x_expr=tx))
|
|
294
|
+
elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
|
|
295
|
+
align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
|
|
296
|
+
outline_color="000000", start=min(e, s + 0.4), end=max(s, e - 0.3),
|
|
297
|
+
x_expr=tx))
|
|
298
|
+
elements.append(dict(text=text, size=fs, color=colour, font=ass_font_family(),
|
|
299
|
+
align=7, x=x_rest, y=ty, outline=max(1.0, fs / 16.0),
|
|
300
|
+
outline_color="000000", start=max(s, e - 0.3), end=e,
|
|
301
|
+
move=(x_rest, ty, x_off, ty, 0, 300), x_expr=tx))
|
|
302
|
+
fc.append(f"[v2]{','.join(draws)}[vout]" if route != "ass" else "[v2]null[vout]")
|
|
138
303
|
|
|
139
304
|
elif args.template == "title":
|
|
140
305
|
if not args.title:
|
|
@@ -142,10 +307,18 @@ def main() -> int:
|
|
|
142
307
|
h1 = int(base * 0.11)
|
|
143
308
|
h2 = int(base * 0.045)
|
|
144
309
|
filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
|
|
145
|
-
|
|
310
|
+
add_text(args.title,
|
|
311
|
+
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
|
|
312
|
+
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
313
|
+
y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
|
|
314
|
+
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
146
315
|
filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
|
|
147
316
|
if args.subtitle:
|
|
148
|
-
|
|
317
|
+
add_text(args.subtitle,
|
|
318
|
+
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
|
|
319
|
+
size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
|
|
320
|
+
y=H / 2 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
|
|
321
|
+
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
149
322
|
|
|
150
323
|
elif args.template in ("chapter", "bug"):
|
|
151
324
|
if not args.title:
|
|
@@ -153,11 +326,20 @@ def main() -> int:
|
|
|
153
326
|
pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
|
|
154
327
|
fs = int(base * (0.04 if args.template == "chapter" else 0.032))
|
|
155
328
|
padx, pady = int(fs * 0.6), int(fs * 0.35)
|
|
156
|
-
xe = f"{
|
|
157
|
-
ye = f"{
|
|
329
|
+
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
330
|
+
ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
|
|
158
331
|
box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
|
|
159
332
|
txt_color = ff_color(bg if args.template == "chapter" else text_c)
|
|
160
|
-
|
|
333
|
+
box_hex = primary if args.template == "chapter" else bg
|
|
334
|
+
txt_hex = bg if args.template == "chapter" else text_c
|
|
335
|
+
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
336
|
+
add_text(args.title,
|
|
337
|
+
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={drawtext_boxborderw(pady, padx)}:alpha='{fade_a}':{en}",
|
|
338
|
+
size=fs, color=txt_hex, font=ass_font_family(), align=align,
|
|
339
|
+
x=(m_left if "left" in pos else W - m_right),
|
|
340
|
+
y=(m_top if "top" in pos else H - m_bottom),
|
|
341
|
+
box=True, box_color=box_hex, box_alpha=(0x19 if args.template == "chapter" else 0x4C),
|
|
342
|
+
outline=float(pady), outline_color=box_hex, start=s, end=e, fade=(300, 300))
|
|
161
343
|
|
|
162
344
|
elif args.template == "progress":
|
|
163
345
|
h = max(3, int(base * 0.008))
|
|
@@ -166,6 +348,76 @@ def main() -> int:
|
|
|
166
348
|
fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
|
|
167
349
|
fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
|
|
168
350
|
|
|
351
|
+
elif args.template == "sticker":
|
|
352
|
+
# A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
|
|
353
|
+
# (its corners are square -- drawtext has no rounded box), and the pop is the two things
|
|
354
|
+
# drawtext *can* animate per frame: alpha and position, so the chip fades up while
|
|
355
|
+
# rising the last few pixels into place over 0.25 s.
|
|
356
|
+
if not args.text:
|
|
357
|
+
die("sticker needs --text")
|
|
358
|
+
pos = args.position or "top-right"
|
|
359
|
+
fs = int(base * 0.05)
|
|
360
|
+
padx, pady = int(fs * 0.7), int(fs * 0.45)
|
|
361
|
+
rise = int(fs * 0.5)
|
|
362
|
+
pop = f"min(1,(t-{s:.3f})/0.25)"
|
|
363
|
+
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
364
|
+
ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
|
|
365
|
+
alpha = f"min({pop},{fade_a})"
|
|
366
|
+
align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
|
|
367
|
+
y_rest = m_top if "top" in pos else H - m_bottom
|
|
368
|
+
y_start = y_rest + rise if "top" in pos else y_rest + rise
|
|
369
|
+
add_text(args.text,
|
|
370
|
+
f"drawtext={drawtext_text_opts(args.text)}:{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
|
|
371
|
+
f"x={xe}:y='{ye}':box=1:boxcolor={ff_color(primary, 0.95)}:boxborderw={drawtext_boxborderw(pady, padx)}:"
|
|
372
|
+
f"alpha='{alpha}':{en}",
|
|
373
|
+
size=fs, color=bg, font=ass_font_family(), align=align,
|
|
374
|
+
x=(m_left if "left" in pos else W - m_right), y=y_rest,
|
|
375
|
+
box=True, box_color=primary, box_alpha=0x0D, outline=float(pady),
|
|
376
|
+
outline_color=primary, start=s, end=e, fade=(250, 250),
|
|
377
|
+
move=(m_left if "left" in pos else W - m_right, y_start,
|
|
378
|
+
m_left if "left" in pos else W - m_right, y_rest, 0, 250))
|
|
379
|
+
|
|
380
|
+
elif args.template == "hook":
|
|
381
|
+
# The opener: a full-width card over the first --duration seconds with a thin bar along
|
|
382
|
+
# the top that empties as the card's time runs out, so the viewer sees how long it lasts.
|
|
383
|
+
if not args.title:
|
|
384
|
+
die("hook needs --title")
|
|
385
|
+
if args.duration <= 0:
|
|
386
|
+
die(f"--duration must be > 0, got {args.duration:g}")
|
|
387
|
+
he = min(e, s + args.duration)
|
|
388
|
+
hen = f"enable='between(t,{s:.3f},{he:.3f})'"
|
|
389
|
+
h1 = int(base * 0.085)
|
|
390
|
+
bar_h = max(3, int(base * 0.01))
|
|
391
|
+
band_h = int(base * 0.30)
|
|
392
|
+
y0 = (H - band_h) // 2
|
|
393
|
+
filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
|
|
394
|
+
add_text(args.title,
|
|
395
|
+
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
|
|
396
|
+
f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
|
|
397
|
+
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
398
|
+
outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
|
|
399
|
+
filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
|
|
400
|
+
f"color={ff_color(primary)}:t=fill:{hen}")
|
|
401
|
+
|
|
402
|
+
elif args.template == "meme":
|
|
403
|
+
# The classic layout: heavy white upper-case lines with a black outline, top and bottom,
|
|
404
|
+
# sized so a short line fills the frame's width without wrapping (drawtext never wraps).
|
|
405
|
+
if not (args.top or args.bottom):
|
|
406
|
+
die("meme needs --top and/or --bottom")
|
|
407
|
+
fs = int(base * 0.09)
|
|
408
|
+
bw = max(2, int(fs / 12))
|
|
409
|
+
white, black = ff_color("FFFFFF"), ff_color("000000")
|
|
410
|
+
for text, y in ((args.top, f"{m_top}"), (args.bottom, f"h-text_h-{m_bottom}")):
|
|
411
|
+
if not text:
|
|
412
|
+
continue
|
|
413
|
+
add_text(text.upper(),
|
|
414
|
+
f"drawtext={drawtext_text_opts(text.upper())}:{fo}:fontsize={fs}:fontcolor={white}:"
|
|
415
|
+
f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}",
|
|
416
|
+
size=fs, color="FFFFFF", font=ass_font_family(), bold=True,
|
|
417
|
+
align=(8 if y == f"{m_top}" else 2), x=W / 2,
|
|
418
|
+
y=(m_top if y == f"{m_top}" else H - m_bottom),
|
|
419
|
+
outline=float(bw), outline_color="000000", start=s, end=e)
|
|
420
|
+
|
|
169
421
|
elif args.template == "countdown":
|
|
170
422
|
n = args.count_from
|
|
171
423
|
seg = (e - s) / (n + 1)
|
|
@@ -174,11 +426,114 @@ def main() -> int:
|
|
|
174
426
|
ks = s + (n - k) * seg
|
|
175
427
|
ke = ks + seg
|
|
176
428
|
pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
|
|
177
|
-
|
|
429
|
+
add_text(str(k),
|
|
430
|
+
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='between(t,{ks:.3f},{ke:.3f})'",
|
|
431
|
+
size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
432
|
+
outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
|
|
433
|
+
scale_t=(0, seg * 500, 115))
|
|
178
434
|
|
|
179
435
|
output = args.output or default_output(args.input, "gfx")
|
|
436
|
+
ass_path = None
|
|
437
|
+
emoji_result = None
|
|
438
|
+
if route == "ass":
|
|
439
|
+
scale_em = float(args.emoji_scale or 1.0)
|
|
440
|
+
missing: List[str] = []
|
|
441
|
+
seen: List[str] = []
|
|
442
|
+
for el in elements:
|
|
443
|
+
el["box_px"] = int(round(el["size"] * scale_em))
|
|
444
|
+
line = el["text"]
|
|
445
|
+
clusters = emoji_clusters(line)
|
|
446
|
+
if not clusters or emoji_mode not in ("png",):
|
|
447
|
+
continue
|
|
448
|
+
line_w = text_width_em(line, scale_em) * el["size"]
|
|
449
|
+
align = int(el.get("align", 7))
|
|
450
|
+
left = el["x"] if align in (7, 4, 1) else (
|
|
451
|
+
el["x"] - line_w / 2.0 if align in (8, 5, 2) else el["x"] - line_w)
|
|
452
|
+
line_h = el["size"] * 1.2
|
|
453
|
+
y_top = el["y"] if align in (7, 8, 9) else (
|
|
454
|
+
el["y"] - line_h / 2.0 if align in (4, 5, 6) else el["y"] - line_h)
|
|
455
|
+
# An RTL line is rendered right-to-left: measure the suffix, not the logical prefix.
|
|
456
|
+
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
457
|
+
rebuilt, cursor = "", 0
|
|
458
|
+
for idx, cluster in clusters:
|
|
459
|
+
name = emoji_codepoint_name(cluster)
|
|
460
|
+
if name not in seen:
|
|
461
|
+
seen.append(name)
|
|
462
|
+
asset = emoji_asset_for(cluster, emoji_assets)
|
|
463
|
+
if not asset:
|
|
464
|
+
if name not in missing:
|
|
465
|
+
missing.append(name)
|
|
466
|
+
rebuilt += line[cursor:idx + len(cluster)]
|
|
467
|
+
cursor = idx + len(cluster)
|
|
468
|
+
continue
|
|
469
|
+
if rtl:
|
|
470
|
+
prefix_px = line_w - text_width_em(line[:idx] + cluster, scale_em) * el["size"]
|
|
471
|
+
else:
|
|
472
|
+
prefix_px = text_width_em(line[:idx], scale_em) * el["size"]
|
|
473
|
+
if el.get("x_expr"):
|
|
474
|
+
# the lower-third slides: the emoji rides the same expression the bar does
|
|
475
|
+
x = f"({el['x_expr']})+{prefix_px:.0f}"
|
|
476
|
+
else:
|
|
477
|
+
x = int(round(max(0.0, min(left + prefix_px, W - el["box_px"]))))
|
|
478
|
+
emoji_overlays.append({"asset": asset, "cluster": name, "x": x,
|
|
479
|
+
"y": int(round(max(0.0, min(y_top + (line_h - el["box_px"]) / 2.0,
|
|
480
|
+
H - el["box_px"])))),
|
|
481
|
+
"start": round(el["start"], 3), "end": round(el["end"], 3),
|
|
482
|
+
"box": el["box_px"],
|
|
483
|
+
# every template fades its text in and out over 0.3 s
|
|
484
|
+
# (fade_a above); the PNG rides the same envelope.
|
|
485
|
+
"fade_in": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3),
|
|
486
|
+
"fade_out": round(min(0.3, max(0.0, (el["end"] - el["start"]) / 2.0)), 3)})
|
|
487
|
+
rebuilt += line[cursor:idx] + EMOJI_SENTINEL
|
|
488
|
+
cursor = idx + len(cluster)
|
|
489
|
+
el["text"] = rebuilt + line[cursor:]
|
|
490
|
+
# `or 60` would swallow --emoji-max 0, the one value meaning "none at all".
|
|
491
|
+
_max = 60 if args.emoji_max is None else int(args.emoji_max)
|
|
492
|
+
if len(emoji_overlays) > _max:
|
|
493
|
+
die(f"{len(emoji_overlays)} emoji overlays would be built for this job "
|
|
494
|
+
f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
|
|
495
|
+
"per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
|
|
496
|
+
if missing:
|
|
497
|
+
info("warning: no PNG in the assets directory for " + ", ".join(missing))
|
|
498
|
+
if emoji_mode:
|
|
499
|
+
emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
|
|
500
|
+
"clusters": sorted(seen) or sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
|
|
501
|
+
"assets": emoji_assets, "missing": missing,
|
|
502
|
+
"overlays": len(emoji_overlays)}
|
|
503
|
+
ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
|
|
504
|
+
if STATE.dry_run:
|
|
505
|
+
info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
|
|
506
|
+
else:
|
|
507
|
+
text_overlay_ass(elements, play_w=W, play_h=H, path=ass_path, fonts_dir=ass_fonts_dir())
|
|
508
|
+
info(f"wrote {ass_path} ({len(elements)} text elements, rendered through libass)")
|
|
509
|
+
elif emoji_mode:
|
|
510
|
+
emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
|
|
511
|
+
"clusters": sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
|
|
512
|
+
"assets": emoji_assets, "missing": [], "overlays": 0}
|
|
513
|
+
|
|
180
514
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
181
|
-
if
|
|
515
|
+
if route == "ass" or emoji_overlays:
|
|
516
|
+
chains = list(fc) if fc else [f"[0:v]{','.join(filters) if filters else 'null'}[vout]"]
|
|
517
|
+
last = "vout"
|
|
518
|
+
if route == "ass":
|
|
519
|
+
vf = f"ass={escape_filter_path(ass_path)}"
|
|
520
|
+
fdir = ass_fonts_dir()
|
|
521
|
+
if fdir:
|
|
522
|
+
vf += f":fontsdir={escape_filter_path(fdir)}"
|
|
523
|
+
chains.append(f"[{last}]{vf}[vtxt]")
|
|
524
|
+
last = "vtxt"
|
|
525
|
+
eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1)
|
|
526
|
+
for spec in emoji_inputs:
|
|
527
|
+
cmd += spec
|
|
528
|
+
asset = spec[-1]
|
|
529
|
+
if asset not in STATE.plan_inputs:
|
|
530
|
+
STATE.plan_inputs.append(asset)
|
|
531
|
+
if eo:
|
|
532
|
+
chains += eo
|
|
533
|
+
last = "vfinal"
|
|
534
|
+
cmd += ["-filter_complex", ";".join(chains), "-map", f"[{last}]",
|
|
535
|
+
"-map", f"0:a:{args.audio_stream}?"]
|
|
536
|
+
elif fc:
|
|
182
537
|
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
183
538
|
else:
|
|
184
539
|
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
@@ -187,7 +542,13 @@ def main() -> int:
|
|
|
187
542
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
188
543
|
r = probe(output, role="output")
|
|
189
544
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
|
|
190
|
-
|
|
545
|
+
extra = {"template": args.template, "dropped_non_av_streams": dropped_streams,
|
|
546
|
+
"text_renderer": route, "script": _script}
|
|
547
|
+
if ass_path:
|
|
548
|
+
extra["ass"] = ass_path
|
|
549
|
+
if emoji_result:
|
|
550
|
+
extra["emoji"] = emoji_result
|
|
551
|
+
emit(output, **extra)
|
|
191
552
|
return 0
|
|
192
553
|
|
|
193
554
|
|
package/scripts/look.py
CHANGED
|
@@ -7,6 +7,7 @@ Examples:
|
|
|
7
7
|
python3 look.py final.mp4 --tiles 4x5 --width 1600
|
|
8
8
|
python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
|
|
9
9
|
python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
|
|
10
|
+
python3 look.py reel.mp4 --safe tiktok --at 3 # shade what TikTok's own UI covers
|
|
10
11
|
Then view the PNG (Read tool / image viewer) and verify before reporting.
|
|
11
12
|
"""
|
|
12
13
|
import argparse
|
|
@@ -15,6 +16,7 @@ import sys
|
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import List
|
|
17
18
|
|
|
19
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
18
20
|
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
19
21
|
|
|
20
22
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
@@ -39,6 +41,8 @@ def main() -> int:
|
|
|
39
41
|
ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
|
|
40
42
|
ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
|
|
41
43
|
ap.add_argument("--no-timecode", action="store_true")
|
|
44
|
+
ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
|
|
45
|
+
"like column, status bar) so you can see whether anything readable is under them")
|
|
42
46
|
add_common(ap)
|
|
43
47
|
args = ap.parse_args()
|
|
44
48
|
apply_common(args)
|
|
@@ -55,6 +59,37 @@ def main() -> int:
|
|
|
55
59
|
default_font = default_font_file("DejaVu Sans")
|
|
56
60
|
font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
|
|
57
61
|
tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
|
|
62
|
+
# --safe: the platform's occluded zones, drawn as shaded boxes in fractions of the frame so
|
|
63
|
+
# the same filter is right at any scale (a tile of a contact sheet as much as a full frame).
|
|
64
|
+
safe_filter = ""
|
|
65
|
+
args.safe = resolve_platform(args.safe)
|
|
66
|
+
if args.safe and not PLATFORMS[args.safe].get("frame"):
|
|
67
|
+
info(f"--safe {args.safe}: this destination has no frame and no app chrome; nothing to shade")
|
|
68
|
+
args.safe = None
|
|
69
|
+
if args.safe:
|
|
70
|
+
z = PLATFORMS[args.safe]["safe"]
|
|
71
|
+
frame = PLATFORMS[args.safe]["frame"]
|
|
72
|
+
src = meta["video"]
|
|
73
|
+
if src.get("width") and src.get("height") and abs(src["width"] / src["height"] - frame["w"] / frame["h"]) > 0.02:
|
|
74
|
+
info(f"--safe {args.safe}: this source is {src['width']}x{src['height']}, not {args.safe}'s "
|
|
75
|
+
f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
|
|
76
|
+
f"so reframe first (fit.py --aspect) to see what the app really covers")
|
|
77
|
+
boxes = []
|
|
78
|
+
for edge, frac in (("top", z["top"]), ("bottom", z["bottom"]), ("left", z["left"]), ("right", z["right"])):
|
|
79
|
+
if frac <= 0:
|
|
80
|
+
continue
|
|
81
|
+
if edge == "top":
|
|
82
|
+
boxes.append(f"drawbox=x=0:y=0:w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
|
|
83
|
+
elif edge == "bottom":
|
|
84
|
+
boxes.append(f"drawbox=x=0:y=ih*(1-{frac:g}):w=iw:h=ih*{frac:g}:color=red@0.35:t=fill")
|
|
85
|
+
elif edge == "left":
|
|
86
|
+
boxes.append(f"drawbox=x=0:y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
|
|
87
|
+
else:
|
|
88
|
+
boxes.append(f"drawbox=x=iw*(1-{frac:g}):y=0:w=iw*{frac:g}:h=ih:color=red@0.20:t=fill")
|
|
89
|
+
safe_filter = "," + ",".join(boxes) if boxes else ""
|
|
90
|
+
info(f"--safe {args.safe}: shaded top {z['top'] * 100:.0f}% / bottom {z['bottom'] * 100:.0f}% / "
|
|
91
|
+
f"left {z['left'] * 100:.0f}% / right {z['right'] * 100:.0f}% of the frame -- keep text out of those")
|
|
92
|
+
tc = safe_filter + tc
|
|
58
93
|
# HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
|
|
59
94
|
if meta["video"].get("hdr"):
|
|
60
95
|
v = meta["video"]
|