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.
- package/docs/contract.md +22 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +1 -1
- package/references/scripts.md +85 -10
- package/scripts/_common/__init__.py +8 -5
- package/scripts/_common/decision.py +86 -0
- package/scripts/_common/drawtext.py +125 -0
- package/scripts/_common/emoji.py +350 -0
- package/scripts/_common/fonts.py +437 -0
- package/scripts/_common/probe.py +26 -0
- package/scripts/_common/text.py +59 -1642
- package/scripts/_common/wrap.py +778 -0
- package/scripts/_contract.py +8 -3
- package/scripts/cropdetect.py +59 -1
- package/scripts/multicam.py +85 -5
- package/scripts/scenes.py +87 -2
- package/scripts/silence.py +46 -1
- package/scripts/sync.py +100 -52
|
@@ -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]
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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."""
|