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
package/scripts/_common/text.py
CHANGED
|
@@ -1,1655 +1,72 @@
|
|
|
1
|
-
"""Text people can see
|
|
2
|
-
|
|
1
|
+
"""Text people can see -- since the refactor after 1.17.3 a re-export shim over four modules:
|
|
2
|
+
|
|
3
|
+
fonts.py font resolution per script: the tables, char_script()/detect_script(), the
|
|
4
|
+
fc-match / fc-list / fc-scan lookups and script_font_for_text()
|
|
5
|
+
emoji.py emoji clusters, the PNG assets, emoji_support() and emoji_filter_chain()
|
|
6
|
+
drawtext.py drawtext option building and escaping, the shaping-library probe
|
|
7
|
+
wrap.py the advance table, the caption wrapper and fit_size()
|
|
8
|
+
|
|
9
|
+
`from _common.text import x` and `_common.text.x` mean what they always meant. Rebinding a name
|
|
10
|
+
on this module (`mock.patch("_common.text.<name>")`) rebinds it on the module that defines it,
|
|
11
|
+
the way the `_common` facade does, so a test that stands a helper up differently still reaches
|
|
12
|
+
the one the code reads at call time.
|
|
3
13
|
"""
|
|
4
14
|
from __future__ import annotations
|
|
5
15
|
|
|
6
|
-
import
|
|
7
|
-
import
|
|
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
|
|
16
|
+
import sys
|
|
17
|
+
import types as _types
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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, encoding="utf-8", errors="replace", 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))),
|
|
19
|
+
from _common.fonts import (
|
|
20
|
+
default_font_file, SCRIPTS, LANGUAGE_NAMES, FC_LANG, PREFERRED_FAMILIES, WINDOWS_FONTS, _SCRIPT_RANGES,
|
|
21
|
+
font_family_of_file, char_script, detect_script, _SCRIPT_FONT_CACHE, _fc_list_fonts, _family_rank,
|
|
22
|
+
_script_font_entry, FC_UNKNOWN, _script_font_uncached, font_for_script, font_family_for_script,
|
|
23
|
+
script_font_status, font_covers_script, FONT_FLAG_HINT, FONT_INSTALL_HINT, fonts_dir_covers_script,
|
|
24
|
+
script_font_for_text,
|
|
155
25
|
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
26
|
+
from _common.emoji import (
|
|
27
|
+
EMOJI_RANGES, _EMOJI_TAIL, _EMOJI_REGIONAL, _ZWJ, _VS15, _VS16, _KEYCAP, _KEYCAP_BASES, _is_emoji_char,
|
|
28
|
+
_is_emoji_base, emoji_clusters, has_emoji, emoji_codepoint_name, _emoji_name_candidates, emoji_asset_for,
|
|
29
|
+
EMOJI_ASSET_HINT, _EMOJI_COLOR_FAMILIES, _EMOJI_SUPPORT_CACHE, _emoji_color_font, _libass_color_probe,
|
|
30
|
+
emoji_support, resolve_emoji_assets, emoji_filter_chain,
|
|
31
|
+
)
|
|
32
|
+
from _common.drawtext import (
|
|
33
|
+
drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
34
|
+
escape_drawtext, drawtext_text_opts,
|
|
35
|
+
)
|
|
36
|
+
from _common.wrap import (
|
|
37
|
+
ADVANCE_EM, NO_SPACE_SCRIPTS, NO_BOUNDARY_SCRIPTS, LATIN_EM, LEADING_VOWELS, _is_mark, _char_em,
|
|
38
|
+
text_width_em, SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, JA_PARTICLES, JA_PARTICLE_WORDS,
|
|
39
|
+
JA_SENTENCE_END, JA_NO_LINE_START, JA_NO_LINE_END, FUNCTION_WORDS, _FUNCTION_WORDS_ANY, PENALTY_FORBIDDEN,
|
|
40
|
+
PENALTY_OKURIGANA, PENALTY_FUNCTION_WORD, PENALTY_IDEOGRAPHS, PENALTY_NEUTRAL, PENALTY_FUNCTION_WORD_START,
|
|
41
|
+
PENALTY_PARTICLE, PENALTY_SENTENCE_END, _HYPHENS, _atoms, _split_hyphens, _join, _break_spaced, _is_kana,
|
|
42
|
+
_is_katakana_run, _is_hiragana, _is_ideograph, _is_weak_line, _function_words, _bare_word, _particle_starts,
|
|
43
|
+
_particle_ends, break_penalty, _cut_penalty, best_break, _fix_orphans, _fix_weak_lines, _rebalance,
|
|
44
|
+
_rebalance_phrase, _greedy_chunks, _balance, wrap_text, wrap_variants, MIN_CAPTION_FRACTION,
|
|
45
|
+
ASS_SCRIPT_HEIGHT, line_em_for_size, fit_size, ass_units_local,
|
|
170
46
|
)
|
|
171
47
|
|
|
48
|
+
_PARTS = tuple(sys.modules["_common." + _n] for _n in ("fonts", "emoji", "drawtext", "wrap"))
|
|
172
49
|
|
|
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 `abcdef`
|
|
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, encoding="utf-8", errors="replace", 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
|
-
# Scripts a line may break inside a run of, one character at a time. Thai is deliberately NOT
|
|
444
|
-
# here since 1.16.1: it writes no space inside a phrase, and without a dictionary the wrapper
|
|
445
|
-
# cannot see where one word ends -- every character-level break it took in eval 17 landed inside
|
|
446
|
-
# a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
|
|
447
|
-
# writer put there; an over-long run stays long on its own line, the rule long Latin words
|
|
448
|
-
# already follow.
|
|
449
|
-
NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
|
|
450
|
-
NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
# Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
|
|
454
|
-
# enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
|
|
455
|
-
# against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
|
|
456
|
-
# the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
|
|
457
|
-
# past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
|
|
458
|
-
# un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
|
|
459
|
-
# 0.57 lowercase and anything else Latin-ish).
|
|
460
|
-
LATIN_EM = {
|
|
461
|
-
' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
|
|
462
|
-
'(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
|
|
463
|
-
'1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
|
|
464
|
-
'9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
|
|
465
|
-
'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
|
|
466
|
-
'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
|
|
467
|
-
'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
|
|
468
|
-
'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
|
|
469
|
-
'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
|
|
470
|
-
'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
|
|
471
|
-
'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,
|
|
472
|
-
'{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
# Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
|
|
477
|
-
# between them and the base that follows.
|
|
478
|
-
LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def _is_mark(ch: str) -> bool:
|
|
482
|
-
"""A character that hangs off the one before it: a combining mark (any script) or one of the
|
|
483
|
-
Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
|
|
484
|
-
return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def _char_em(ch: str) -> float:
|
|
488
|
-
# CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
|
|
489
|
-
# full-width grid as the ideographs they sit between, even though they are not "Han" to a
|
|
490
|
-
# script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
|
|
491
|
-
cp = ord(ch)
|
|
492
|
-
# A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
|
|
493
|
-
# charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
|
|
494
|
-
if unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Cf"):
|
|
495
|
-
# "Cf" catches ZWJ/ZWNJ: an Indic joiner is orthography, and it advances the pen by
|
|
496
|
-
# nothing -- charging it a full em (it used to count as "emoji") shrank a Hindi line.
|
|
497
|
-
return 0.0
|
|
498
|
-
if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
|
|
499
|
-
return 1.0
|
|
500
|
-
script = char_script(ch)
|
|
501
|
-
if script == "emoji":
|
|
502
|
-
# 1.15: an emoji is drawn (or reserved) at a full em box, not at Latin's 0.57 -- counting
|
|
503
|
-
# it as Latin overflowed the safe area on an emoji-heavy line.
|
|
504
|
-
return 1.0
|
|
505
|
-
if script == "latin":
|
|
506
|
-
if ch in LATIN_EM:
|
|
507
|
-
return LATIN_EM[ch]
|
|
508
|
-
if ch.isupper() or ch.isdigit():
|
|
509
|
-
return 0.7
|
|
510
|
-
return 0.57
|
|
511
|
-
return ADVANCE_EM.get(script, 0.55)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
def text_width_em(text: str, emoji_em: float = 1.0) -> float:
|
|
515
|
-
"""Width of `text` in em, from the per-script average advance table. `emoji_em` is what one
|
|
516
|
-
emoji cluster costs (--emoji-scale), so a wrap counts the box that will actually be drawn."""
|
|
517
|
-
total = 0.0
|
|
518
|
-
spans = {i: len(c) for i, c in emoji_clusters(text)}
|
|
519
|
-
i = 0
|
|
520
|
-
while i < len(text):
|
|
521
|
-
if i in spans:
|
|
522
|
-
total += emoji_em
|
|
523
|
-
i += spans[i]
|
|
524
|
-
continue
|
|
525
|
-
total += _char_em(text[i])
|
|
526
|
-
i += 1
|
|
527
|
-
return total
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
def emoji_filter_chain(plan, base_label, out_label, first_input=1):
|
|
531
|
-
"""(chains, inputs) that composite the planned PNGs on top of `base_label`.
|
|
532
|
-
|
|
533
|
-
`inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
|
|
534
|
-
ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
|
|
535
|
-
a timeline the fade filter can move along; one that does not is a plain `-i`).
|
|
536
|
-
"""
|
|
537
|
-
overlays = plan.get("overlays") or []
|
|
538
|
-
if not overlays:
|
|
539
|
-
return [], []
|
|
540
|
-
# Group by everything that makes two uses of the same PNG a different STREAM: the fade is
|
|
541
|
-
# expressed in the cue's own timeline, so two cues cannot share one faded input.
|
|
542
|
-
def _key(o):
|
|
543
|
-
fades = (round(float(o.get("fade_in") or 0.0), 3), round(float(o.get("fade_out") or 0.0), 3))
|
|
544
|
-
window = (round(float(o["start"]), 3), round(float(o["end"]), 3)) if any(fades) else (None, None)
|
|
545
|
-
return (o["asset"], o["box"]) + fades + window
|
|
546
|
-
|
|
547
|
-
groups: "List[Tuple]" = []
|
|
548
|
-
for o in overlays:
|
|
549
|
-
if _key(o) not in groups:
|
|
550
|
-
groups.append(_key(o))
|
|
551
|
-
chains: List[str] = []
|
|
552
|
-
inputs: "List[List[str]]" = []
|
|
553
|
-
pads: "Dict[Tuple, List[str]]" = {}
|
|
554
|
-
for k, key in enumerate(groups):
|
|
555
|
-
asset, box, fin, fout, gstart, gend = key
|
|
556
|
-
uses = [o for o in overlays if _key(o) == key]
|
|
557
|
-
idx = first_input + k
|
|
558
|
-
labels = [f"e{k}_{j}" for j in range(len(uses))]
|
|
559
|
-
chain = f"[{idx}:v]format=rgba,scale={box}:{box}"
|
|
560
|
-
if fin or fout:
|
|
561
|
-
# -loop 1 gives the still an advancing timeline on the SAME clock as the main video,
|
|
562
|
-
# so the fade times below are the cue's own seconds. The emoji then appears and
|
|
563
|
-
# leaves with the text instead of popping in against a fading line.
|
|
564
|
-
# -t bounds the loop at the cue's end: an unbounded looped still never EOFs and the
|
|
565
|
-
# whole encode hangs (overlay keeps pulling from it after the main video is done).
|
|
566
|
-
inputs.append(["-loop", "1", "-t", f"{gend:.3f}", "-i", asset])
|
|
567
|
-
if fin:
|
|
568
|
-
chain += f",fade=t=in:st={gstart:.3f}:d={fin:.3f}:alpha=1"
|
|
569
|
-
if fout:
|
|
570
|
-
chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
|
|
571
|
-
else:
|
|
572
|
-
inputs.append(["-i", asset])
|
|
573
|
-
if len(labels) > 1:
|
|
574
|
-
chain += f",split={len(labels)}"
|
|
575
|
-
chains.append(chain + "".join(f"[{l}]" for l in labels))
|
|
576
|
-
pads[key] = labels
|
|
577
|
-
cur = base_label
|
|
578
|
-
remaining = {key: list(v) for key, v in pads.items()}
|
|
579
|
-
for j, o in enumerate(overlays):
|
|
580
|
-
label = remaining[_key(o)].pop(0)
|
|
581
|
-
nxt = out_label if j == len(overlays) - 1 else f"eov{j}"
|
|
582
|
-
x = o["x"]
|
|
583
|
-
x = f"'{x}'" if isinstance(x, str) else x
|
|
584
|
-
# No eof_action=pass here: a PNG input is a SINGLE frame at pts 0, and eof_action=pass
|
|
585
|
-
# switches off overlay's default "hold the last frame of the secondary input", so the
|
|
586
|
-
# asset would be composited on frame 0 only and vanish for the rest of the cue (that is
|
|
587
|
-
# exactly what shipped first). eof_action=repeat (the default) holds the still for the
|
|
588
|
-
# whole timeline; enable= is what confines it to the cue's window.
|
|
589
|
-
chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
|
|
590
|
-
f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
|
|
591
|
-
cur = nxt
|
|
592
|
-
return chains, inputs
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
# --------------------------------------------------------------------------- shaping (1.15)
|
|
596
|
-
# Scripts whose correct rendering needs harfbuzz-class reordering and re-clustering (Indic matras,
|
|
597
|
-
# Thai/Lao mark stacking). drawtext does NOT use harfbuzz even in an --enable-libharfbuzz build, so
|
|
598
|
-
# these come out wrong through drawtext on every build and must go through libass. Arabic and
|
|
599
|
-
# Hebrew are NOT here: drawtext's text_shaping uses fribidi, which does bidi and Arabic joining
|
|
600
|
-
# correctly -- they only join this set on a build compiled without fribidi.
|
|
601
|
-
SHAPING_SCRIPTS = frozenset({"hi", "bn", "ta", "te", "kn", "ml", "gu", "pa", "si", "th", "lo", "km", "my"})
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
BIDI_SCRIPTS = frozenset({"ar", "he"})
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
_SHAPING_BUILD_CACHE: "Dict[str, bool]" = {}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def drawtext_shaping() -> "Dict[str, bool]":
|
|
611
|
-
"""Which shaping libraries THIS ffmpeg was built with, from -buildconf (falling back to the
|
|
612
|
-
`configuration:` line of -version). Cached per process."""
|
|
613
|
-
if _SHAPING_BUILD_CACHE:
|
|
614
|
-
return dict(_SHAPING_BUILD_CACHE)
|
|
615
|
-
text = ""
|
|
616
|
-
exe = shutil.which("ffmpeg")
|
|
617
|
-
if exe:
|
|
618
|
-
for flag in ("-buildconf", "-version"):
|
|
619
|
-
try:
|
|
620
|
-
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
|
|
621
|
-
stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
622
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
623
|
-
break
|
|
624
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
625
|
-
text = proc.stdout
|
|
626
|
-
break
|
|
627
|
-
_SHAPING_BUILD_CACHE.update({"fribidi": "--enable-libfribidi" in text,
|
|
628
|
-
"harfbuzz": "--enable-libharfbuzz" in text})
|
|
629
|
-
return dict(_SHAPING_BUILD_CACHE)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def needs_shaping(script: str) -> bool:
|
|
633
|
-
"""Whether drawtext would render `script` wrongly on this build."""
|
|
634
|
-
if script in SHAPING_SCRIPTS:
|
|
635
|
-
return True
|
|
636
|
-
return script in BIDI_SCRIPTS and not drawtext_shaping()["fribidi"]
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
def font_family_of_file(path: str) -> "Optional[str]":
|
|
640
|
-
"""The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
|
|
641
|
-
the file directly; without fontconfig the file stem is the honest best guess."""
|
|
642
|
-
if not path or not os.path.isfile(path):
|
|
643
|
-
return None
|
|
644
|
-
exe = shutil.which("fc-scan")
|
|
645
|
-
if exe:
|
|
646
|
-
try:
|
|
647
|
-
proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
|
|
648
|
-
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
649
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
650
|
-
return proc.stdout.strip().splitlines()[0].strip()
|
|
651
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
652
|
-
pass
|
|
653
|
-
return Path(path).stem
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def char_script(ch: str) -> str:
|
|
657
|
-
"""The script of one character: one of SCRIPTS, or "latin" for anything else (including
|
|
658
|
-
digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
|
|
659
|
-
cp = ord(ch)
|
|
660
|
-
# 1.15: an emoji cluster is not Latin. detect_script() skips "emoji" the way it skips "latin",
|
|
661
|
-
# so font resolution still follows the letters around it.
|
|
662
|
-
if _is_emoji_char(ch):
|
|
663
|
-
return "emoji"
|
|
664
|
-
for name, ranges in _SCRIPT_RANGES:
|
|
665
|
-
for lo, hi in ranges:
|
|
666
|
-
if lo <= cp <= hi:
|
|
667
|
-
return "ja" if name == "kana" else ("zh" if name == "han" else name)
|
|
668
|
-
return "latin"
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def detect_script(text: str, lang: "Optional[str]" = None) -> str:
|
|
672
|
-
"""Which script `text` is written in, as one of SCRIPTS.
|
|
673
|
-
|
|
674
|
-
Hangul wins for Korean, any kana makes the whole string Japanese (Japanese mixes kana and
|
|
675
|
-
Han), Han alone is Chinese. Mixed text is decided by character count: the non-Latin script
|
|
676
|
-
with the most characters wins, ties going to whichever appeared first, and text with no
|
|
677
|
-
non-Latin characters at all is "latin". `lang` (a --lang/--language hint, or brand.json's
|
|
678
|
-
`lang`) only resolves the one ambiguity the characters genuinely cannot: Han with no kana
|
|
679
|
-
is Chinese by default but Japanese (or Korean hanja) when the caller says so.
|
|
680
|
-
"""
|
|
681
|
-
counts: "Dict[str, int]" = {}
|
|
682
|
-
order: "List[str]" = []
|
|
683
|
-
kana = 0
|
|
684
|
-
for ch in text or "":
|
|
685
|
-
s = char_script(ch)
|
|
686
|
-
if s in ("latin", "emoji"):
|
|
687
|
-
continue
|
|
688
|
-
if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
|
|
689
|
-
kana += 1
|
|
690
|
-
if s not in counts:
|
|
691
|
-
order.append(s)
|
|
692
|
-
counts[s] = counts.get(s, 0) + 1
|
|
693
|
-
if kana: # Japanese: the Han characters in the same string are Japanese too
|
|
694
|
-
counts["ja"] = counts.pop("ja", 0) + counts.pop("zh", 0)
|
|
695
|
-
order = [s for s in order if s != "zh"]
|
|
696
|
-
if not counts:
|
|
697
|
-
return "latin"
|
|
698
|
-
best = max(counts, key=lambda s: (counts[s], -order.index(s)))
|
|
699
|
-
hint = (lang or "").strip().lower().replace("_", "-").split("-")[0]
|
|
700
|
-
if not kana and best == "zh" and hint in ("ja", "zh", "ko"):
|
|
701
|
-
return hint # Han-only text: only the caller knows whether it is Chinese, Japanese or hanja
|
|
702
|
-
return best
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
_SCRIPT_FONT_CACHE: "Dict[Tuple[str, Optional[str]], Optional[Tuple[str, str]]]" = {}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
|
|
709
|
-
"""(file, families) for every font fontconfig says covers `fc_lang`.
|
|
710
|
-
|
|
711
|
-
`[]` means fontconfig answered and nothing covers the language; `None` means it could not be
|
|
712
|
-
asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
|
|
713
|
-
"missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
|
|
714
|
-
"""
|
|
715
|
-
exe = shutil.which("fc-list")
|
|
716
|
-
if not exe:
|
|
717
|
-
return None
|
|
718
|
-
try:
|
|
719
|
-
proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
|
|
720
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
721
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
722
|
-
return None
|
|
723
|
-
if proc.returncode != 0:
|
|
724
|
-
return None
|
|
725
|
-
out = []
|
|
726
|
-
for line in proc.stdout.splitlines():
|
|
727
|
-
if ": " not in line:
|
|
728
|
-
continue
|
|
729
|
-
path, _, families = line.partition(": ")
|
|
730
|
-
path = path.strip()
|
|
731
|
-
if not path or not os.path.exists(path):
|
|
732
|
-
continue
|
|
733
|
-
names = [f.replace("\\-", "-").strip() for f in families.split(",") if f.strip()]
|
|
734
|
-
out.append((path, names or [Path(path).stem]))
|
|
735
|
-
return out
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
def _family_rank(families: "Sequence[str]", preferred: "Sequence[str]") -> int:
|
|
739
|
-
for i, want in enumerate(preferred):
|
|
740
|
-
w = want.lower()
|
|
741
|
-
if any(f.lower().startswith(w) for f in families):
|
|
742
|
-
return i
|
|
743
|
-
return len(preferred)
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
def _script_font_entry(script: str, family_hint: "Optional[str]" = None) -> "Optional[Tuple[str, str]]":
|
|
747
|
-
key = (script, family_hint)
|
|
748
|
-
if key in _SCRIPT_FONT_CACHE:
|
|
749
|
-
return _SCRIPT_FONT_CACHE[key]
|
|
750
|
-
_SCRIPT_FONT_CACHE[key] = result = _script_font_uncached(script, family_hint)
|
|
751
|
-
return result
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
FC_UNKNOWN = "unknown" # sentinel: fontconfig could not be asked (absent or failing), not "no font"
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
def _script_font_uncached(script: str, family_hint: "Optional[str]" = None):
|
|
758
|
-
"""(file, family), None when nothing covers `script`, or FC_UNKNOWN when it cannot be asked."""
|
|
759
|
-
if script not in FC_LANG:
|
|
760
|
-
return None
|
|
761
|
-
if platform.system() == "Windows":
|
|
762
|
-
fonts = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
|
|
763
|
-
for name, family in WINDOWS_FONTS.get(script, []):
|
|
764
|
-
if (fonts / name).exists():
|
|
765
|
-
return str(fonts / name), family
|
|
766
|
-
return None
|
|
767
|
-
preferred = list(PREFERRED_FAMILIES.get(script, []))
|
|
768
|
-
if family_hint:
|
|
769
|
-
preferred.insert(0, family_hint)
|
|
770
|
-
candidates = _fc_list_fonts(FC_LANG[script])
|
|
771
|
-
if candidates is None:
|
|
772
|
-
return FC_UNKNOWN
|
|
773
|
-
if not candidates:
|
|
774
|
-
return None
|
|
775
|
-
scored = []
|
|
776
|
-
for path, families in candidates:
|
|
777
|
-
joined = " ".join(families).lower()
|
|
778
|
-
stem = Path(path).stem.lower()
|
|
779
|
-
# "Unifont Sample" is fontconfig's tofu-with-hex-digits fallback: it "covers" every script
|
|
780
|
-
# by drawing the code point, which is exactly the unreadable result this feature exists to
|
|
781
|
-
# avoid -- it is only ever chosen when nothing else covers the script at all. A *Mono* face
|
|
782
|
-
# is legible but wrong for a caption band, so it sorts after every proportional one.
|
|
783
|
-
last_resort = 1 if "unifont" in joined else 0
|
|
784
|
-
mono = 1 if "mono" in joined else 0
|
|
785
|
-
# regular weights before Bold/Italic/Oblique cuts, so a default caption is not bold by accident
|
|
786
|
-
styled = 1 if any(k in stem for k in ("bold", "italic", "oblique", "light", "thin", "black")) else 0
|
|
787
|
-
scored.append((last_resort, _family_rank(families, preferred), mono, styled, path, families[0]))
|
|
788
|
-
scored.sort(key=lambda row: (row[0], row[1], row[2], row[3], row[4]))
|
|
789
|
-
best = scored[0]
|
|
790
|
-
return best[4], best[5]
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
def font_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
|
|
794
|
-
"""A font FILE path that covers `script`, or None when this machine has none.
|
|
795
|
-
|
|
796
|
-
Linux/macOS ask fontconfig (`fc-list :lang=xx file family`) and rank what it reports by the
|
|
797
|
-
PREFERRED_FAMILIES table; Windows has no fontconfig, so the known system files are probed by
|
|
798
|
-
name. Cached per process: a caption job resolves the same script for every cue.
|
|
799
|
-
"""
|
|
800
|
-
entry = _script_font_entry(script, family_hint)
|
|
801
|
-
return entry[0] if entry and entry is not FC_UNKNOWN else None
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def font_family_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
|
|
805
|
-
"""The family NAME of font_for_script()'s file -- what libass wants in an ASS Fontname."""
|
|
806
|
-
entry = _script_font_entry(script, family_hint)
|
|
807
|
-
return entry[1] if entry and entry is not FC_UNKNOWN else None
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
def script_font_status(script: str) -> str:
|
|
811
|
-
""""available" (a font file covers `script`), "missing" (fontconfig answered, none does) or
|
|
812
|
-
"unknown" (there is no working fontconfig to ask). Only "missing" is a refusal."""
|
|
813
|
-
entry = _script_font_entry(script)
|
|
814
|
-
if entry is FC_UNKNOWN:
|
|
815
|
-
return "unknown"
|
|
816
|
-
return "available" if entry else "missing"
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
def font_covers_script(font_name: str, script: str) -> bool:
|
|
820
|
-
"""Whether the installed family `font_name` actually carries glyphs for `script`.
|
|
821
|
-
|
|
822
|
-
`fc-match` cannot answer this: given a family that IS installed it returns that family
|
|
823
|
-
whatever `:lang=` asks for (verified -- `fc-match "DejaVu Sans:lang=zh-cn"` answers
|
|
824
|
-
"DejaVu Sans", which has no Han glyphs at all). `fc-list :lang=xx:family=<name>` does: it
|
|
825
|
-
lists only files that satisfy BOTH, so an empty listing is the proof of no coverage. Unknown
|
|
826
|
-
(no fontconfig at all) counts as covering: a warning nobody can verify is worse than none.
|
|
827
|
-
"""
|
|
828
|
-
if script not in FC_LANG or not font_name:
|
|
829
|
-
return True
|
|
830
|
-
exe = shutil.which("fc-list")
|
|
831
|
-
if not exe:
|
|
832
|
-
return True
|
|
833
|
-
try:
|
|
834
|
-
proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
|
|
835
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
836
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
837
|
-
return True
|
|
838
|
-
if proc.returncode != 0:
|
|
839
|
-
return True
|
|
840
|
-
return bool(proc.stdout.strip())
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
# Named flags differ per tool: overlay.py and graphics.py take a font FILE, caption.py takes a
|
|
844
|
-
# directory of faces plus the family name -- naming a flag the tool does not have is worse than
|
|
845
|
-
# naming none, so the hint says both (review 10).
|
|
846
|
-
FONT_FLAG_HINT = "pass a font file (--font-file on overlay.py/graphics.py, --fonts-dir with --font on caption.py)"
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
FONT_INSTALL_HINT = ("install fonts-noto-cjk / fonts-noto-core (apt), "
|
|
850
|
-
"brew install --cask font-noto-sans-cjk / font-noto-sans-arabic (mac), or "
|
|
851
|
-
+ FONT_FLAG_HINT)
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
|
|
855
|
-
"""Does any font under `fonts_dir` cover `script`? None when it cannot be checked.
|
|
856
|
-
|
|
857
|
-
`--fonts-dir` says "also look here", not "this exact face", so it must not switch the
|
|
858
|
-
coverage guarantee off. fontconfig's `fc-scan` reads the files directly (no cache, no
|
|
859
|
-
installed-font database), which is exactly the question: `%{lang}` lists the languages each
|
|
860
|
-
face claims.
|
|
861
|
-
"""
|
|
862
|
-
if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
|
|
863
|
-
return None
|
|
864
|
-
exe = shutil.which("fc-scan")
|
|
865
|
-
if not exe:
|
|
866
|
-
return None
|
|
867
|
-
try:
|
|
868
|
-
proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
|
|
869
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
870
|
-
except (subprocess.TimeoutExpired, OSError):
|
|
871
|
-
return None
|
|
872
|
-
if proc.returncode != 0:
|
|
873
|
-
return None
|
|
874
|
-
want = FC_LANG[script].lower()
|
|
875
|
-
for line in proc.stdout.splitlines():
|
|
876
|
-
if want in [tag.strip().lower() for tag in line.split("|")]:
|
|
877
|
-
return True
|
|
878
|
-
return False
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Optional[str]" = None,
|
|
882
|
-
font_explicit: bool = False, font_file: "Optional[str]" = None,
|
|
883
|
-
fonts_dir: "Optional[str]" = None
|
|
884
|
-
) -> "Tuple[str, Optional[str], Optional[str]]":
|
|
885
|
-
"""(script, font file, family) to draw `text` with, resolving by script when nothing explicit
|
|
886
|
-
was asked for.
|
|
887
|
-
|
|
888
|
-
Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
|
|
889
|
-
--font-file, an explicit --font (which is kept even when fontconfig says it does not cover
|
|
890
|
-
the script -- with one info line saying so, because overriding a user's stated font silently
|
|
891
|
-
is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
|
|
892
|
-
file is returned with ONE info line naming it.
|
|
893
|
-
|
|
894
|
-
A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
|
|
895
|
-
with no working fontconfig at all answers "unknown", not "missing": the job continues with
|
|
896
|
-
the caller's font -- libass and drawtext still have their own font backends -- and one info
|
|
897
|
-
line says the coverage could not be verified.
|
|
898
|
-
"""
|
|
899
|
-
script = detect_script(text or "", lang)
|
|
900
|
-
if script == "latin":
|
|
901
|
-
return script, None, None
|
|
902
|
-
if font_file:
|
|
903
|
-
return script, None, None
|
|
904
|
-
if font_explicit and font:
|
|
905
|
-
if not font_covers_script(font, script):
|
|
906
|
-
info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
|
|
907
|
-
f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
|
|
908
|
-
return script, None, None
|
|
909
|
-
if fonts_dir:
|
|
910
|
-
covered = fonts_dir_covers_script(fonts_dir, script)
|
|
911
|
-
if covered:
|
|
912
|
-
return script, None, None
|
|
913
|
-
if covered is None:
|
|
914
|
-
info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
|
|
915
|
-
"(no fc-scan on this machine); using it as given")
|
|
916
|
-
return script, None, None
|
|
917
|
-
info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
|
|
918
|
-
"picking one by script instead (the directory is still searched first)")
|
|
919
|
-
entry = _script_font_entry(script)
|
|
920
|
-
if entry is FC_UNKNOWN:
|
|
921
|
-
info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
|
|
922
|
-
"(no working fontconfig); rendering with the font as given -- "
|
|
923
|
-
"doctor --json .fonts.scripts reports what is known")
|
|
924
|
-
return script, None, None
|
|
925
|
-
if not entry:
|
|
926
|
-
die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")
|
|
927
|
-
info(f"font: {entry[0]} (covers {script})")
|
|
928
|
-
return script, entry[0], entry[1]
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
def escape_drawtext(text: str) -> str:
|
|
932
|
-
"""Escape a FONT NAME for a single-quoted drawtext option value (`font='<this>'`).
|
|
933
|
-
|
|
934
|
-
Since 1.15 this is no longer the route for drawn TEXT -- use drawtext_text_opts(), which puts
|
|
935
|
-
the text in a file and keeps `\'` and `%` verbatim. It remains the escape for the font-name
|
|
936
|
-
fallback, where the value is a family name that never legitimately contains a quote or a
|
|
937
|
-
percent sign.
|
|
938
|
-
|
|
939
|
-
Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash escape
|
|
940
|
-
regardless of the surrounding quotes -- the graph parser still splits on an unescaped `,`/`;`
|
|
941
|
-
or ends an option list on an unescaped `:`/`[`/`]` even while "inside" a quoted value. The
|
|
942
|
-
quote character itself has no reliable backslash escape at all: `\\'` and the POSIX shell
|
|
943
|
-
close-insert-reopen trick both parse fine in a simple `-vf` chain but silently corrupt a
|
|
944
|
-
`-filter_complex` chain that uses explicit `[label]` pads (confirmed by rendering the result:
|
|
945
|
-
trailing option names leak into the picture as literal text). `%` has the same problem as far
|
|
946
|
-
as drawtext's own expansion scanner is concerned. Both are therefore dropped here rather than
|
|
947
|
-
escaped -- which is exactly why drawn text no longer comes through this function.
|
|
948
|
-
"""
|
|
949
|
-
text = re.sub(r"[\x00-\x1f\x7f]", "", text)
|
|
950
|
-
return (
|
|
951
|
-
text.replace("'", "")
|
|
952
|
-
.replace("%", "")
|
|
953
|
-
.replace("\\", "\\\\")
|
|
954
|
-
.replace(":", "\\:")
|
|
955
|
-
.replace(",", "\\,")
|
|
956
|
-
.replace("[", "\\[")
|
|
957
|
-
.replace("]", "\\]")
|
|
958
|
-
.replace(";", "\\;")
|
|
959
|
-
)
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
963
|
-
"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
|
|
964
|
-
every character on every build shape this repo uses.
|
|
965
|
-
|
|
966
|
-
The filter-graph parser never sees the text at all: only the PATH is parsed, and
|
|
967
|
-
escape_filter_path() already handles that. `expansion=none` switches off drawtext's own
|
|
968
|
-
`%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
|
|
969
|
-
and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
|
|
970
|
-
`]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
|
|
971
|
-
done"` losing both characters. Control characters are still stripped: a one-line burnt-in
|
|
972
|
-
label has no use for them.
|
|
973
|
-
|
|
974
|
-
The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
|
|
975
|
-
removed when the process ends. It is *registered* here and written by run() only if the
|
|
976
|
-
command about to run actually names it, so --dry-run and the ASS route write nothing; a
|
|
977
|
-
printed plan therefore names a path that no longer exists once the run is over, which is the
|
|
978
|
-
same promise every other temp file in this skill makes.
|
|
979
|
-
"""
|
|
980
|
-
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
|
|
981
|
-
import hashlib
|
|
982
|
-
name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
|
|
983
|
-
if tmpdir is None:
|
|
984
|
-
tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
|
|
985
|
-
path = os.path.join(tmpdir, name)
|
|
986
|
-
_DRAWTEXT_PENDING[path] = cleaned
|
|
987
|
-
return f"textfile={escape_filter_path(path)}:expansion=none"
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
# --------------------------------------------------------------- caption line breaking (1.16)
|
|
991
|
-
# Lifted out of caption.py in 1.16.0 so graphics.py can wrap the same way (caption.py keeps the
|
|
992
|
-
# names it exported, re-imported from here). The whole breaker is pure: a string in, a list of
|
|
993
|
-
# lines out, no subprocess and no probe, which is what makes the eval regression corpus cheap
|
|
994
|
-
# to lock down in unit tests.
|
|
995
|
-
|
|
996
|
-
# How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
|
|
997
|
-
# 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
|
|
998
|
-
SAFE_WIDTH_FRACTION = 0.9
|
|
999
|
-
# ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
|
|
1000
|
-
# single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
|
|
1001
|
-
ORPHAN_MIN_EM = 1.1
|
|
1002
|
-
|
|
1003
|
-
WRAP_MODES = ("phrase", "measured")
|
|
1004
|
-
|
|
1005
|
-
# R3's Japanese preference table. These are *preferences* applied only among positions that
|
|
1006
|
-
# already fit the line, so the table can never make a line too wide or change the line count.
|
|
1007
|
-
#
|
|
1008
|
-
# JA_PARTICLES is a "do not strand at the start of a line" table, which is the direction kinsoku
|
|
1009
|
-
# practice actually goes: a particle is enclitic -- it attaches to the word BEFORE it and marks
|
|
1010
|
-
# that word's role -- so a line beginning with は or が reads as a fragment torn off its phrase.
|
|
1011
|
-
# A break AFTER a particle is therefore preferred (the particle stays with what it marks) and a
|
|
1012
|
-
# break BEFORE one is forbidden. The list is the eight case/topic particles named in the 1.16.0
|
|
1013
|
-
# task brief (は が を に で と の へ) plus も や から まで より, which a reader of Japanese would
|
|
1014
|
-
# add for the same reason. It is a judgement call with no upstream source; treat it as tunable
|
|
1015
|
-
# data, not as grammar.
|
|
1016
|
-
JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one of these is preferred, BEFORE one forbidden
|
|
1017
|
-
# The multi-character members of the same table. They are matched as whole strings against the
|
|
1018
|
-
# text on each side of a candidate break -- putting them in the character string above turned
|
|
1019
|
-
# か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
|
|
1020
|
-
JA_PARTICLE_WORDS = ("から", "まで", "より")
|
|
1021
|
-
JA_SENTENCE_END = "。、!?」』)" # a break AFTER one of these is preferred
|
|
1022
|
-
# Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
|
|
1023
|
-
# and the Japanese punctuation that hangs on the end of the line before it.
|
|
1024
|
-
JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
|
|
1025
|
-
JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
|
|
1026
|
-
|
|
1027
|
-
# R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
|
|
1028
|
-
# noun phrase it governs, so a break before one is the good break (the word opens the next line
|
|
1029
|
-
# with its phrase) and a break after one is the bad break (it is stranded at the end of a line,
|
|
1030
|
-
# away from what it governs). Both directions are scored, which is what makes the rule decide
|
|
1031
|
-
# rather than merely veto. Frozen data, matched case-folded on the atom with its punctuation
|
|
1032
|
-
# stripped; six languages because those are the Latin-script languages the eval corpus covers. A
|
|
1033
|
-
# word in several sets means the same thing structurally in each, so the union is used when no
|
|
1034
|
-
# --lang was given.
|
|
1035
|
-
FUNCTION_WORDS = {
|
|
1036
|
-
"en": {"a", "an", "the", "of", "to", "in", "on", "at", "for", "with", "by", "from", "and",
|
|
1037
|
-
"or", "as", "is", "it", "its", "this", "that", "into", "than", "but", "so"},
|
|
1038
|
-
"es": {"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "al", "en", "con",
|
|
1039
|
-
"por", "para", "y", "o", "que", "su", "sus", "lo", "se", "es"},
|
|
1040
|
-
"pt": {"o", "a", "os", "as", "um", "uma", "de", "do", "da", "dos", "das", "em", "no", "na",
|
|
1041
|
-
"nos", "nas", "com", "por", "para", "e", "que", "se", "ao", "aos"},
|
|
1042
|
-
"fr": {"le", "la", "les", "un", "une", "de", "du", "des", "à", "au", "aux", "en", "dans",
|
|
1043
|
-
"et", "ou", "que", "qui", "ce", "ces", "son", "sa", "ses", "par", "pour", "avec", "sur"},
|
|
1044
|
-
"de": {"der", "die", "das", "ein", "eine", "einen", "einem", "einer", "den", "dem", "des",
|
|
1045
|
-
"zu", "in", "im", "auf", "mit", "und", "oder", "von", "vom", "für", "aus", "an"},
|
|
1046
|
-
"it": {"il", "lo", "la", "i", "gli", "le", "un", "una", "uno", "di", "del", "della", "da",
|
|
1047
|
-
"in", "nel", "con", "per", "e", "che", "su", "al", "ai"},
|
|
1048
|
-
}
|
|
1049
|
-
_FUNCTION_WORDS_ANY = frozenset().union(*FUNCTION_WORDS.values())
|
|
1050
|
-
|
|
1051
|
-
# Penalty scores. Only the ordering matters; 1.0 means "never choose this if anything else fits".
|
|
1052
|
-
PENALTY_FORBIDDEN = 1.0
|
|
1053
|
-
PENALTY_OKURIGANA = 0.9 # between a kanji stem and the hiragana that inflects it
|
|
1054
|
-
PENALTY_FUNCTION_WORD = 0.8 # R4: the line before the break ends in an article/preposition
|
|
1055
|
-
PENALTY_IDEOGRAPHS = 0.6 # between two kanji: no evidence either way, mildly discouraged
|
|
1056
|
-
PENALTY_NEUTRAL = 0.5 # between two content words, or two characters with nothing to say
|
|
1057
|
-
PENALTY_FUNCTION_WORD_START = 0.2 # R4: the next line opens with the article/preposition it governs
|
|
1058
|
-
PENALTY_PARTICLE = 0.2 # R3: after a particle, so the particle stays with the word it marks
|
|
1059
|
-
PENALTY_SENTENCE_END = 0.0 # R3: after 。、!? -- the one break a reader expects
|
|
1060
|
-
|
|
1061
|
-
_HYPHENS = ("-", "‐") # ‑ (non-breaking hyphen) is deliberately NOT here
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
def _atoms(line: str) -> "List[Tuple[str, bool]]":
|
|
1065
|
-
"""Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
|
|
1066
|
-
character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
|
|
1067
|
-
whether a space stood before it in the original. The flag is what puts the text back together
|
|
1068
|
-
exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
|
|
1069
|
-
out: "List[Tuple[str, bool]]" = []
|
|
1070
|
-
word = ""
|
|
1071
|
-
spaced = False # a space stands before the atom being built
|
|
1072
|
-
pending = False # a space stands before the NEXT atom
|
|
1073
|
-
attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
|
|
1074
|
-
# An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
|
|
1075
|
-
# between a base and its skin-tone modifier (the same rule combining marks already follow).
|
|
1076
|
-
clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
|
|
1077
|
-
i = 0
|
|
1078
|
-
while i < len(line):
|
|
1079
|
-
ch = line[i]
|
|
1080
|
-
if i in clusters:
|
|
1081
|
-
cluster = line[i:i + clusters[i]]
|
|
1082
|
-
if word:
|
|
1083
|
-
out.append((word, spaced))
|
|
1084
|
-
word = ""
|
|
1085
|
-
out.append((cluster, pending))
|
|
1086
|
-
pending = False
|
|
1087
|
-
attach_next = False
|
|
1088
|
-
i += clusters[i]
|
|
1089
|
-
continue
|
|
1090
|
-
i += 1
|
|
1091
|
-
if char_script(ch) in NO_SPACE_SCRIPTS:
|
|
1092
|
-
if word:
|
|
1093
|
-
out.append((word, spaced))
|
|
1094
|
-
word = ""
|
|
1095
|
-
if out and not pending and _is_katakana_run(ch) and _is_katakana_run(out[-1][0][-1]):
|
|
1096
|
-
# a katakana word (タイミング, コンピューター) is one atom: eval 17 saw タイ|ミング
|
|
1097
|
-
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
1098
|
-
elif out and (attach_next or _is_mark(ch)):
|
|
1099
|
-
# never break between a base and the mark (or the leading vowel) that belongs to
|
|
1100
|
-
# it: the line would start with an orphaned tone mark or vowel sign
|
|
1101
|
-
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
1102
|
-
else:
|
|
1103
|
-
out.append((ch, pending))
|
|
1104
|
-
pending = False
|
|
1105
|
-
attach_next = ord(ch) in LEADING_VOWELS
|
|
1106
|
-
elif ch.isspace():
|
|
1107
|
-
if word:
|
|
1108
|
-
out.append((word, spaced))
|
|
1109
|
-
word = ""
|
|
1110
|
-
pending = True
|
|
1111
|
-
else:
|
|
1112
|
-
if not word:
|
|
1113
|
-
spaced, pending = pending, False
|
|
1114
|
-
word += ch
|
|
1115
|
-
if word:
|
|
1116
|
-
out.append((word, spaced))
|
|
1117
|
-
return out
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
def _split_hyphens(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
|
|
1121
|
-
"""R1's one addition to the atom list: a hyphenated token may break *after* its hyphen.
|
|
1122
|
-
|
|
1123
|
-
"end-to-end" becomes `end-` / `to-` / `end`, each piece carrying the space flag of the token
|
|
1124
|
-
it came from for the first piece and False for the rest, so _join() puts it back with no space
|
|
1125
|
-
at all. A hyphen that is the first or last character of the token (`-5`, `well-`) is never a
|
|
1126
|
-
break point: the guard is that both sides must be non-empty."""
|
|
1127
|
-
out: "List[Tuple[str, bool]]" = []
|
|
1128
|
-
for atom, spaced in atoms:
|
|
1129
|
-
if len(atom) < 3 or not any(h in atom[1:-1] for h in _HYPHENS):
|
|
1130
|
-
out.append((atom, spaced))
|
|
1131
|
-
continue
|
|
1132
|
-
piece = ""
|
|
1133
|
-
first = True
|
|
1134
|
-
for i, ch in enumerate(atom):
|
|
1135
|
-
piece += ch
|
|
1136
|
-
if ch in _HYPHENS and 0 < i < len(atom) - 1:
|
|
1137
|
-
out.append((piece, spaced if first else False))
|
|
1138
|
-
piece = ""
|
|
1139
|
-
first = False
|
|
1140
|
-
if piece:
|
|
1141
|
-
out.append((piece, spaced if first else False))
|
|
1142
|
-
return out
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
def _join(left: str, atom: str, spaced: bool) -> str:
|
|
1146
|
-
"""Put an atom back on a line, restoring the space that stood before it."""
|
|
1147
|
-
if not left:
|
|
1148
|
-
return atom
|
|
1149
|
-
return left + (" " if spaced else "") + atom
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
def _break_spaced(first: str, second: str) -> bool:
|
|
1153
|
-
"""Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
|
|
1154
|
-
there -- a CJK/Thai break sits between two characters that were written with nothing between
|
|
1155
|
-
them, and re-joining them with a space would insert a character the cue never had."""
|
|
1156
|
-
if not first or not second:
|
|
1157
|
-
return False
|
|
1158
|
-
return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
|
|
1159
|
-
and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
def _is_kana(ch: str) -> bool:
|
|
1163
|
-
return 0x3040 <= ord(ch) <= 0x30FF
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
def _is_katakana_run(ch: str) -> bool:
|
|
1167
|
-
"""Katakana proper plus the prolonged-sound mark: the characters one loan word is made of."""
|
|
1168
|
-
cp = ord(ch)
|
|
1169
|
-
return (0x30A1 <= cp <= 0x30FA) or cp == 0x30FC or (0x31F0 <= cp <= 0x31FF) or (0xFF66 <= cp <= 0xFF9F)
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
def _is_hiragana(ch: str) -> bool:
|
|
1173
|
-
return 0x3040 <= ord(ch) <= 0x309F
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
def _is_ideograph(ch: str) -> bool:
|
|
1177
|
-
cp = ord(ch)
|
|
1178
|
-
return 0x3400 <= cp <= 0x4DBF or 0x4E00 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
def _is_weak_line(line: str) -> "bool":
|
|
1182
|
-
"""A line no reader should be given on its own (R2).
|
|
1183
|
-
|
|
1184
|
-
1.15 asked only "is the last line one atom narrower than ORPHAN_MIN_EM", which a full-width
|
|
1185
|
-
character passes: dl3 still showed a lone `2` and a stranded `行`. Three cases instead, any of
|
|
1186
|
-
which makes a line too thin to stand alone:
|
|
1187
|
-
- a single character narrower than ORPHAN_MIN_EM (1.15's rule, kept);
|
|
1188
|
-
- nothing but digits, punctuation and symbols, at most two characters ("2", "--");
|
|
1189
|
-
- a single kana, whatever its width -- a kana is a full em and passes the width test, but a
|
|
1190
|
-
line holding one is a syllable, not a word.
|
|
1191
|
-
"""
|
|
1192
|
-
stripped = (line or "").strip()
|
|
1193
|
-
if not stripped:
|
|
1194
|
-
return True
|
|
1195
|
-
if len(stripped) == 1 and text_width_em(stripped) < ORPHAN_MIN_EM:
|
|
1196
|
-
return True
|
|
1197
|
-
if len(stripped) <= 2 and all(unicodedata.category(c)[0] in "NPS" for c in stripped):
|
|
1198
|
-
return True
|
|
1199
|
-
if len(stripped) == 1 and char_script(stripped) == "ja" and _is_kana(stripped):
|
|
1200
|
-
return True
|
|
1201
|
-
return False
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
def _function_words(lang: "Optional[str]") -> "frozenset":
|
|
1205
|
-
"""R4's table for this language. An unknown or absent language gets the union of the six sets:
|
|
1206
|
-
a token that appears in several of them is the same kind of word in each, which is why the
|
|
1207
|
-
rule is a penalty and not a refusal."""
|
|
1208
|
-
key = (lang or "").strip().lower().split("-")[0]
|
|
1209
|
-
if key in FUNCTION_WORDS:
|
|
1210
|
-
return frozenset(FUNCTION_WORDS[key])
|
|
1211
|
-
return _FUNCTION_WORDS_ANY
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
def _bare_word(atom: str) -> str:
|
|
1215
|
-
return "".join(c for c in (atom or "") if c.isalpha() or c == "'").strip("'").lower()
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
def _particle_starts(text: str) -> bool:
|
|
1219
|
-
"""Does `text` begin with a particle -- one character, or one of the two-character ones?"""
|
|
1220
|
-
if not text:
|
|
1221
|
-
return False
|
|
1222
|
-
return text[0] in JA_PARTICLES or text.startswith(JA_PARTICLE_WORDS)
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
def _particle_ends(text: str) -> bool:
|
|
1226
|
-
"""Does `text` end with a particle? `から` counts, a bare `ら` does not."""
|
|
1227
|
-
if not text:
|
|
1228
|
-
return False
|
|
1229
|
-
return text[-1] in JA_PARTICLES or text.endswith(JA_PARTICLE_WORDS)
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
|
|
1233
|
-
before: str = "", after: str = "") -> float:
|
|
1234
|
-
"""How bad a break between these two characters is, 0.0 (preferred) to 1.0 (forbidden).
|
|
1235
|
-
|
|
1236
|
-
Only consulted among break positions that already fit `max_em`, so a preference can never
|
|
1237
|
-
widen a line or change the line count. Japanese gets the particle half of the table -- a break
|
|
1238
|
-
AFTER a particle is preferred and a break BEFORE one forbidden, because a particle attaches to
|
|
1239
|
-
the word before it; Chinese gets only the sentence-end and forbidden halves, because particles
|
|
1240
|
-
are Japanese grammar.
|
|
1241
|
-
|
|
1242
|
-
`before`/`after` are the text on each side of the break when the caller has it, which is what
|
|
1243
|
-
lets the two-character particles (から/まで/より) be matched as words. Without them only the
|
|
1244
|
-
single-character table applies."""
|
|
1245
|
-
if not prev_char or not next_char:
|
|
1246
|
-
return PENALTY_NEUTRAL
|
|
1247
|
-
script = (lang or "").strip().lower().split("-")[0]
|
|
1248
|
-
if script not in ("ja", "zh"):
|
|
1249
|
-
# A kana on either side settles it: only Japanese has them, and char_script() reads a bare
|
|
1250
|
-
# Han character as Chinese, which used to switch the particle rules off for exactly the
|
|
1251
|
-
# break they exist to judge (`...が|決まる` -- kana before, kanji after).
|
|
1252
|
-
if _is_kana(prev_char) or _is_kana(next_char):
|
|
1253
|
-
script = "ja"
|
|
1254
|
-
else:
|
|
1255
|
-
script = char_script(next_char)
|
|
1256
|
-
if script not in ("ja", "zh"):
|
|
1257
|
-
script = char_script(prev_char)
|
|
1258
|
-
if next_char in JA_NO_LINE_START or prev_char in JA_NO_LINE_END or _is_mark(next_char):
|
|
1259
|
-
return PENALTY_FORBIDDEN
|
|
1260
|
-
if script not in ("ja", "zh"):
|
|
1261
|
-
return PENALTY_NEUTRAL
|
|
1262
|
-
if prev_char in JA_SENTENCE_END:
|
|
1263
|
-
return PENALTY_SENTENCE_END
|
|
1264
|
-
if script == "ja" and _particle_starts(after or next_char):
|
|
1265
|
-
# a particle may not open a line: it belongs to the word before it (kinsoku)
|
|
1266
|
-
return PENALTY_FORBIDDEN
|
|
1267
|
-
if script == "ja" and _particle_ends(before or prev_char):
|
|
1268
|
-
return PENALTY_PARTICLE
|
|
1269
|
-
if script == "ja" and _is_ideograph(prev_char) and _is_hiragana(next_char):
|
|
1270
|
-
# okurigana: 決|まる is inside a word even though neither half is a "word" on its own
|
|
1271
|
-
return PENALTY_OKURIGANA
|
|
1272
|
-
if _is_ideograph(prev_char) and _is_ideograph(next_char):
|
|
1273
|
-
return PENALTY_IDEOGRAPHS
|
|
1274
|
-
return PENALTY_NEUTRAL
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
def _cut_penalty(atoms: "Sequence[Tuple[str, bool]]", cut: int, lang: "Optional[str]") -> float:
|
|
1278
|
-
"""The penalty of breaking `atoms` before index `cut`."""
|
|
1279
|
-
prev_atom = atoms[cut - 1][0]
|
|
1280
|
-
next_atom = atoms[cut][0]
|
|
1281
|
-
if not prev_atom or not next_atom:
|
|
1282
|
-
return PENALTY_NEUTRAL
|
|
1283
|
-
if atoms[cut][1]:
|
|
1284
|
-
# a space stood here: a spaced script, so R4 is the rule that applies, in both directions
|
|
1285
|
-
if all(not ch.isalnum() for ch in next_atom):
|
|
1286
|
-
return PENALTY_FORBIDDEN # never strand punctuation at the start of a line
|
|
1287
|
-
words = _function_words(lang)
|
|
1288
|
-
if _bare_word(prev_atom) in words:
|
|
1289
|
-
return PENALTY_FUNCTION_WORD # stranded at the end of a line, away from its noun
|
|
1290
|
-
if _bare_word(next_atom) in words:
|
|
1291
|
-
return PENALTY_FUNCTION_WORD_START # opens the next line with the phrase it governs
|
|
1292
|
-
return PENALTY_NEUTRAL
|
|
1293
|
-
if prev_atom.endswith(_HYPHENS):
|
|
1294
|
-
return PENALTY_NEUTRAL # R1: a hyphen is a legitimate break point
|
|
1295
|
-
# the text on each side, so a two-character particle (から/まで/より) is seen as one
|
|
1296
|
-
before = "".join(a for a, _sp in atoms[:cut])
|
|
1297
|
-
after = "".join(a for a, _sp in atoms[cut:])
|
|
1298
|
-
return break_penalty(prev_atom[-1], next_atom[0], lang, before=before, after=after)
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
def best_break(atoms: "Sequence[Tuple[str, bool]]", max_em: float,
|
|
1302
|
-
lang: "Optional[str]" = None) -> "Optional[int]":
|
|
1303
|
-
"""The index to break `atoms` at so they become two lines, or None when none fits.
|
|
1304
|
-
|
|
1305
|
-
Among every position whose two halves both fit `max_em`, the one minimising
|
|
1306
|
-
(penalty, widest line, |width difference|) wins: R1-R4 choose first, and 1.15's
|
|
1307
|
-
minimise-the-widest-line rule breaks the ties it used to decide alone."""
|
|
1308
|
-
best = None
|
|
1309
|
-
for cut in range(1, len(atoms)):
|
|
1310
|
-
a = b = ""
|
|
1311
|
-
for atom, sp in atoms[:cut]:
|
|
1312
|
-
a = _join(a, atom, sp)
|
|
1313
|
-
for atom, sp in atoms[cut:]:
|
|
1314
|
-
b = _join(b, atom, sp)
|
|
1315
|
-
wa, wb = text_width_em(a), text_width_em(b)
|
|
1316
|
-
if max(wa, wb) > max_em:
|
|
1317
|
-
continue
|
|
1318
|
-
if _is_weak_line(a) or _is_weak_line(b):
|
|
1319
|
-
continue
|
|
1320
|
-
key = (_cut_penalty(atoms, cut, lang), max(wa, wb), abs(wa - wb))
|
|
1321
|
-
if best is None or key < best[0]:
|
|
1322
|
-
best = (key, cut)
|
|
1323
|
-
return None if best is None else best[1]
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1327
|
-
"""No last line that is a single stranded atom.
|
|
1328
|
-
|
|
1329
|
-
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
1330
|
-
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
1331
|
-
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
1332
|
-
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
1333
|
-
itself, so a two-word cue is never made worse."""
|
|
1334
|
-
lines = list(lines)
|
|
1335
|
-
for _ in range(len(lines)):
|
|
1336
|
-
if len(lines) < 2:
|
|
1337
|
-
break
|
|
1338
|
-
tail = _atoms(lines[-1])
|
|
1339
|
-
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
1340
|
-
break
|
|
1341
|
-
prev = _atoms(lines[-2])
|
|
1342
|
-
if len(prev) < 2:
|
|
1343
|
-
break
|
|
1344
|
-
moved, spaced = prev[-1]
|
|
1345
|
-
new_prev = ""
|
|
1346
|
-
for atom, sp in prev[:-1]:
|
|
1347
|
-
new_prev = _join(new_prev, atom, sp)
|
|
1348
|
-
new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
|
|
1349
|
-
if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
1350
|
-
break
|
|
1351
|
-
lines[-2], lines[-1] = new_prev, new_last
|
|
1352
|
-
return lines
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1356
|
-
"""R2, generalised: _fix_orphans run at *every* boundary, against _is_weak_line.
|
|
1357
|
-
|
|
1358
|
-
1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
|
|
1359
|
-
three-line cue survived. Walking upward from the last line, while a line is weak the last atom
|
|
1360
|
-
of the line above moves down onto it -- with 1.15's two guards intact (the result must still
|
|
1361
|
-
fit, and the line above must not itself become weak), so the line count never changes."""
|
|
1362
|
-
lines = list(lines)
|
|
1363
|
-
for i in range(len(lines) - 1, 0, -1):
|
|
1364
|
-
for _ in range(len(lines)):
|
|
1365
|
-
if not _is_weak_line(lines[i]):
|
|
1366
|
-
break
|
|
1367
|
-
prev = _atoms(lines[i - 1])
|
|
1368
|
-
if len(prev) < 2:
|
|
1369
|
-
break
|
|
1370
|
-
moved, _spaced = prev[-1]
|
|
1371
|
-
new_prev = ""
|
|
1372
|
-
for atom, sp in prev[:-1]:
|
|
1373
|
-
new_prev = _join(new_prev, atom, sp)
|
|
1374
|
-
new_last = _join(moved, lines[i], _break_spaced(lines[i - 1], lines[i]))
|
|
1375
|
-
if text_width_em(new_last) > max_em or _is_weak_line(new_prev) \
|
|
1376
|
-
or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
1377
|
-
break
|
|
1378
|
-
lines[i - 1], lines[i] = new_prev, new_last
|
|
1379
|
-
return lines
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1383
|
-
"""Move each break to the one that minimises the widest line of the pair, without changing the
|
|
1384
|
-
line count.
|
|
1385
|
-
|
|
1386
|
-
Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
|
|
1387
|
-
`"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
|
|
1388
|
-
non-spaced script has no phrase structure in its atom list, so moving the break there only
|
|
1389
|
-
moves the ragged edge. A break is never placed before a punctuation-only atom."""
|
|
1390
|
-
if len(lines) < 2:
|
|
1391
|
-
return lines
|
|
1392
|
-
out = list(lines)
|
|
1393
|
-
for i in range(len(out) - 1):
|
|
1394
|
-
first, second = out[i], out[i + 1]
|
|
1395
|
-
tail_atoms = _atoms(second)
|
|
1396
|
-
if tail_atoms:
|
|
1397
|
-
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
1398
|
-
atoms = _atoms(first) + tail_atoms
|
|
1399
|
-
if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
|
|
1400
|
-
continue
|
|
1401
|
-
best = None
|
|
1402
|
-
for cut in range(1, len(atoms)):
|
|
1403
|
-
if not atoms[cut][1]:
|
|
1404
|
-
continue # only break where a space stood
|
|
1405
|
-
if all(not ch.isalnum() for ch in atoms[cut][0]):
|
|
1406
|
-
continue # never strand punctuation at the start of a line
|
|
1407
|
-
a = b = ""
|
|
1408
|
-
for atom, sp in atoms[:cut]:
|
|
1409
|
-
a = _join(a, atom, sp)
|
|
1410
|
-
for atom, sp in atoms[cut:]:
|
|
1411
|
-
b = _join(b, atom, sp)
|
|
1412
|
-
wa, wb = text_width_em(a), text_width_em(b)
|
|
1413
|
-
if max(wa, wb) > max_em:
|
|
1414
|
-
continue
|
|
1415
|
-
key = (max(wa, wb), abs(wa - wb))
|
|
1416
|
-
if best is None or key < best[0]:
|
|
1417
|
-
best = (key, a, b)
|
|
1418
|
-
if best is not None:
|
|
1419
|
-
out[i], out[i + 1] = best[1], best[2]
|
|
1420
|
-
return out
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]") -> "Tuple[List[str], int]":
|
|
1424
|
-
"""_rebalance with R1-R4 deciding, for every script rather than spaced ones only.
|
|
1425
|
-
|
|
1426
|
-
Returns the new lines and how many breaks a phrase rule moved away from the position 1.15's
|
|
1427
|
-
widest-line rule alone would have chosen -- the `phrase_breaks` count in the result."""
|
|
1428
|
-
if len(lines) < 2:
|
|
1429
|
-
return list(lines), 0
|
|
1430
|
-
out = list(lines)
|
|
1431
|
-
moved = 0
|
|
1432
|
-
for i in range(len(out) - 1):
|
|
1433
|
-
first, second = out[i], out[i + 1]
|
|
1434
|
-
tail_atoms = _atoms(second)
|
|
1435
|
-
if tail_atoms:
|
|
1436
|
-
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
1437
|
-
atoms = _split_hyphens(_atoms(first) + tail_atoms)
|
|
1438
|
-
if len(atoms) < 2:
|
|
1439
|
-
continue
|
|
1440
|
-
cut = best_break(atoms, max_em, lang)
|
|
1441
|
-
if cut is None:
|
|
1442
|
-
continue
|
|
1443
|
-
a = b = ""
|
|
1444
|
-
for atom, sp in atoms[:cut]:
|
|
1445
|
-
a = _join(a, atom, sp)
|
|
1446
|
-
for atom, sp in atoms[cut:]:
|
|
1447
|
-
b = _join(b, atom, sp)
|
|
1448
|
-
if (a, b) != (first, second):
|
|
1449
|
-
moved += 1
|
|
1450
|
-
out[i], out[i + 1] = a, b
|
|
1451
|
-
return out, moved
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
def _greedy_chunks(raw: str, max_em: float) -> "List[str]":
|
|
1455
|
-
"""The greedy fill on its own: the line count every mode must keep."""
|
|
1456
|
-
current = ""
|
|
1457
|
-
chunk: "List[str]" = []
|
|
1458
|
-
for atom, spaced in _atoms(raw):
|
|
1459
|
-
candidate = _join(current, atom, spaced)
|
|
1460
|
-
if current and text_width_em(candidate) > max_em:
|
|
1461
|
-
chunk.append(current)
|
|
1462
|
-
current = atom
|
|
1463
|
-
else:
|
|
1464
|
-
current = candidate
|
|
1465
|
-
if current:
|
|
1466
|
-
chunk.append(current)
|
|
1467
|
-
return chunk
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]") -> "List[str]":
|
|
1471
|
-
"""The post-passes for one greedy chunk, in the mode's own order. Never changes the count:
|
|
1472
|
-
a pass that would is discarded, exactly as 1.15 did."""
|
|
1473
|
-
if len(chunk) < 2:
|
|
1474
|
-
return chunk
|
|
1475
|
-
if mode == "measured":
|
|
1476
|
-
fixed = _fix_orphans(chunk, max_em)
|
|
1477
|
-
rebalanced = _rebalance(fixed, max_em)
|
|
1478
|
-
else:
|
|
1479
|
-
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em), max_em)
|
|
1480
|
-
rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang)
|
|
1481
|
-
rebalanced = _fix_weak_lines(rebalanced, max_em)
|
|
1482
|
-
if len(rebalanced) == len(chunk):
|
|
1483
|
-
return rebalanced
|
|
1484
|
-
return fixed if len(fixed) == len(chunk) else chunk
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "phrase",
|
|
1488
|
-
lang: "Optional[str]" = None) -> "List[str]":
|
|
1489
|
-
"""Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
|
|
1490
|
-
|
|
1491
|
-
An atom wider than the whole line (one very long word) is left alone on its line rather than
|
|
1492
|
-
cut mid-word: an over-long line is readable, a chopped word is not.
|
|
1493
|
-
|
|
1494
|
-
`mode="phrase"` (the default since 1.16) then applies the four phrase rules -- never inside a
|
|
1495
|
-
word or across a hyphen's wrong side (R1), no line that is a lone digit, punctuation or kana
|
|
1496
|
-
(R2), Japanese/Chinese breaks preferred at sentence ends and after particles, never before one
|
|
1497
|
-
and never inside a word (R3), and an article or preposition kept with the phrase it governs by
|
|
1498
|
-
preferring the break before it and avoiding the break after it (R4). `mode="measured"` is
|
|
1499
|
-
1.15's behaviour exactly: no one-character orphan line, and a break chosen only to minimise the
|
|
1500
|
-
widest line. Neither mode ever changes the number of lines the greedy fill produced.
|
|
1501
|
-
"""
|
|
1502
|
-
lines: "List[str]" = []
|
|
1503
|
-
for raw in text.split("\n"):
|
|
1504
|
-
if not raw.strip():
|
|
1505
|
-
continue
|
|
1506
|
-
chunk = _greedy_chunks(raw, max_em)
|
|
1507
|
-
lines.extend(_balance(chunk, max_em, mode, lang) if balance else chunk)
|
|
1508
|
-
return lines or [text]
|
|
1509
|
-
def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
1510
|
-
lang: "Optional[str]" = None) -> "Tuple[List[str], List[str], List[str]]":
|
|
1511
|
-
"""`(wrapped, greedy, measured)` for one cue from a single greedy fill.
|
|
1512
|
-
|
|
1513
|
-
layout_cues needs all three -- `wrapped` is what is burnt in, `greedy` is what `rebalanced`
|
|
1514
|
-
counts against and `measured` what `phrase_breaks` counts against -- and used to call
|
|
1515
|
-
wrap_text() three times, re-running the atomiser and the greedy fill each time. The fill is
|
|
1516
|
-
the same for every mode, so it is done once here and only the post-passes are repeated.
|
|
1517
|
-
`measured` is the same list object as `wrapped` when that is already the mode.
|
|
1518
|
-
"""
|
|
1519
|
-
wrapped: "List[str]" = []
|
|
1520
|
-
greedy: "List[str]" = []
|
|
1521
|
-
measured: "List[str]" = []
|
|
1522
|
-
for raw in text.split("\n"):
|
|
1523
|
-
if not raw.strip():
|
|
1524
|
-
continue
|
|
1525
|
-
chunk = _greedy_chunks(raw, max_em)
|
|
1526
|
-
greedy.extend(chunk)
|
|
1527
|
-
wrapped.extend(_balance(list(chunk), max_em, mode, lang))
|
|
1528
|
-
measured.extend(chunk if mode == "measured" else _balance(list(chunk), max_em, "measured", None))
|
|
1529
|
-
if not greedy:
|
|
1530
|
-
greedy = [text]
|
|
1531
|
-
return (wrapped or [text], greedy, measured or [text])
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
# --- caption size that fits the cue (1.17) -------------------------------------------------
|
|
1535
|
-
# The legibility floor: 4.5 % of the frame height, ass_units(0.045) = 13 against the 288-line
|
|
1536
|
-
# ASS script grid. One floor for every destination -- 87 px of type on a 1920-tall frame, above
|
|
1537
|
-
# the ~3.5 % where mobile legibility bottoms out and where the platforms' own caption UIs sit.
|
|
1538
|
-
# Nothing per-platform is measured, so nothing per-platform is claimed. (The eval-17 cues happen
|
|
1539
|
-
# to land exactly on it: 13 is the smallest size at which every one of them fits two lines.)
|
|
1540
|
-
MIN_CAPTION_FRACTION = 0.045
|
|
1541
|
-
ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid; mirrors _platforms
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
def line_em_for_size(size: float, play_w: "Optional[int]", play_h: "Optional[int]", *,
|
|
1545
|
-
safe_fraction: float = SAFE_WIDTH_FRACTION,
|
|
1546
|
-
script_height: int = ASS_SCRIPT_HEIGHT) -> "Optional[float]":
|
|
1547
|
-
"""How many em fit on one caption line at `size`, or None without geometry.
|
|
1548
|
-
|
|
1549
|
-
`size` is in ASS points against a `script_height`-line script (what libass's force_style
|
|
1550
|
-
uses), so the rendered pixel size is size * play_h / script_height. This is the one width
|
|
1551
|
-
formula: caption.py::max_line_em and fit_size() both call it.
|
|
1552
|
-
"""
|
|
1553
|
-
if not play_w or not play_h or not size:
|
|
1554
|
-
return None
|
|
1555
|
-
size_px = size * play_h / float(script_height)
|
|
1556
|
-
if size_px <= 0:
|
|
1557
|
-
return None
|
|
1558
|
-
return (play_w * safe_fraction) / size_px
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
def fit_size(cues, *, size: int, min_size: "Optional[int]" = None, max_lines: int = 2,
|
|
1562
|
-
play_w: "Optional[int]" = None, play_h: "Optional[int]" = None,
|
|
1563
|
-
safe_fraction: float = SAFE_WIDTH_FRACTION, mode: str = "phrase",
|
|
1564
|
-
lang: "Optional[str]" = None, script_height: int = ASS_SCRIPT_HEIGHT,
|
|
1565
|
-
step: int = 1, scope: str = "file") -> "Dict[str, Any]":
|
|
1566
|
-
"""The largest size in [min_size, size] at which every cue wraps to <= max_lines lines.
|
|
1567
|
-
|
|
1568
|
-
Pure: strings and integers in, a dict out. No ffmpeg, no ffprobe, no I/O -- the caption size
|
|
1569
|
-
is a text-measurement decision, and measuring it must not need a subprocess.
|
|
1570
|
-
|
|
1571
|
-
`cues` is an iterable of cue texts (or of (start, end, text) tuples, as caption.py holds
|
|
1572
|
-
them before layout). Returns
|
|
1573
|
-
{"size", "floor", "requested", "scope", "shrunk", "fits", "per_cue", "max_em", "steps"}.
|
|
1574
|
-
|
|
1575
|
-
The search is a linear walk downwards, not a bisection, and deliberately so:
|
|
1576
|
-
len(wrap_text(t, max_em)) is NOT guaranteed monotone in max_em under the phrase rules -- a
|
|
1577
|
-
rebalance that is discarded at one width can be applied at the next -- and a non-monotone
|
|
1578
|
-
predicate breaks bisection. 24 -> 13 is at most twelve iterations of pure string work.
|
|
1579
|
-
|
|
1580
|
-
`scope="cue"` returns one size per cue index in `per_cue`, with `size` the minimum of them;
|
|
1581
|
-
the caller writes a per-cue {\\fsN} override. The default is `scope="file"`: a caption track
|
|
1582
|
-
whose type size changes from cue to cue reads as a mistake, and one measured line width per
|
|
1583
|
-
file is what makes the wrap behaviour reproducible.
|
|
1584
|
-
"""
|
|
1585
|
-
# `texts` stays parallel to `cues`: a blank cue becomes None rather than being dropped, so
|
|
1586
|
-
# per_cue[i] always refers to the caller's cue i. caption.py indexes layout by these keys.
|
|
1587
|
-
texts: "List[Optional[str]]" = []
|
|
1588
|
-
for cue in cues or []:
|
|
1589
|
-
if isinstance(cue, (tuple, list)):
|
|
1590
|
-
raw = cue[2] if len(cue) > 2 else cue[-1]
|
|
1591
|
-
else:
|
|
1592
|
-
raw = cue
|
|
1593
|
-
texts.append(raw if raw and str(raw).strip() else None)
|
|
1594
|
-
measurable = [t for t in texts if t is not None]
|
|
1595
|
-
requested = int(size)
|
|
1596
|
-
floor = int(min_size) if min_size is not None else ass_units_local(MIN_CAPTION_FRACTION,
|
|
1597
|
-
script_height)
|
|
1598
|
-
floor = max(1, min(floor, requested))
|
|
1599
|
-
step = max(1, int(step))
|
|
1600
|
-
result: "Dict[str, Any]" = {"size": requested, "floor": floor, "requested": requested,
|
|
1601
|
-
"scope": scope, "shrunk": 0, "fits": True, "per_cue": {},
|
|
1602
|
-
"max_em": None, "steps": 0}
|
|
1603
|
-
em_at = lambda sz: line_em_for_size(sz, play_w, play_h, safe_fraction=safe_fraction,
|
|
1604
|
-
script_height=script_height)
|
|
1605
|
-
base_em = em_at(requested)
|
|
1606
|
-
result["max_em"] = base_em
|
|
1607
|
-
if not measurable or base_em is None or max_lines < 1:
|
|
1608
|
-
# No geometry means no measurable width: leave the size exactly as asked.
|
|
1609
|
-
return result
|
|
1610
|
-
|
|
1611
|
-
def lines_at(text: str, sz: int) -> int:
|
|
1612
|
-
em = em_at(sz)
|
|
1613
|
-
if em is None:
|
|
1614
|
-
return 1
|
|
1615
|
-
return len(wrap_text(text, em, mode=mode, lang=lang))
|
|
1616
50
|
|
|
1617
|
-
|
|
1618
|
-
|
|
51
|
+
class _Shim(_types.ModuleType):
|
|
52
|
+
"""Rebinding a name here rebinds it on the part that defines it (and reads it at call time);
|
|
53
|
+
dunders stay per module, as on the `_common` facade."""
|
|
1619
54
|
|
|
1620
|
-
def
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
if
|
|
1626
|
-
|
|
1627
|
-
sz -= step
|
|
1628
|
-
return floor, all(lines_at(t, floor) <= max_lines for t in subset)
|
|
55
|
+
def __setattr__(self, name, value):
|
|
56
|
+
_types.ModuleType.__setattr__(self, name, value)
|
|
57
|
+
if name.startswith("__") and name.endswith("__"):
|
|
58
|
+
return
|
|
59
|
+
for _m in _PARTS:
|
|
60
|
+
if name in _m.__dict__:
|
|
61
|
+
_types.ModuleType.__setattr__(_m, name, value)
|
|
1629
62
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
sz, ok = best_for([t])
|
|
1638
|
-
per_cue[i] = sz
|
|
1639
|
-
fits_all = fits_all and ok
|
|
1640
|
-
result["per_cue"] = per_cue
|
|
1641
|
-
sized = [v for i, v in per_cue.items() if texts[i] is not None]
|
|
1642
|
-
result["size"] = min(sized) if sized else requested
|
|
1643
|
-
result["fits"] = fits_all
|
|
1644
|
-
else:
|
|
1645
|
-
sz, ok = best_for(measurable)
|
|
1646
|
-
result["size"] = sz
|
|
1647
|
-
result["fits"] = ok
|
|
1648
|
-
result["max_em"] = em_at(result["size"])
|
|
1649
|
-
return result
|
|
63
|
+
def __delattr__(self, name):
|
|
64
|
+
_types.ModuleType.__delattr__(self, name)
|
|
65
|
+
if name.startswith("__") and name.endswith("__"):
|
|
66
|
+
return
|
|
67
|
+
for _m in _PARTS:
|
|
68
|
+
if name in _m.__dict__:
|
|
69
|
+
_types.ModuleType.__delattr__(_m, name)
|
|
1650
70
|
|
|
1651
71
|
|
|
1652
|
-
|
|
1653
|
-
"""`fraction` of the frame height in ASS units. Mirrors _platforms.ass_units, kept here so
|
|
1654
|
-
_common.text stays importable without the scripts/ top level on sys.path."""
|
|
1655
|
-
return int(round(fraction * script_height))
|
|
72
|
+
sys.modules[__name__].__class__ = _Shim
|