ffmpeg-skill 1.14.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.
@@ -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, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
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
- tx = f"({x_expr})+{int(base * 0.035)}"
167
- chain = f"drawtext=text='{escape_drawtext(args.name)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x='{tx}':y={y0 + pad}:{en}"
168
- if args.title:
169
- chain += f",drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x='{tx}':y={y0 + pad + h1 + pad // 2}:{en}"
170
- fc.append(f"[v2]{chain}[vout]")
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
- filters.append(f"drawtext=text='{escape_drawtext(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}")
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
- filters.append(f"drawtext=text='{escape_drawtext(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}")
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
- filters.append(f"drawtext=text='{escape_drawtext(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}")
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
- filters.append(f"drawtext=text='{escape_drawtext(args.text)}':{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
218
- f"x={xe}:y='{ye}':box=1:boxcolor={ff_color(primary, 0.95)}:boxborderw={drawtext_boxborderw(pady, padx)}:"
219
- f"alpha='{alpha}':{en}")
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
- filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
236
- f"x=(w-text_w)/2:y=(h-text_h)/2:{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)
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
- filters.append(f"drawtext=text='{escape_drawtext(text.upper())}':{fo}:fontsize={fs}:fontcolor={white}:"
252
- f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}")
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
- filters.append(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})'")
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 fc:
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
- emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
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
 
@@ -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
- opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
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)}")