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.
@@ -0,0 +1,980 @@
1
+ """Text people can see: font resolution per script, emoji clusters and their assets, drawtext
2
+ escaping and option building, and the per-character advance table the caption wrap measures with.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import unicodedata
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
14
+ from _common.decision import escape_filter_path
15
+ from _common.emit import die, info
16
+ from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_version
17
+
18
+
19
+ def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
20
+ """drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
21
+ arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
22
+ (found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
23
+ if ffmpeg_version() >= (6, 1):
24
+ return f"{vertical}|{horizontal}"
25
+ return str(max(vertical, horizontal))
26
+
27
+
28
+ def default_font_file(font_name: str) -> Optional[str]:
29
+ """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
30
+ `fontfile=<path>` instead of `font=<name>`, when possible.
31
+
32
+ On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
33
+ resolution crashes with an access violation whenever it has to resolve a font by family name
34
+ -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
35
+ confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
36
+ ignored on Windows for that reason: a fixed, near-universally-present system font is used
37
+ instead of trying to resolve the requested family (which would crash the same way).
38
+
39
+ On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
40
+ same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
41
+ just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
42
+ sidesteps the same class of crash if it exists on some build there too, but the fallback below
43
+ (returning None) is exercised routinely there, not just on failure.
44
+
45
+ Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
46
+ Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
47
+ """
48
+ if platform.system() == "Windows":
49
+ windir = os.environ.get("WINDIR", "C:\\Windows")
50
+ fonts = Path(windir) / "Fonts"
51
+ # The requested family first: a file whose name starts with the family name with spaces
52
+ # removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
53
+ # common CJK system fonts when the request looks CJK (so Japanese text does not render as
54
+ # boxes in Arial), and Arial only as the last resort.
55
+ wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
56
+ try:
57
+ files = sorted(fonts.iterdir()) if fonts.is_dir() else []
58
+ except OSError:
59
+ files = []
60
+ if wanted:
61
+ for f in files:
62
+ stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
63
+ if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
64
+ return str(f)
65
+ if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
66
+ for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
67
+ if (fonts / name).exists():
68
+ return str(fonts / name)
69
+ candidate = fonts / "arial.ttf"
70
+ return str(candidate) if candidate.exists() else None
71
+ exe = shutil.which("fc-match")
72
+ if not exe:
73
+ return None
74
+ try:
75
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
76
+ except (subprocess.TimeoutExpired, OSError):
77
+ return None
78
+ if proc.returncode != 0:
79
+ return None
80
+ path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
81
+ return path if path and os.path.exists(path) else None
82
+
83
+
84
+ # --------------------------------------------------------------------------- script detection
85
+ # 1.12: non-Latin caption/overlay text used to render as tofu (empty boxes) whenever the default
86
+ # family carried no glyphs for it -- silently, because fontconfig substitutes SOMETHING for every
87
+ # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
88
+ # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
89
+ # warning (a video full of boxes is not a delivery).
90
+ SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
91
+
92
+
93
+ LANGUAGE_NAMES = {
94
+ "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
95
+ "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
96
+ "el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
97
+ }
98
+
99
+
100
+ # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
101
+ # every CJK font that claims zh carries; the rest are the plain two-letter codes).
102
+ FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
103
+ "bn": "bn", "ta": "ta", "lo": "lo"}
104
+
105
+
106
+ # Families tried in order, best first. The names are matched case-insensitively against the start
107
+ # of any family fontconfig reports for a file, so "Noto Sans CJK JP" also matches
108
+ # "Noto Sans CJK JP Black". Anything not listed still qualifies -- it just sorts after these.
109
+ PREFERRED_FAMILIES = {
110
+ "ja": ["Noto Sans CJK JP", "Noto Serif CJK JP", "Noto Sans JP", "Source Han Sans", "IPAPGothic", "IPAGothic", "IPA", "VL Gothic", "TakaoGothic", "WenQuanYi Zen Hei"],
111
+ "zh": ["Noto Sans CJK SC", "Noto Serif CJK SC", "Noto Sans SC", "Source Han Sans", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "Droid Sans Fallback"],
112
+ "ko": ["Noto Sans CJK KR", "Noto Serif CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "Nanum Gothic", "Malgun Gothic", "WenQuanYi Zen Hei"],
113
+ "ar": ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri", "Scheherazade", "DejaVu Sans", "FreeSans", "FreeSerif"],
114
+ "he": ["Noto Sans Hebrew", "Noto Serif Hebrew", "DejaVu Sans", "FreeSans", "FreeSerif"],
115
+ "hi": ["Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari", "Mangal", "Nirmala UI", "Samyak Devanagari", "FreeSans", "FreeSerif"],
116
+ "th": ["Noto Sans Thai", "Noto Serif Thai", "Loma", "Garuda", "Waree", "Umpush", "Norasi", "Sarabun", "Leelawadee UI", "FreeSerif"],
117
+ "bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
118
+ "ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
119
+ "lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
120
+ "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
121
+ "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
122
+ }
123
+
124
+
125
+ # Windows has no fontconfig: the system fonts are looked up by file name instead, best first.
126
+ WINDOWS_FONTS = {
127
+ "ko": [("malgun.ttf", "Malgun Gothic"), ("gulim.ttc", "Gulim"), ("batang.ttc", "Batang")],
128
+ "zh": [("msyh.ttc", "Microsoft YaHei"), ("simhei.ttf", "SimHei"), ("simsun.ttc", "SimSun")],
129
+ "ja": [("meiryo.ttc", "Meiryo"), ("YuGothM.ttc", "Yu Gothic Medium"), ("YuGothR.ttc", "Yu Gothic"), ("msgothic.ttc", "MS Gothic")],
130
+ "ar": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
131
+ "he": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
132
+ "hi": [("mangal.ttf", "Mangal"), ("Nirmala.ttf", "Nirmala UI"), ("NirmalaB.ttf", "Nirmala UI")],
133
+ "th": [("leelawui.ttf", "Leelawadee UI"), ("leelawad.ttf", "Leelawadee"), ("tahoma.ttf", "Tahoma")],
134
+ "bn": [("Nirmala.ttf", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
135
+ "ta": [("Nirmala.ttf", "Nirmala UI"), ("latha.ttf", "Latha")],
136
+ "lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
137
+ "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
138
+ "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
139
+ }
140
+
141
+
142
+ _SCRIPT_RANGES = (
143
+ ("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
144
+ ("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
145
+ ("han", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x2A6DF))),
146
+ ("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF))),
147
+ ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
148
+ ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
149
+ ("bn", ((0x0980, 0x09FF),)),
150
+ ("ta", ((0x0B80, 0x0BFF),)),
151
+ ("th", ((0x0E00, 0x0E7F),)),
152
+ ("lo", ((0x0E80, 0x0EFF),)),
153
+ ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
154
+ ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
155
+ )
156
+
157
+
158
+ # --------------------------------------------------------------------------- emoji (1.15)
159
+ # Emoji are orthogonal to the writing system: "やった 🎉" is Japanese AND emoji. They are detected
160
+ # separately from detect_script() so a cue's font resolution is still decided by its letters.
161
+ EMOJI_RANGES = (
162
+ (0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
163
+ (0x1F000, 0x1F0FF), # mahjong/domino/playing cards
164
+ (0x2600, 0x27BF), # misc symbols + dingbats
165
+ (0x2B00, 0x2BFF), # misc symbols and arrows
166
+ (0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
167
+ (0x1F1E6, 0x1F1FF), # regional indicators (flags)
168
+ (0x20E3, 0x20E3), # combining enclosing keycap
169
+ (0x1F3FB, 0x1F3FF), # skin-tone modifiers
170
+ )
171
+
172
+
173
+ # U+200D ZWJ is deliberately NOT in EMOJI_RANGES: it is ordinary Indic/Persian orthography
174
+ # (क्‍ष is ka + virama + ZWJ + ssa) and only becomes emoji glue *between two emoji bases*.
175
+ # Characters that never START a cluster: they bind to whatever stands before them.
176
+ _EMOJI_TAIL = frozenset({0x200D, 0xFE0F, 0x20E3} | set(range(0x1F3FB, 0x1F400)))
177
+
178
+
179
+ _EMOJI_REGIONAL = range(0x1F1E6, 0x1F200)
180
+
181
+
182
+ _ZWJ = 0x200D
183
+
184
+
185
+ _VS15 = 0xFE0E # text-presentation selector: "draw this as a character, not as an emoji"
186
+
187
+
188
+ _VS16 = 0xFE0F
189
+
190
+
191
+ _KEYCAP = 0x20E3
192
+
193
+
194
+ _KEYCAP_BASES = frozenset("0123456789#*")
195
+
196
+
197
+ def _is_emoji_char(ch: str) -> bool:
198
+ cp = ord(ch)
199
+ return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
200
+
201
+
202
+ def _is_emoji_base(ch: str) -> bool:
203
+ """Can this character START an emoji cluster? Pictographs and regional indicators can;
204
+ the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
205
+ emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
206
+ ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap."""
207
+ return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
208
+
209
+
210
+ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
211
+ """(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
212
+ and skin-tone modifiers kept together -- 👩‍💻 is one cluster, not three, and 1️⃣ starts at the
213
+ digit even though the digit is not itself an emoji character.
214
+
215
+ A cluster can only START at an emoji base (a pictograph, a regional indicator) or at a keycap
216
+ base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
217
+ a starter and never a tail on its own: `क्‍ष` (Hindi ka + virama + ZWJ + ssa) and `abc‍def`
218
+ contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
219
+ not an emoji -- the author asked for the character, not the picture.
220
+ """
221
+ out: "List[Tuple[int, str]]" = []
222
+ i = 0
223
+ n = len(text or "")
224
+ while i < n:
225
+ ch = text[i]
226
+ start = i
227
+ if _is_emoji_base(ch):
228
+ j = i + 1
229
+ if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
230
+ i = j + 1
231
+ continue
232
+ elif ch in _KEYCAP_BASES:
233
+ j = i + 1
234
+ if j < n and ord(text[j]) == _VS16:
235
+ j += 1
236
+ if not (j < n and ord(text[j]) == _KEYCAP):
237
+ i += 1
238
+ continue
239
+ j += 1
240
+ else:
241
+ i += 1
242
+ continue
243
+ # extend: modifiers bind rightwards, a ZWJ only when a real emoji base follows it
244
+ while j < n:
245
+ cp = ord(text[j])
246
+ if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
247
+ j += 1
248
+ continue
249
+ if cp == _ZWJ and j + 1 < n and _is_emoji_base(text[j + 1]):
250
+ j += 2
251
+ continue
252
+ if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
253
+ j += 1
254
+ continue
255
+ break
256
+ out.append((start, text[start:j]))
257
+ i = j
258
+ return out
259
+
260
+
261
+ def has_emoji(text: str) -> bool:
262
+ return bool(emoji_clusters(text or ""))
263
+
264
+
265
+ def emoji_codepoint_name(cluster: str) -> str:
266
+ """The asset filename stem for a cluster: lowercase hex code points joined by '-', the
267
+ Twemoji/Noto convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5)."""
268
+ return "-".join(f"{ord(c):x}" for c in cluster)
269
+
270
+
271
+ def _emoji_name_candidates(cluster: str) -> "List[str]":
272
+ """Asset stems to try, most specific first: exact, without VS16, without skin tone, the ZWJ
273
+ sequence reduced to its first code point, the bare base."""
274
+ cps = [ord(c) for c in cluster]
275
+ names = [emoji_codepoint_name(cluster)]
276
+
277
+ def add(seq):
278
+ name = "-".join(f"{c:x}" for c in seq)
279
+ if name and name not in names:
280
+ names.append(name)
281
+ add([c for c in cps if c != 0xFE0F])
282
+ add([c for c in cps if c != 0xFE0F and not (0x1F3FB <= c <= 0x1F3FF)])
283
+ if 0x200D in cps:
284
+ add([cps[0]])
285
+ add([cps[0]])
286
+ return names
287
+
288
+
289
+ def emoji_asset_for(cluster: str, assets_dir: "Optional[str]") -> "Optional[str]":
290
+ """The PNG for `cluster` under `assets_dir`, or None when nothing matches."""
291
+ if not assets_dir or not os.path.isdir(assets_dir):
292
+ return None
293
+ for name in _emoji_name_candidates(cluster):
294
+ for ext in (".png", ".PNG"):
295
+ candidate = os.path.join(assets_dir, name + ext)
296
+ if os.path.isfile(candidate):
297
+ return candidate
298
+ return None
299
+
300
+
301
+ EMOJI_ASSET_HINT = (
302
+ "point --emoji-assets at a directory of PNGs named by code point (1f389.png): "
303
+ "twemoji/assets/72x72 (Twemoji, CC-BY 4.0) or noto-emoji/png/128 (Noto Emoji, OFL/Apache-2.0) "
304
+ "are the two people already have. The skill has no network at runtime, so the assets must "
305
+ "already exist on this machine -- nothing is ever downloaded")
306
+
307
+
308
+ _EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
309
+
310
+
311
+ _EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
312
+
313
+
314
+ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
315
+ """(family, file, fontconfig_answered) for the first installed colour emoji family."""
316
+ exe = shutil.which("fc-list")
317
+ if not exe:
318
+ return None, None, False
319
+ for family in _EMOJI_COLOR_FAMILIES:
320
+ try:
321
+ proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
322
+ stderr=subprocess.DEVNULL, text=True, timeout=10)
323
+ except (subprocess.TimeoutExpired, OSError):
324
+ return None, None, False
325
+ if proc.returncode != 0:
326
+ return None, None, False
327
+ for line in proc.stdout.splitlines():
328
+ path = line.split(":", 1)[0].strip()
329
+ if path and os.path.exists(path):
330
+ return family, path, True
331
+ return None, None, True
332
+
333
+
334
+ def _libass_color_probe() -> "Optional[bool]":
335
+ """Does THIS ffmpeg render an emoji in colour through libass? Answered by a render, never by
336
+ the font listing: Noto Color Emoji installs happily on builds whose freetype/libass has no
337
+ colour-bitmap path at all, and those render a monochrome outline instead (measured). ~80 ms.
338
+ None means the probe could not be run (no ffmpeg, a failure) -- unknown, not false."""
339
+ exe = shutil.which("ffmpeg")
340
+ if not exe:
341
+ return None
342
+ import tempfile
343
+ with tempfile.TemporaryDirectory() as td:
344
+ srt = os.path.join(td, "e.srt")
345
+ with open(srt, "w", encoding="utf-8") as fh:
346
+ fh.write("1\n00:00:00,000 --> 00:00:01,000\n\U0001F389\n")
347
+ try:
348
+ proc = subprocess.run(
349
+ [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
350
+ "-i", "color=c=black:s=64x64:d=0.04",
351
+ "-vf", "subtitles=" + srt.replace("\\", "/"), "-frames:v", "1",
352
+ "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
353
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
354
+ except (subprocess.TimeoutExpired, OSError):
355
+ return None
356
+ if proc.returncode != 0 or len(proc.stdout) < 64 * 64 * 3:
357
+ return None
358
+ data = proc.stdout
359
+ for i in range(0, 64 * 64 * 3, 3):
360
+ r, g, b = data[i], data[i + 1], data[i + 2]
361
+ if max(r, g, b) - min(r, g, b) > 40:
362
+ return True
363
+ return False
364
+
365
+
366
+ def emoji_support(assets: "Optional[str]" = None, probe: bool = True) -> "Dict[str, Any]":
367
+ """What this machine can actually do with emoji, cached per process.
368
+
369
+ `mode` is `color` when a render probe proves libass draws colour, else `png` when an assets
370
+ directory resolves, else `mono` when some installed face has a glyph at all, else `none`.
371
+ An installed colour emoji font proves nothing on its own -- that is why `libass_color` comes
372
+ from a render (see references/gotchas.md#emoji). `probe=False` (`contract --json --static`, and every
373
+ static/JSON-only path) skips the render entirely and leaves `libass_color` unknown.
374
+ """
375
+ key = (assets or None, bool(probe))
376
+ if key in _EMOJI_SUPPORT_CACHE:
377
+ return dict(_EMOJI_SUPPORT_CACHE[key])
378
+ family, file, fc_answered = _emoji_color_font()
379
+ libass_color = _libass_color_probe() if probe else None
380
+ assets_dir = assets if (assets and os.path.isdir(assets)) else None
381
+ if libass_color:
382
+ mode = "color"
383
+ elif assets_dir:
384
+ mode = "png"
385
+ elif family:
386
+ mode = "mono"
387
+ elif not fc_answered:
388
+ # No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
389
+ # so the honest answer is png-or-none, never "none because fc-list is missing".
390
+ mode = "none"
391
+ else:
392
+ mode = "none"
393
+ if not fc_answered:
394
+ detail = "no fontconfig on this machine; the PNG overlay path needs none"
395
+ elif libass_color:
396
+ detail = f"{family or 'an installed face'} renders in colour through libass on this ffmpeg"
397
+ elif family and libass_color is False:
398
+ detail = f"{family} installed but libass renders it monochrome on this build"
399
+ elif family and libass_color is None:
400
+ detail = f"{family} installed; the colour render probe was not run"
401
+ elif assets_dir:
402
+ detail = "no colour emoji family installed; using the PNG assets directory"
403
+ else:
404
+ detail = "no colour emoji family installed and no --emoji-assets directory"
405
+ result = {"mode": mode, "color_font": family, "color_font_file": file,
406
+ "libass_color": libass_color, "assets": assets_dir,
407
+ "detail": detail, "fix": EMOJI_ASSET_HINT}
408
+ _EMOJI_SUPPORT_CACHE[key] = result
409
+ return dict(result)
410
+
411
+
412
+ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]" = None,
413
+ brand: "Optional[dict]" = None) -> "Optional[str]":
414
+ """--emoji-assets DIR, else the project key, else brand.json, else FFMPEG_SKILL_EMOJI_ASSETS.
415
+ A directory that was named but does not exist is a failed job, never a silent downgrade."""
416
+ brand = brand or {}
417
+ styles = (brand.get("styles") or {}).get("caption") or {}
418
+ for value, where in ((flag, "--emoji-assets"), (project, "the project's text.emoji_assets"),
419
+ (styles.get("emoji_assets"), "brand.json styles.caption.emoji_assets"),
420
+ (brand.get("emoji_assets"), "brand.json emoji_assets"),
421
+ (os.environ.get("FFMPEG_SKILL_EMOJI_ASSETS"), "FFMPEG_SKILL_EMOJI_ASSETS")):
422
+ if not value:
423
+ continue
424
+ if not os.path.isdir(str(value)):
425
+ die(f"{where}: {value} is not a readable directory -- {EMOJI_ASSET_HINT}", kind="input")
426
+ return str(value)
427
+ return None
428
+
429
+
430
+ # --------------------------------------------------------------------------- text measurement (1.12)
431
+ # Moved here in 1.15 so graphics.py's ASS route and the emoji placement share caption.py's table.
432
+ # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
433
+ # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
434
+ # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
435
+ # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
436
+ # shaping, while a cue wrapped from an average is right to within a character on every line.
437
+ # (Latin is measured per character from LATIN_EM below, not from this average.)
438
+ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
439
+ "ru": 0.55, "el": 0.55, "latin": 0.55}
440
+
441
+
442
+ # Scripts written without spaces: a line breaks between any two characters.
443
+ NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
444
+
445
+
446
+ # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
447
+ # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
448
+ # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
449
+ # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
450
+ # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
451
+ # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
452
+ # 0.57 lowercase and anything else Latin-ish).
453
+ LATIN_EM = {
454
+ ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
455
+ '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
456
+ '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
457
+ '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
458
+ 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
459
+ 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
460
+ 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
461
+ 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
462
+ 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
463
+ 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
464
+ 'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
465
+ '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
466
+ }
467
+
468
+
469
+ # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
470
+ # between them and the base that follows.
471
+ LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
472
+
473
+
474
+ def _is_mark(ch: str) -> bool:
475
+ """A character that hangs off the one before it: a combining mark (any script) or one of the
476
+ Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
477
+ return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
478
+
479
+
480
+ def _char_em(ch: str) -> float:
481
+ # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
482
+ # full-width grid as the ideographs they sit between, even though they are not "Han" to a
483
+ # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
484
+ cp = ord(ch)
485
+ # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
486
+ # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
487
+ if unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Cf"):
488
+ # "Cf" catches ZWJ/ZWNJ: an Indic joiner is orthography, and it advances the pen by
489
+ # nothing -- charging it a full em (it used to count as "emoji") shrank a Hindi line.
490
+ return 0.0
491
+ if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
492
+ return 1.0
493
+ script = char_script(ch)
494
+ if script == "emoji":
495
+ # 1.15: an emoji is drawn (or reserved) at a full em box, not at Latin's 0.57 -- counting
496
+ # it as Latin overflowed the safe area on an emoji-heavy line.
497
+ return 1.0
498
+ if script == "latin":
499
+ if ch in LATIN_EM:
500
+ return LATIN_EM[ch]
501
+ if ch.isupper() or ch.isdigit():
502
+ return 0.7
503
+ return 0.57
504
+ return ADVANCE_EM.get(script, 0.55)
505
+
506
+
507
+ def text_width_em(text: str, emoji_em: float = 1.0) -> float:
508
+ """Width of `text` in em, from the per-script average advance table. `emoji_em` is what one
509
+ emoji cluster costs (--emoji-scale), so a wrap counts the box that will actually be drawn."""
510
+ total = 0.0
511
+ spans = {i: len(c) for i, c in emoji_clusters(text)}
512
+ i = 0
513
+ while i < len(text):
514
+ if i in spans:
515
+ total += emoji_em
516
+ i += spans[i]
517
+ continue
518
+ total += _char_em(text[i])
519
+ i += 1
520
+ return total
521
+
522
+
523
+ def emoji_filter_chain(plan, base_label, out_label, first_input=1):
524
+ """(chains, inputs) that composite the planned PNGs on top of `base_label`.
525
+
526
+ `inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
527
+ ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
528
+ a timeline the fade filter can move along; one that does not is a plain `-i`).
529
+ """
530
+ overlays = plan.get("overlays") or []
531
+ if not overlays:
532
+ return [], []
533
+ # Group by everything that makes two uses of the same PNG a different STREAM: the fade is
534
+ # expressed in the cue's own timeline, so two cues cannot share one faded input.
535
+ def _key(o):
536
+ fades = (round(float(o.get("fade_in") or 0.0), 3), round(float(o.get("fade_out") or 0.0), 3))
537
+ window = (round(float(o["start"]), 3), round(float(o["end"]), 3)) if any(fades) else (None, None)
538
+ return (o["asset"], o["box"]) + fades + window
539
+
540
+ groups: "List[Tuple]" = []
541
+ for o in overlays:
542
+ if _key(o) not in groups:
543
+ groups.append(_key(o))
544
+ chains: List[str] = []
545
+ inputs: "List[List[str]]" = []
546
+ pads: "Dict[Tuple, List[str]]" = {}
547
+ for k, key in enumerate(groups):
548
+ asset, box, fin, fout, gstart, gend = key
549
+ uses = [o for o in overlays if _key(o) == key]
550
+ idx = first_input + k
551
+ labels = [f"e{k}_{j}" for j in range(len(uses))]
552
+ chain = f"[{idx}:v]format=rgba,scale={box}:{box}"
553
+ if fin or fout:
554
+ # -loop 1 gives the still an advancing timeline on the SAME clock as the main video,
555
+ # so the fade times below are the cue's own seconds. The emoji then appears and
556
+ # leaves with the text instead of popping in against a fading line.
557
+ # -t bounds the loop at the cue's end: an unbounded looped still never EOFs and the
558
+ # whole encode hangs (overlay keeps pulling from it after the main video is done).
559
+ inputs.append(["-loop", "1", "-t", f"{gend:.3f}", "-i", asset])
560
+ if fin:
561
+ chain += f",fade=t=in:st={gstart:.3f}:d={fin:.3f}:alpha=1"
562
+ if fout:
563
+ chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
564
+ else:
565
+ inputs.append(["-i", asset])
566
+ if len(labels) > 1:
567
+ chain += f",split={len(labels)}"
568
+ chains.append(chain + "".join(f"[{l}]" for l in labels))
569
+ pads[key] = labels
570
+ cur = base_label
571
+ remaining = {key: list(v) for key, v in pads.items()}
572
+ for j, o in enumerate(overlays):
573
+ label = remaining[_key(o)].pop(0)
574
+ nxt = out_label if j == len(overlays) - 1 else f"eov{j}"
575
+ x = o["x"]
576
+ x = f"'{x}'" if isinstance(x, str) else x
577
+ # No eof_action=pass here: a PNG input is a SINGLE frame at pts 0, and eof_action=pass
578
+ # switches off overlay's default "hold the last frame of the secondary input", so the
579
+ # asset would be composited on frame 0 only and vanish for the rest of the cue (that is
580
+ # exactly what shipped first). eof_action=repeat (the default) holds the still for the
581
+ # whole timeline; enable= is what confines it to the cue's window.
582
+ chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
583
+ f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
584
+ cur = nxt
585
+ return chains, inputs
586
+
587
+
588
+ # --------------------------------------------------------------------------- shaping (1.15)
589
+ # Scripts whose correct rendering needs harfbuzz-class reordering and re-clustering (Indic matras,
590
+ # Thai/Lao mark stacking). drawtext does NOT use harfbuzz even in an --enable-libharfbuzz build, so
591
+ # these come out wrong through drawtext on every build and must go through libass. Arabic and
592
+ # Hebrew are NOT here: drawtext's text_shaping uses fribidi, which does bidi and Arabic joining
593
+ # correctly -- they only join this set on a build compiled without fribidi.
594
+ SHAPING_SCRIPTS = frozenset({"hi", "bn", "ta", "te", "kn", "ml", "gu", "pa", "si", "th", "lo", "km", "my"})
595
+
596
+
597
+ BIDI_SCRIPTS = frozenset({"ar", "he"})
598
+
599
+
600
+ _SHAPING_BUILD_CACHE: "Dict[str, bool]" = {}
601
+
602
+
603
+ def drawtext_shaping() -> "Dict[str, bool]":
604
+ """Which shaping libraries THIS ffmpeg was built with, from -buildconf (falling back to the
605
+ `configuration:` line of -version). Cached per process."""
606
+ if _SHAPING_BUILD_CACHE:
607
+ return dict(_SHAPING_BUILD_CACHE)
608
+ text = ""
609
+ exe = shutil.which("ffmpeg")
610
+ if exe:
611
+ for flag in ("-buildconf", "-version"):
612
+ try:
613
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
614
+ stderr=subprocess.STDOUT, text=True, timeout=10)
615
+ except (subprocess.TimeoutExpired, OSError):
616
+ break
617
+ if proc.returncode == 0 and proc.stdout.strip():
618
+ text = proc.stdout
619
+ break
620
+ _SHAPING_BUILD_CACHE.update({"fribidi": "--enable-libfribidi" in text,
621
+ "harfbuzz": "--enable-libharfbuzz" in text})
622
+ return dict(_SHAPING_BUILD_CACHE)
623
+
624
+
625
+ def needs_shaping(script: str) -> bool:
626
+ """Whether drawtext would render `script` wrongly on this build."""
627
+ if script in SHAPING_SCRIPTS:
628
+ return True
629
+ return script in BIDI_SCRIPTS and not drawtext_shaping()["fribidi"]
630
+
631
+
632
+ def font_family_of_file(path: str) -> "Optional[str]":
633
+ """The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
634
+ the file directly; without fontconfig the file stem is the honest best guess."""
635
+ if not path or not os.path.isfile(path):
636
+ return None
637
+ exe = shutil.which("fc-scan")
638
+ if exe:
639
+ try:
640
+ proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
641
+ stderr=subprocess.DEVNULL, text=True, timeout=10)
642
+ if proc.returncode == 0 and proc.stdout.strip():
643
+ return proc.stdout.strip().splitlines()[0].strip()
644
+ except (subprocess.TimeoutExpired, OSError):
645
+ pass
646
+ return Path(path).stem
647
+
648
+
649
+ def char_script(ch: str) -> str:
650
+ """The script of one character: one of SCRIPTS, or "latin" for anything else (including
651
+ digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
652
+ cp = ord(ch)
653
+ # 1.15: an emoji cluster is not Latin. detect_script() skips "emoji" the way it skips "latin",
654
+ # so font resolution still follows the letters around it.
655
+ if _is_emoji_char(ch):
656
+ return "emoji"
657
+ for name, ranges in _SCRIPT_RANGES:
658
+ for lo, hi in ranges:
659
+ if lo <= cp <= hi:
660
+ return "ja" if name == "kana" else ("zh" if name == "han" else name)
661
+ return "latin"
662
+
663
+
664
+ def detect_script(text: str, lang: "Optional[str]" = None) -> str:
665
+ """Which script `text` is written in, as one of SCRIPTS.
666
+
667
+ Hangul wins for Korean, any kana makes the whole string Japanese (Japanese mixes kana and
668
+ Han), Han alone is Chinese. Mixed text is decided by character count: the non-Latin script
669
+ with the most characters wins, ties going to whichever appeared first, and text with no
670
+ non-Latin characters at all is "latin". `lang` (a --lang/--language hint, or brand.json's
671
+ `lang`) only resolves the one ambiguity the characters genuinely cannot: Han with no kana
672
+ is Chinese by default but Japanese (or Korean hanja) when the caller says so.
673
+ """
674
+ counts: "Dict[str, int]" = {}
675
+ order: "List[str]" = []
676
+ kana = 0
677
+ for ch in text or "":
678
+ s = char_script(ch)
679
+ if s in ("latin", "emoji"):
680
+ continue
681
+ if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
682
+ kana += 1
683
+ if s not in counts:
684
+ order.append(s)
685
+ counts[s] = counts.get(s, 0) + 1
686
+ if kana: # Japanese: the Han characters in the same string are Japanese too
687
+ counts["ja"] = counts.pop("ja", 0) + counts.pop("zh", 0)
688
+ order = [s for s in order if s != "zh"]
689
+ if not counts:
690
+ return "latin"
691
+ best = max(counts, key=lambda s: (counts[s], -order.index(s)))
692
+ hint = (lang or "").strip().lower().replace("_", "-").split("-")[0]
693
+ if not kana and best == "zh" and hint in ("ja", "zh", "ko"):
694
+ return hint # Han-only text: only the caller knows whether it is Chinese, Japanese or hanja
695
+ return best
696
+
697
+
698
+ _SCRIPT_FONT_CACHE: "Dict[Tuple[str, Optional[str]], Optional[Tuple[str, str]]]" = {}
699
+
700
+
701
+ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
702
+ """(file, families) for every font fontconfig says covers `fc_lang`.
703
+
704
+ `[]` means fontconfig answered and nothing covers the language; `None` means it could not be
705
+ asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
706
+ "missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
707
+ """
708
+ exe = shutil.which("fc-list")
709
+ if not exe:
710
+ return None
711
+ try:
712
+ proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
713
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
714
+ except (subprocess.TimeoutExpired, OSError):
715
+ return None
716
+ if proc.returncode != 0:
717
+ return None
718
+ out = []
719
+ for line in proc.stdout.splitlines():
720
+ if ": " not in line:
721
+ continue
722
+ path, _, families = line.partition(": ")
723
+ path = path.strip()
724
+ if not path or not os.path.exists(path):
725
+ continue
726
+ names = [f.replace("\\-", "-").strip() for f in families.split(",") if f.strip()]
727
+ out.append((path, names or [Path(path).stem]))
728
+ return out
729
+
730
+
731
+ def _family_rank(families: "Sequence[str]", preferred: "Sequence[str]") -> int:
732
+ for i, want in enumerate(preferred):
733
+ w = want.lower()
734
+ if any(f.lower().startswith(w) for f in families):
735
+ return i
736
+ return len(preferred)
737
+
738
+
739
+ def _script_font_entry(script: str, family_hint: "Optional[str]" = None) -> "Optional[Tuple[str, str]]":
740
+ key = (script, family_hint)
741
+ if key in _SCRIPT_FONT_CACHE:
742
+ return _SCRIPT_FONT_CACHE[key]
743
+ _SCRIPT_FONT_CACHE[key] = result = _script_font_uncached(script, family_hint)
744
+ return result
745
+
746
+
747
+ FC_UNKNOWN = "unknown" # sentinel: fontconfig could not be asked (absent or failing), not "no font"
748
+
749
+
750
+ def _script_font_uncached(script: str, family_hint: "Optional[str]" = None):
751
+ """(file, family), None when nothing covers `script`, or FC_UNKNOWN when it cannot be asked."""
752
+ if script not in FC_LANG:
753
+ return None
754
+ if platform.system() == "Windows":
755
+ fonts = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
756
+ for name, family in WINDOWS_FONTS.get(script, []):
757
+ if (fonts / name).exists():
758
+ return str(fonts / name), family
759
+ return None
760
+ preferred = list(PREFERRED_FAMILIES.get(script, []))
761
+ if family_hint:
762
+ preferred.insert(0, family_hint)
763
+ candidates = _fc_list_fonts(FC_LANG[script])
764
+ if candidates is None:
765
+ return FC_UNKNOWN
766
+ if not candidates:
767
+ return None
768
+ scored = []
769
+ for path, families in candidates:
770
+ joined = " ".join(families).lower()
771
+ stem = Path(path).stem.lower()
772
+ # "Unifont Sample" is fontconfig's tofu-with-hex-digits fallback: it "covers" every script
773
+ # by drawing the code point, which is exactly the unreadable result this feature exists to
774
+ # avoid -- it is only ever chosen when nothing else covers the script at all. A *Mono* face
775
+ # is legible but wrong for a caption band, so it sorts after every proportional one.
776
+ last_resort = 1 if "unifont" in joined else 0
777
+ mono = 1 if "mono" in joined else 0
778
+ # regular weights before Bold/Italic/Oblique cuts, so a default caption is not bold by accident
779
+ styled = 1 if any(k in stem for k in ("bold", "italic", "oblique", "light", "thin", "black")) else 0
780
+ scored.append((last_resort, _family_rank(families, preferred), mono, styled, path, families[0]))
781
+ scored.sort(key=lambda row: (row[0], row[1], row[2], row[3], row[4]))
782
+ best = scored[0]
783
+ return best[4], best[5]
784
+
785
+
786
+ def font_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
787
+ """A font FILE path that covers `script`, or None when this machine has none.
788
+
789
+ Linux/macOS ask fontconfig (`fc-list :lang=xx file family`) and rank what it reports by the
790
+ PREFERRED_FAMILIES table; Windows has no fontconfig, so the known system files are probed by
791
+ name. Cached per process: a caption job resolves the same script for every cue.
792
+ """
793
+ entry = _script_font_entry(script, family_hint)
794
+ return entry[0] if entry and entry is not FC_UNKNOWN else None
795
+
796
+
797
+ def font_family_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
798
+ """The family NAME of font_for_script()'s file -- what libass wants in an ASS Fontname."""
799
+ entry = _script_font_entry(script, family_hint)
800
+ return entry[1] if entry and entry is not FC_UNKNOWN else None
801
+
802
+
803
+ def script_font_status(script: str) -> str:
804
+ """"available" (a font file covers `script`), "missing" (fontconfig answered, none does) or
805
+ "unknown" (there is no working fontconfig to ask). Only "missing" is a refusal."""
806
+ entry = _script_font_entry(script)
807
+ if entry is FC_UNKNOWN:
808
+ return "unknown"
809
+ return "available" if entry else "missing"
810
+
811
+
812
+ def font_covers_script(font_name: str, script: str) -> bool:
813
+ """Whether the installed family `font_name` actually carries glyphs for `script`.
814
+
815
+ `fc-match` cannot answer this: given a family that IS installed it returns that family
816
+ whatever `:lang=` asks for (verified -- `fc-match "DejaVu Sans:lang=zh-cn"` answers
817
+ "DejaVu Sans", which has no Han glyphs at all). `fc-list :lang=xx:family=<name>` does: it
818
+ lists only files that satisfy BOTH, so an empty listing is the proof of no coverage. Unknown
819
+ (no fontconfig at all) counts as covering: a warning nobody can verify is worse than none.
820
+ """
821
+ if script not in FC_LANG or not font_name:
822
+ return True
823
+ exe = shutil.which("fc-list")
824
+ if not exe:
825
+ return True
826
+ try:
827
+ proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
828
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
829
+ except (subprocess.TimeoutExpired, OSError):
830
+ return True
831
+ if proc.returncode != 0:
832
+ return True
833
+ return bool(proc.stdout.strip())
834
+
835
+
836
+ # Named flags differ per tool: overlay.py and graphics.py take a font FILE, caption.py takes a
837
+ # directory of faces plus the family name -- naming a flag the tool does not have is worse than
838
+ # naming none, so the hint says both (review 10).
839
+ FONT_FLAG_HINT = "pass a font file (--font-file on overlay.py/graphics.py, --fonts-dir with --font on caption.py)"
840
+
841
+
842
+ FONT_INSTALL_HINT = ("install fonts-noto-cjk / fonts-noto-core (apt), "
843
+ "brew install --cask font-noto-sans-cjk / font-noto-sans-arabic (mac), or "
844
+ + FONT_FLAG_HINT)
845
+
846
+
847
+ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
848
+ """Does any font under `fonts_dir` cover `script`? None when it cannot be checked.
849
+
850
+ `--fonts-dir` says "also look here", not "this exact face", so it must not switch the
851
+ coverage guarantee off. fontconfig's `fc-scan` reads the files directly (no cache, no
852
+ installed-font database), which is exactly the question: `%{lang}` lists the languages each
853
+ face claims.
854
+ """
855
+ if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
856
+ return None
857
+ exe = shutil.which("fc-scan")
858
+ if not exe:
859
+ return None
860
+ try:
861
+ proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
862
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
863
+ except (subprocess.TimeoutExpired, OSError):
864
+ return None
865
+ if proc.returncode != 0:
866
+ return None
867
+ want = FC_LANG[script].lower()
868
+ for line in proc.stdout.splitlines():
869
+ if want in [tag.strip().lower() for tag in line.split("|")]:
870
+ return True
871
+ return False
872
+
873
+
874
+ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Optional[str]" = None,
875
+ font_explicit: bool = False, font_file: "Optional[str]" = None,
876
+ fonts_dir: "Optional[str]" = None
877
+ ) -> "Tuple[str, Optional[str], Optional[str]]":
878
+ """(script, font file, family) to draw `text` with, resolving by script when nothing explicit
879
+ was asked for.
880
+
881
+ Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
882
+ --font-file, an explicit --font (which is kept even when fontconfig says it does not cover
883
+ the script -- with one info line saying so, because overriding a user's stated font silently
884
+ is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
885
+ file is returned with ONE info line naming it.
886
+
887
+ A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
888
+ with no working fontconfig at all answers "unknown", not "missing": the job continues with
889
+ the caller's font -- libass and drawtext still have their own font backends -- and one info
890
+ line says the coverage could not be verified.
891
+ """
892
+ script = detect_script(text or "", lang)
893
+ if script == "latin":
894
+ return script, None, None
895
+ if font_file:
896
+ return script, None, None
897
+ if font_explicit and font:
898
+ if not font_covers_script(font, script):
899
+ info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
900
+ f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
901
+ return script, None, None
902
+ if fonts_dir:
903
+ covered = fonts_dir_covers_script(fonts_dir, script)
904
+ if covered:
905
+ return script, None, None
906
+ if covered is None:
907
+ info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
908
+ "(no fc-scan on this machine); using it as given")
909
+ return script, None, None
910
+ info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
911
+ "picking one by script instead (the directory is still searched first)")
912
+ entry = _script_font_entry(script)
913
+ if entry is FC_UNKNOWN:
914
+ info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
915
+ "(no working fontconfig); rendering with the font as given -- "
916
+ "doctor --json .fonts.scripts reports what is known")
917
+ return script, None, None
918
+ if not entry:
919
+ die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")
920
+ info(f"font: {entry[0]} (covers {script})")
921
+ return script, entry[0], entry[1]
922
+
923
+
924
+ def escape_drawtext(text: str) -> str:
925
+ """Escape a FONT NAME for a single-quoted drawtext option value (`font='<this>'`).
926
+
927
+ Since 1.15 this is no longer the route for drawn TEXT -- use drawtext_text_opts(), which puts
928
+ the text in a file and keeps `\'` and `%` verbatim. It remains the escape for the font-name
929
+ fallback, where the value is a family name that never legitimately contains a quote or a
930
+ percent sign.
931
+
932
+ Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash escape
933
+ regardless of the surrounding quotes -- the graph parser still splits on an unescaped `,`/`;`
934
+ or ends an option list on an unescaped `:`/`[`/`]` even while "inside" a quoted value. The
935
+ quote character itself has no reliable backslash escape at all: `\\'` and the POSIX shell
936
+ close-insert-reopen trick both parse fine in a simple `-vf` chain but silently corrupt a
937
+ `-filter_complex` chain that uses explicit `[label]` pads (confirmed by rendering the result:
938
+ trailing option names leak into the picture as literal text). `%` has the same problem as far
939
+ as drawtext's own expansion scanner is concerned. Both are therefore dropped here rather than
940
+ escaped -- which is exactly why drawn text no longer comes through this function.
941
+ """
942
+ text = re.sub(r"[\x00-\x1f\x7f]", "", text)
943
+ return (
944
+ text.replace("'", "")
945
+ .replace("%", "")
946
+ .replace("\\", "\\\\")
947
+ .replace(":", "\\:")
948
+ .replace(",", "\\,")
949
+ .replace("[", "\\[")
950
+ .replace("]", "\\]")
951
+ .replace(";", "\\;")
952
+ )
953
+
954
+
955
+ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
956
+ """`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
957
+ every character on every build shape this repo uses.
958
+
959
+ The filter-graph parser never sees the text at all: only the PATH is parsed, and
960
+ escape_filter_path() already handles that. `expansion=none` switches off drawtext's own
961
+ `%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
962
+ and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
963
+ `]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
964
+ done"` losing both characters. Control characters are still stripped: a one-line burnt-in
965
+ label has no use for them.
966
+
967
+ The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
968
+ removed when the process ends. It is *registered* here and written by run() only if the
969
+ command about to run actually names it, so --dry-run and the ASS route write nothing; a
970
+ printed plan therefore names a path that no longer exists once the run is over, which is the
971
+ same promise every other temp file in this skill makes.
972
+ """
973
+ cleaned = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
974
+ import hashlib
975
+ name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
976
+ if tmpdir is None:
977
+ tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
978
+ path = os.path.join(tmpdir, name)
979
+ _DRAWTEXT_PENDING[path] = cleaned
980
+ return f"textfile={escape_filter_path(path)}:expansion=none"