ffmpeg-skill 1.17.3 → 1.18.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.
@@ -0,0 +1,437 @@
1
+ """Fonts and scripts: the per-script font tables, script detection, fontconfig lookups
2
+ (fc-match / fc-list / fc-scan) and the font-for-this-text resolution the drawing tools use.
3
+ Split out of _common.text in the refactor after 1.17.3; every body is byte-identical.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import platform
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Sequence, Tuple
14
+ from _common.emit import die, info
15
+ from _common.emoji import _is_emoji_char
16
+
17
+
18
+ def default_font_file(font_name: str) -> Optional[str]:
19
+ """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
20
+ `fontfile=<path>` instead of `font=<name>`, when possible.
21
+
22
+ On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
23
+ resolution crashes with an access violation whenever it has to resolve a font by family name
24
+ -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
25
+ confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
26
+ ignored on Windows for that reason: a fixed, near-universally-present system font is used
27
+ instead of trying to resolve the requested family (which would crash the same way).
28
+
29
+ On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
30
+ same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
31
+ just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
32
+ sidesteps the same class of crash if it exists on some build there too, but the fallback below
33
+ (returning None) is exercised routinely there, not just on failure.
34
+
35
+ Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
36
+ Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
37
+ """
38
+ if platform.system() == "Windows":
39
+ windir = os.environ.get("WINDIR", "C:\\Windows")
40
+ fonts = Path(windir) / "Fonts"
41
+ # The requested family first: a file whose name starts with the family name with spaces
42
+ # removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
43
+ # common CJK system fonts when the request looks CJK (so Japanese text does not render as
44
+ # boxes in Arial), and Arial only as the last resort.
45
+ wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
46
+ try:
47
+ files = sorted(fonts.iterdir()) if fonts.is_dir() else []
48
+ except OSError:
49
+ files = []
50
+ if wanted:
51
+ for f in files:
52
+ stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
53
+ if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
54
+ return str(f)
55
+ if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
56
+ for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
57
+ if (fonts / name).exists():
58
+ return str(fonts / name)
59
+ candidate = fonts / "arial.ttf"
60
+ return str(candidate) if candidate.exists() else None
61
+ exe = shutil.which("fc-match")
62
+ if not exe:
63
+ return None
64
+ try:
65
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=5)
66
+ except (subprocess.TimeoutExpired, OSError):
67
+ return None
68
+ if proc.returncode != 0:
69
+ return None
70
+ path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
71
+ return path if path and os.path.exists(path) else None
72
+
73
+
74
+ # --------------------------------------------------------------------------- script detection
75
+ # 1.12: non-Latin caption/overlay text used to render as tofu (empty boxes) whenever the default
76
+ # family carried no glyphs for it -- silently, because fontconfig substitutes SOMETHING for every
77
+ # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
78
+ # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
79
+ # warning (a video full of boxes is not a delivery).
80
+ SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
81
+
82
+
83
+ LANGUAGE_NAMES = {
84
+ "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
85
+ "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
86
+ "el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
87
+ }
88
+
89
+
90
+ # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
91
+ # every CJK font that claims zh carries; the rest are the plain two-letter codes).
92
+ FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
93
+ "bn": "bn", "ta": "ta", "lo": "lo"}
94
+
95
+
96
+ # Families tried in order, best first. The names are matched case-insensitively against the start
97
+ # of any family fontconfig reports for a file, so "Noto Sans CJK JP" also matches
98
+ # "Noto Sans CJK JP Black". Anything not listed still qualifies -- it just sorts after these.
99
+ PREFERRED_FAMILIES = {
100
+ "ja": ["Noto Sans CJK JP", "Noto Serif CJK JP", "Noto Sans JP", "Source Han Sans", "IPAPGothic", "IPAGothic", "IPA", "VL Gothic", "TakaoGothic", "WenQuanYi Zen Hei"],
101
+ "zh": ["Noto Sans CJK SC", "Noto Serif CJK SC", "Noto Sans SC", "Source Han Sans", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "Droid Sans Fallback"],
102
+ "ko": ["Noto Sans CJK KR", "Noto Serif CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "Nanum Gothic", "Malgun Gothic", "WenQuanYi Zen Hei"],
103
+ "ar": ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri", "Scheherazade", "DejaVu Sans", "FreeSans", "FreeSerif"],
104
+ "he": ["Noto Sans Hebrew", "Noto Serif Hebrew", "DejaVu Sans", "FreeSans", "FreeSerif"],
105
+ "hi": ["Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari", "Mangal", "Nirmala UI", "Samyak Devanagari", "FreeSans", "FreeSerif"],
106
+ "th": ["Noto Sans Thai", "Noto Serif Thai", "Loma", "Garuda", "Waree", "Umpush", "Norasi", "Sarabun", "Leelawadee UI", "FreeSerif"],
107
+ "bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
108
+ "ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
109
+ "lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
110
+ "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
111
+ "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
112
+ }
113
+
114
+
115
+ # Windows has no fontconfig: the system fonts are looked up by file name instead, best first.
116
+ WINDOWS_FONTS = {
117
+ "ko": [("malgun.ttf", "Malgun Gothic"), ("gulim.ttc", "Gulim"), ("batang.ttc", "Batang")],
118
+ "zh": [("msyh.ttc", "Microsoft YaHei"), ("simhei.ttf", "SimHei"), ("simsun.ttc", "SimSun")],
119
+ "ja": [("meiryo.ttc", "Meiryo"), ("YuGothM.ttc", "Yu Gothic Medium"), ("YuGothR.ttc", "Yu Gothic"), ("msgothic.ttc", "MS Gothic")],
120
+ "ar": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
121
+ "he": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
122
+ "hi": [("mangal.ttf", "Mangal"), ("Nirmala.ttf", "Nirmala UI"), ("NirmalaB.ttf", "Nirmala UI")],
123
+ "th": [("leelawui.ttf", "Leelawadee UI"), ("leelawad.ttf", "Leelawadee"), ("tahoma.ttf", "Tahoma")],
124
+ "bn": [("Nirmala.ttf", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
125
+ "ta": [("Nirmala.ttf", "Nirmala UI"), ("latha.ttf", "Latha")],
126
+ "lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
127
+ "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
128
+ "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
129
+ }
130
+
131
+
132
+ _SCRIPT_RANGES = (
133
+ ("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
134
+ ("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
135
+ ("han", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x2A6DF))),
136
+ ("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF))),
137
+ ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
138
+ ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
139
+ ("bn", ((0x0980, 0x09FF),)),
140
+ ("ta", ((0x0B80, 0x0BFF),)),
141
+ ("th", ((0x0E00, 0x0E7F),)),
142
+ ("lo", ((0x0E80, 0x0EFF),)),
143
+ ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
144
+ ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
145
+ )
146
+
147
+
148
+ def font_family_of_file(path: str) -> "Optional[str]":
149
+ """The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
150
+ the file directly; without fontconfig the file stem is the honest best guess."""
151
+ if not path or not os.path.isfile(path):
152
+ return None
153
+ exe = shutil.which("fc-scan")
154
+ if exe:
155
+ try:
156
+ proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
157
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
158
+ if proc.returncode == 0 and proc.stdout.strip():
159
+ return proc.stdout.strip().splitlines()[0].strip()
160
+ except (subprocess.TimeoutExpired, OSError):
161
+ pass
162
+ return Path(path).stem
163
+
164
+
165
+ def char_script(ch: str) -> str:
166
+ """The script of one character: one of SCRIPTS, or "latin" for anything else (including
167
+ digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
168
+ cp = ord(ch)
169
+ # 1.15: an emoji cluster is not Latin. detect_script() skips "emoji" the way it skips "latin",
170
+ # so font resolution still follows the letters around it.
171
+ if _is_emoji_char(ch):
172
+ return "emoji"
173
+ for name, ranges in _SCRIPT_RANGES:
174
+ for lo, hi in ranges:
175
+ if lo <= cp <= hi:
176
+ return "ja" if name == "kana" else ("zh" if name == "han" else name)
177
+ return "latin"
178
+
179
+
180
+ def detect_script(text: str, lang: "Optional[str]" = None) -> str:
181
+ """Which script `text` is written in, as one of SCRIPTS.
182
+
183
+ Hangul wins for Korean, any kana makes the whole string Japanese (Japanese mixes kana and
184
+ Han), Han alone is Chinese. Mixed text is decided by character count: the non-Latin script
185
+ with the most characters wins, ties going to whichever appeared first, and text with no
186
+ non-Latin characters at all is "latin". `lang` (a --lang/--language hint, or brand.json's
187
+ `lang`) only resolves the one ambiguity the characters genuinely cannot: Han with no kana
188
+ is Chinese by default but Japanese (or Korean hanja) when the caller says so.
189
+ """
190
+ counts: "Dict[str, int]" = {}
191
+ order: "List[str]" = []
192
+ kana = 0
193
+ for ch in text or "":
194
+ s = char_script(ch)
195
+ if s in ("latin", "emoji"):
196
+ continue
197
+ if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
198
+ kana += 1
199
+ if s not in counts:
200
+ order.append(s)
201
+ counts[s] = counts.get(s, 0) + 1
202
+ if kana: # Japanese: the Han characters in the same string are Japanese too
203
+ counts["ja"] = counts.pop("ja", 0) + counts.pop("zh", 0)
204
+ order = [s for s in order if s != "zh"]
205
+ if not counts:
206
+ return "latin"
207
+ best = max(counts, key=lambda s: (counts[s], -order.index(s)))
208
+ hint = (lang or "").strip().lower().replace("_", "-").split("-")[0]
209
+ if not kana and best == "zh" and hint in ("ja", "zh", "ko"):
210
+ return hint # Han-only text: only the caller knows whether it is Chinese, Japanese or hanja
211
+ return best
212
+
213
+
214
+ _SCRIPT_FONT_CACHE: "Dict[Tuple[str, Optional[str]], Optional[Tuple[str, str]]]" = {}
215
+
216
+
217
+ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
218
+ """(file, families) for every font fontconfig says covers `fc_lang`.
219
+
220
+ `[]` means fontconfig answered and nothing covers the language; `None` means it could not be
221
+ asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
222
+ "missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
223
+ """
224
+ exe = shutil.which("fc-list")
225
+ if not exe:
226
+ return None
227
+ try:
228
+ proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
229
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
230
+ except (subprocess.TimeoutExpired, OSError):
231
+ return None
232
+ if proc.returncode != 0:
233
+ return None
234
+ out = []
235
+ for line in proc.stdout.splitlines():
236
+ if ": " not in line:
237
+ continue
238
+ path, _, families = line.partition(": ")
239
+ path = path.strip()
240
+ if not path or not os.path.exists(path):
241
+ continue
242
+ names = [f.replace("\\-", "-").strip() for f in families.split(",") if f.strip()]
243
+ out.append((path, names or [Path(path).stem]))
244
+ return out
245
+
246
+
247
+ def _family_rank(families: "Sequence[str]", preferred: "Sequence[str]") -> int:
248
+ for i, want in enumerate(preferred):
249
+ w = want.lower()
250
+ if any(f.lower().startswith(w) for f in families):
251
+ return i
252
+ return len(preferred)
253
+
254
+
255
+ def _script_font_entry(script: str, family_hint: "Optional[str]" = None) -> "Optional[Tuple[str, str]]":
256
+ key = (script, family_hint)
257
+ if key in _SCRIPT_FONT_CACHE:
258
+ return _SCRIPT_FONT_CACHE[key]
259
+ _SCRIPT_FONT_CACHE[key] = result = _script_font_uncached(script, family_hint)
260
+ return result
261
+
262
+
263
+ FC_UNKNOWN = "unknown" # sentinel: fontconfig could not be asked (absent or failing), not "no font"
264
+
265
+
266
+ def _script_font_uncached(script: str, family_hint: "Optional[str]" = None):
267
+ """(file, family), None when nothing covers `script`, or FC_UNKNOWN when it cannot be asked."""
268
+ if script not in FC_LANG:
269
+ return None
270
+ if platform.system() == "Windows":
271
+ fonts = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
272
+ for name, family in WINDOWS_FONTS.get(script, []):
273
+ if (fonts / name).exists():
274
+ return str(fonts / name), family
275
+ return None
276
+ preferred = list(PREFERRED_FAMILIES.get(script, []))
277
+ if family_hint:
278
+ preferred.insert(0, family_hint)
279
+ candidates = _fc_list_fonts(FC_LANG[script])
280
+ if candidates is None:
281
+ return FC_UNKNOWN
282
+ if not candidates:
283
+ return None
284
+ scored = []
285
+ for path, families in candidates:
286
+ joined = " ".join(families).lower()
287
+ stem = Path(path).stem.lower()
288
+ # "Unifont Sample" is fontconfig's tofu-with-hex-digits fallback: it "covers" every script
289
+ # by drawing the code point, which is exactly the unreadable result this feature exists to
290
+ # avoid -- it is only ever chosen when nothing else covers the script at all. A *Mono* face
291
+ # is legible but wrong for a caption band, so it sorts after every proportional one.
292
+ last_resort = 1 if "unifont" in joined else 0
293
+ mono = 1 if "mono" in joined else 0
294
+ # regular weights before Bold/Italic/Oblique cuts, so a default caption is not bold by accident
295
+ styled = 1 if any(k in stem for k in ("bold", "italic", "oblique", "light", "thin", "black")) else 0
296
+ scored.append((last_resort, _family_rank(families, preferred), mono, styled, path, families[0]))
297
+ scored.sort(key=lambda row: (row[0], row[1], row[2], row[3], row[4]))
298
+ best = scored[0]
299
+ return best[4], best[5]
300
+
301
+
302
+ def font_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
303
+ """A font FILE path that covers `script`, or None when this machine has none.
304
+
305
+ Linux/macOS ask fontconfig (`fc-list :lang=xx file family`) and rank what it reports by the
306
+ PREFERRED_FAMILIES table; Windows has no fontconfig, so the known system files are probed by
307
+ name. Cached per process: a caption job resolves the same script for every cue.
308
+ """
309
+ entry = _script_font_entry(script, family_hint)
310
+ return entry[0] if entry and entry is not FC_UNKNOWN else None
311
+
312
+
313
+ def font_family_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
314
+ """The family NAME of font_for_script()'s file -- what libass wants in an ASS Fontname."""
315
+ entry = _script_font_entry(script, family_hint)
316
+ return entry[1] if entry and entry is not FC_UNKNOWN else None
317
+
318
+
319
+ def script_font_status(script: str) -> str:
320
+ """"available" (a font file covers `script`), "missing" (fontconfig answered, none does) or
321
+ "unknown" (there is no working fontconfig to ask). Only "missing" is a refusal."""
322
+ entry = _script_font_entry(script)
323
+ if entry is FC_UNKNOWN:
324
+ return "unknown"
325
+ return "available" if entry else "missing"
326
+
327
+
328
+ def font_covers_script(font_name: str, script: str) -> bool:
329
+ """Whether the installed family `font_name` actually carries glyphs for `script`.
330
+
331
+ `fc-match` cannot answer this: given a family that IS installed it returns that family
332
+ whatever `:lang=` asks for (verified -- `fc-match "DejaVu Sans:lang=zh-cn"` answers
333
+ "DejaVu Sans", which has no Han glyphs at all). `fc-list :lang=xx:family=<name>` does: it
334
+ lists only files that satisfy BOTH, so an empty listing is the proof of no coverage. Unknown
335
+ (no fontconfig at all) counts as covering: a warning nobody can verify is worse than none.
336
+ """
337
+ if script not in FC_LANG or not font_name:
338
+ return True
339
+ exe = shutil.which("fc-list")
340
+ if not exe:
341
+ return True
342
+ try:
343
+ proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
344
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
345
+ except (subprocess.TimeoutExpired, OSError):
346
+ return True
347
+ if proc.returncode != 0:
348
+ return True
349
+ return bool(proc.stdout.strip())
350
+
351
+
352
+ # Named flags differ per tool: overlay.py and graphics.py take a font FILE, caption.py takes a
353
+ # directory of faces plus the family name -- naming a flag the tool does not have is worse than
354
+ # naming none, so the hint says both (review 10).
355
+ FONT_FLAG_HINT = "pass a font file (--font-file on overlay.py/graphics.py, --fonts-dir with --font on caption.py)"
356
+
357
+
358
+ FONT_INSTALL_HINT = ("install fonts-noto-cjk / fonts-noto-core (apt), "
359
+ "brew install --cask font-noto-sans-cjk / font-noto-sans-arabic (mac), or "
360
+ + FONT_FLAG_HINT)
361
+
362
+
363
+ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
364
+ """Does any font under `fonts_dir` cover `script`? None when it cannot be checked.
365
+
366
+ `--fonts-dir` says "also look here", not "this exact face", so it must not switch the
367
+ coverage guarantee off. fontconfig's `fc-scan` reads the files directly (no cache, no
368
+ installed-font database), which is exactly the question: `%{lang}` lists the languages each
369
+ face claims.
370
+ """
371
+ if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
372
+ return None
373
+ exe = shutil.which("fc-scan")
374
+ if not exe:
375
+ return None
376
+ try:
377
+ proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
378
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
379
+ except (subprocess.TimeoutExpired, OSError):
380
+ return None
381
+ if proc.returncode != 0:
382
+ return None
383
+ want = FC_LANG[script].lower()
384
+ for line in proc.stdout.splitlines():
385
+ if want in [tag.strip().lower() for tag in line.split("|")]:
386
+ return True
387
+ return False
388
+
389
+
390
+ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Optional[str]" = None,
391
+ font_explicit: bool = False, font_file: "Optional[str]" = None,
392
+ fonts_dir: "Optional[str]" = None
393
+ ) -> "Tuple[str, Optional[str], Optional[str]]":
394
+ """(script, font file, family) to draw `text` with, resolving by script when nothing explicit
395
+ was asked for.
396
+
397
+ Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
398
+ --font-file, an explicit --font (which is kept even when fontconfig says it does not cover
399
+ the script -- with one info line saying so, because overriding a user's stated font silently
400
+ is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
401
+ file is returned with ONE info line naming it.
402
+
403
+ A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
404
+ with no working fontconfig at all answers "unknown", not "missing": the job continues with
405
+ the caller's font -- libass and drawtext still have their own font backends -- and one info
406
+ line says the coverage could not be verified.
407
+ """
408
+ script = detect_script(text or "", lang)
409
+ if script == "latin":
410
+ return script, None, None
411
+ if font_file:
412
+ return script, None, None
413
+ if font_explicit and font:
414
+ if not font_covers_script(font, script):
415
+ info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
416
+ f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
417
+ return script, None, None
418
+ if fonts_dir:
419
+ covered = fonts_dir_covers_script(fonts_dir, script)
420
+ if covered:
421
+ return script, None, None
422
+ if covered is None:
423
+ info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
424
+ "(no fc-scan on this machine); using it as given")
425
+ return script, None, None
426
+ info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
427
+ "picking one by script instead (the directory is still searched first)")
428
+ entry = _script_font_entry(script)
429
+ if entry is FC_UNKNOWN:
430
+ info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
431
+ "(no working fontconfig); rendering with the font as given -- "
432
+ "doctor --json .fonts.scripts reports what is known")
433
+ return script, None, None
434
+ if not entry:
435
+ die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")
436
+ info(f"font: {entry[0]} (covers {script})")
437
+ return script, entry[0], entry[1]
@@ -77,6 +77,32 @@ MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m
77
77
  ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
78
78
 
79
79
 
80
+ def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start: float = 0.0,
81
+ seconds: "Optional[float]" = None, check: bool = True) -> "List[bytes]":
82
+ """Decode `path` to raw 8-bit grayscale frames at a low `fps`/`width`x`height`, one ffmpeg
83
+ pass to stdout. Used by scenes.py --shots (per-shot flow label) and cropdetect.py
84
+ --motion-centre (motion centroid): both need pixel data, not a filter's own summary number,
85
+ but at 1.18.0's resolutions (tens of pixels a side, a few fps) a whole shot is a few KB, so
86
+ piping raw frames through Python stays cheap and needs no extra dependency."""
87
+ ffmpeg = require_tool("ffmpeg")
88
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
89
+ if start:
90
+ cmd += ["-ss", f"{start:.3f}"]
91
+ cmd += ["-i", path]
92
+ if seconds is not None:
93
+ cmd += ["-t", f"{max(0.0, seconds):.3f}"]
94
+ cmd += ["-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
95
+ "-f", "rawvideo", "-"]
96
+ proc = run_analysis(cmd, check=False, text=False)
97
+ if proc.returncode != 0 or not proc.stdout:
98
+ if check:
99
+ die(f"could not decode frames from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
100
+ return []
101
+ frame_size = width * height
102
+ data = proc.stdout
103
+ return [data[i:i + frame_size] for i in range(0, len(data) - frame_size + 1, frame_size)]
104
+
105
+
80
106
  def _output_failed(path: str, why: str) -> "None":
81
107
  """An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
82
108
  0-byte file behind that a later step could mistake for a result."""