ffmpeg-skill 1.14.0 → 1.15.1
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 +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
package/scripts/graphics.py
CHANGED
|
@@ -26,11 +26,20 @@ Examples:
|
|
|
26
26
|
python3 graphics.py clip.mp4 --template meme --top "when the render" --bottom "finally finishes"
|
|
27
27
|
"""
|
|
28
28
|
import argparse
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
29
31
|
import sys
|
|
30
32
|
from typing import List, Optional
|
|
31
33
|
|
|
32
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
33
|
-
from _common import aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
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
|
|
34
43
|
|
|
35
44
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
|
|
36
45
|
|
|
@@ -82,6 +91,23 @@ def main() -> int:
|
|
|
82
91
|
ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
|
|
83
92
|
"text is Chinese, Japanese or Korean when a font is picked by script")
|
|
84
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)")
|
|
85
111
|
ap.add_argument("--crf", type=int, default=18)
|
|
86
112
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
87
113
|
add_common(ap)
|
|
@@ -144,6 +170,92 @@ def main() -> int:
|
|
|
144
170
|
filters: List[str] = []
|
|
145
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))"
|
|
146
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
|
+
|
|
147
259
|
extra_inputs: List[str] = []
|
|
148
260
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
149
261
|
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
@@ -163,11 +275,31 @@ def main() -> int:
|
|
|
163
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]")
|
|
164
276
|
fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
|
|
165
277
|
fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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]")
|
|
171
303
|
|
|
172
304
|
elif args.template == "title":
|
|
173
305
|
if not args.title:
|
|
@@ -175,10 +307,18 @@ def main() -> int:
|
|
|
175
307
|
h1 = int(base * 0.11)
|
|
176
308
|
h2 = int(base * 0.045)
|
|
177
309
|
filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
|
|
178
|
-
|
|
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))
|
|
179
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}")
|
|
180
316
|
if args.subtitle:
|
|
181
|
-
|
|
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))
|
|
182
322
|
|
|
183
323
|
elif args.template in ("chapter", "bug"):
|
|
184
324
|
if not args.title:
|
|
@@ -190,7 +330,16 @@ def main() -> int:
|
|
|
190
330
|
ye = f"{m_top}" if "top" in pos else f"h-text_h-{m_bottom}"
|
|
191
331
|
box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
|
|
192
332
|
txt_color = ff_color(bg if args.template == "chapter" else text_c)
|
|
193
|
-
|
|
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))
|
|
194
343
|
|
|
195
344
|
elif args.template == "progress":
|
|
196
345
|
h = max(3, int(base * 0.008))
|
|
@@ -214,9 +363,19 @@ def main() -> int:
|
|
|
214
363
|
xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
|
|
215
364
|
ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
|
|
216
365
|
alpha = f"min({pop},{fade_a})"
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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))
|
|
220
379
|
|
|
221
380
|
elif args.template == "hook":
|
|
222
381
|
# The opener: a full-width card over the first --duration seconds with a thin bar along
|
|
@@ -232,8 +391,11 @@ def main() -> int:
|
|
|
232
391
|
band_h = int(base * 0.30)
|
|
233
392
|
y0 = (H - band_h) // 2
|
|
234
393
|
filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
|
|
235
|
-
|
|
236
|
-
|
|
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)
|
|
237
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}:"
|
|
238
400
|
f"color={ff_color(primary)}:t=fill:{hen}")
|
|
239
401
|
|
|
@@ -248,8 +410,13 @@ def main() -> int:
|
|
|
248
410
|
for text, y in ((args.top, f"{m_top}"), (args.bottom, f"h-text_h-{m_bottom}")):
|
|
249
411
|
if not text:
|
|
250
412
|
continue
|
|
251
|
-
|
|
252
|
-
|
|
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)
|
|
253
420
|
|
|
254
421
|
elif args.template == "countdown":
|
|
255
422
|
n = args.count_from
|
|
@@ -259,11 +426,114 @@ def main() -> int:
|
|
|
259
426
|
ks = s + (n - k) * seg
|
|
260
427
|
ke = ks + seg
|
|
261
428
|
pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
|
|
262
|
-
|
|
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))
|
|
263
434
|
|
|
264
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
|
+
|
|
265
514
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
266
|
-
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:
|
|
267
537
|
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
268
538
|
else:
|
|
269
539
|
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
@@ -272,7 +542,13 @@ def main() -> int:
|
|
|
272
542
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
273
543
|
r = probe(output, role="output")
|
|
274
544
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
|
|
275
|
-
|
|
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)
|
|
276
552
|
return 0
|
|
277
553
|
|
|
278
554
|
|
package/scripts/overlay.py
CHANGED
|
@@ -25,7 +25,7 @@ import sys
|
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
27
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
28
|
-
from _common import STATE, script_font_for_text, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
|
+
from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
29
29
|
|
|
30
30
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
31
31
|
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
@@ -126,6 +126,15 @@ def main() -> int:
|
|
|
126
126
|
txt.add_argument("--border", type=int, default=2, help="text outline width (default 2)")
|
|
127
127
|
txt.add_argument("--border-color", default="black")
|
|
128
128
|
txt.add_argument("--box", action="store_true", help="draw a translucent box behind the text")
|
|
129
|
+
emo = ap.add_argument_group("emoji (1.15)")
|
|
130
|
+
emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
|
|
131
|
+
help="how emoji in --text are drawn. overlay.py draws through drawtext, which cannot load "
|
|
132
|
+
"a colour emoji font at all, so only 'mono'/'none' render here -- 'png'/'color' name "
|
|
133
|
+
"caption.py/graphics.py instead")
|
|
134
|
+
emo.add_argument("--emoji-assets", metavar="DIR", help="directory of emoji PNGs named by code point "
|
|
135
|
+
"(used by caption.py/graphics.py; overlay.py has no PNG route)")
|
|
136
|
+
emo.add_argument("--emoji-scale", type=float, default=1.0, help="emoji box as a multiple of the font size")
|
|
137
|
+
emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build")
|
|
129
138
|
txt.add_argument("--box-color", default="black@0.5")
|
|
130
139
|
enc = ap.add_argument_group("encoding")
|
|
131
140
|
enc.add_argument("--crf", type=int, default=18)
|
|
@@ -154,6 +163,28 @@ def main() -> int:
|
|
|
154
163
|
args.font = brand.get("font", args.font)
|
|
155
164
|
if not args.font_file and brand.get("font_file"):
|
|
156
165
|
args.font_file = brand["font_file"]
|
|
166
|
+
if args.text:
|
|
167
|
+
# 1.15: drawtext never reorders or re-clusters (no harfbuzz), so Devanagari matras and
|
|
168
|
+
# Thai/Lao mark stacking come out wrong on EVERY build. A wrong frame is not a delivery:
|
|
169
|
+
# refuse and name the two tools that render the script correctly through libass.
|
|
170
|
+
_sc = detect_script(args.text, getattr(args, "lang", None))
|
|
171
|
+
if needs_shaping(_sc):
|
|
172
|
+
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text cannot be shaped by drawtext on any ffmpeg build "
|
|
173
|
+
"(the marks are reordered by harfbuzz, which drawtext does not use): draw it with "
|
|
174
|
+
"caption.py (--text cues, burned through libass) or graphics.py (--template with "
|
|
175
|
+
"--text-render ass) instead", kind="input")
|
|
176
|
+
if has_emoji(args.text):
|
|
177
|
+
if args.emoji in ("png", "color"):
|
|
178
|
+
die(f"--emoji {args.emoji}: overlay.py draws text with drawtext, which cannot load a colour "
|
|
179
|
+
"emoji font and cannot place a PNG inside a line -- use caption.py (--emoji-assets) for "
|
|
180
|
+
"cues or graphics.py (--template) for a title card", kind="input")
|
|
181
|
+
if args.emoji == "none":
|
|
182
|
+
for _i, _cl in list(reversed(emoji_clusters(args.text))):
|
|
183
|
+
args.text = args.text[:_i] + args.text[_i + len(_cl):]
|
|
184
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
185
|
+
else:
|
|
186
|
+
info("warning: emoji are drawn by the text font here (monochrome at best); "
|
|
187
|
+
"caption.py/graphics.py composite colour PNGs with --emoji-assets")
|
|
157
188
|
if args.text and not args.font_file:
|
|
158
189
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
159
190
|
# Arabic ... draws glyphs instead of boxes. drawtext does not shape or reorder RTL text --
|
|
@@ -276,7 +307,9 @@ def main() -> int:
|
|
|
276
307
|
cmd += ["-shortest"]
|
|
277
308
|
else:
|
|
278
309
|
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
279
|
-
|
|
310
|
+
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
311
|
+
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
312
|
+
opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
280
313
|
f"borderw={args.border}", f"bordercolor={args.border_color}"]
|
|
281
314
|
if args.font_file:
|
|
282
315
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|