ffmpeg-skill 1.17.3 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +22 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +1 -1
- package/references/scripts.md +85 -10
- package/scripts/_common/__init__.py +8 -5
- package/scripts/_common/decision.py +86 -0
- package/scripts/_common/drawtext.py +125 -0
- package/scripts/_common/emoji.py +350 -0
- package/scripts/_common/fonts.py +437 -0
- package/scripts/_common/probe.py +26 -0
- package/scripts/_common/text.py +59 -1642
- package/scripts/_common/wrap.py +778 -0
- package/scripts/_contract.py +8 -3
- package/scripts/cropdetect.py +59 -1
- package/scripts/multicam.py +85 -5
- package/scripts/scenes.py +87 -2
- package/scripts/silence.py +46 -1
- package/scripts/sync.py +100 -52
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Emoji: cluster detection (ZWJ sequences, keycaps, flags, skin tones), the PNG asset lookup,
|
|
2
|
+
the per-machine support probe and the overlay filter chain that composites the assets.
|
|
3
|
+
Split out of _common.text in the refactor after 1.17.3; every body is byte-identical.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Dict, List, Tuple, Optional
|
|
11
|
+
from _common.emit import die
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --------------------------------------------------------------------------- emoji (1.15)
|
|
15
|
+
# Emoji are orthogonal to the writing system: "やった 🎉" is Japanese AND emoji. They are detected
|
|
16
|
+
# separately from detect_script() so a cue's font resolution is still decided by its letters.
|
|
17
|
+
EMOJI_RANGES = (
|
|
18
|
+
(0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
|
|
19
|
+
(0x1F000, 0x1F0FF), # mahjong/domino/playing cards
|
|
20
|
+
(0x2600, 0x27BF), # misc symbols + dingbats
|
|
21
|
+
(0x2B00, 0x2BFF), # misc symbols and arrows
|
|
22
|
+
(0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
|
|
23
|
+
(0x1F1E6, 0x1F1FF), # regional indicators (flags)
|
|
24
|
+
(0x20E3, 0x20E3), # combining enclosing keycap
|
|
25
|
+
(0x1F3FB, 0x1F3FF), # skin-tone modifiers
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# U+200D ZWJ is deliberately NOT in EMOJI_RANGES: it is ordinary Indic/Persian orthography
|
|
30
|
+
# (क्ष is ka + virama + ZWJ + ssa) and only becomes emoji glue *between two emoji bases*.
|
|
31
|
+
# Characters that never START a cluster: they bind to whatever stands before them.
|
|
32
|
+
_EMOJI_TAIL = frozenset({0x200D, 0xFE0F, 0x20E3} | set(range(0x1F3FB, 0x1F400)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_EMOJI_REGIONAL = range(0x1F1E6, 0x1F200)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_ZWJ = 0x200D
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_VS15 = 0xFE0E # text-presentation selector: "draw this as a character, not as an emoji"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_VS16 = 0xFE0F
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_KEYCAP = 0x20E3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_KEYCAP_BASES = frozenset("0123456789#*")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_emoji_char(ch: str) -> bool:
|
|
54
|
+
cp = ord(ch)
|
|
55
|
+
return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_emoji_base(ch: str) -> bool:
|
|
59
|
+
"""Can this character START an emoji cluster? Pictographs and regional indicators can;
|
|
60
|
+
the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
|
|
61
|
+
emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
|
|
62
|
+
ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap."""
|
|
63
|
+
return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
|
|
67
|
+
"""(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
|
|
68
|
+
and skin-tone modifiers kept together -- 👩💻 is one cluster, not three, and 1️⃣ starts at the
|
|
69
|
+
digit even though the digit is not itself an emoji character.
|
|
70
|
+
|
|
71
|
+
A cluster can only START at an emoji base (a pictograph, a regional indicator) or at a keycap
|
|
72
|
+
base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
|
|
73
|
+
a starter and never a tail on its own: `क्ष` (Hindi ka + virama + ZWJ + ssa) and `abcdef`
|
|
74
|
+
contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
|
|
75
|
+
not an emoji -- the author asked for the character, not the picture.
|
|
76
|
+
"""
|
|
77
|
+
out: "List[Tuple[int, str]]" = []
|
|
78
|
+
i = 0
|
|
79
|
+
n = len(text or "")
|
|
80
|
+
while i < n:
|
|
81
|
+
ch = text[i]
|
|
82
|
+
start = i
|
|
83
|
+
if _is_emoji_base(ch):
|
|
84
|
+
j = i + 1
|
|
85
|
+
if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
|
|
86
|
+
i = j + 1
|
|
87
|
+
continue
|
|
88
|
+
elif ch in _KEYCAP_BASES:
|
|
89
|
+
j = i + 1
|
|
90
|
+
if j < n and ord(text[j]) == _VS16:
|
|
91
|
+
j += 1
|
|
92
|
+
if not (j < n and ord(text[j]) == _KEYCAP):
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
j += 1
|
|
96
|
+
else:
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
# extend: modifiers bind rightwards, a ZWJ only when a real emoji base follows it
|
|
100
|
+
while j < n:
|
|
101
|
+
cp = ord(text[j])
|
|
102
|
+
if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
|
|
103
|
+
j += 1
|
|
104
|
+
continue
|
|
105
|
+
if cp == _ZWJ and j + 1 < n and _is_emoji_base(text[j + 1]):
|
|
106
|
+
j += 2
|
|
107
|
+
continue
|
|
108
|
+
if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
|
|
109
|
+
j += 1
|
|
110
|
+
continue
|
|
111
|
+
break
|
|
112
|
+
out.append((start, text[start:j]))
|
|
113
|
+
i = j
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def has_emoji(text: str) -> bool:
|
|
118
|
+
return bool(emoji_clusters(text or ""))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def emoji_codepoint_name(cluster: str) -> str:
|
|
122
|
+
"""The asset filename stem for a cluster: lowercase hex code points joined by '-', the
|
|
123
|
+
Twemoji/Noto convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5)."""
|
|
124
|
+
return "-".join(f"{ord(c):x}" for c in cluster)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _emoji_name_candidates(cluster: str) -> "List[str]":
|
|
128
|
+
"""Asset stems to try, most specific first: exact, without VS16, without skin tone, the ZWJ
|
|
129
|
+
sequence reduced to its first code point, the bare base."""
|
|
130
|
+
cps = [ord(c) for c in cluster]
|
|
131
|
+
names = [emoji_codepoint_name(cluster)]
|
|
132
|
+
|
|
133
|
+
def add(seq):
|
|
134
|
+
name = "-".join(f"{c:x}" for c in seq)
|
|
135
|
+
if name and name not in names:
|
|
136
|
+
names.append(name)
|
|
137
|
+
add([c for c in cps if c != 0xFE0F])
|
|
138
|
+
add([c for c in cps if c != 0xFE0F and not (0x1F3FB <= c <= 0x1F3FF)])
|
|
139
|
+
if 0x200D in cps:
|
|
140
|
+
add([cps[0]])
|
|
141
|
+
add([cps[0]])
|
|
142
|
+
return names
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def emoji_asset_for(cluster: str, assets_dir: "Optional[str]") -> "Optional[str]":
|
|
146
|
+
"""The PNG for `cluster` under `assets_dir`, or None when nothing matches."""
|
|
147
|
+
if not assets_dir or not os.path.isdir(assets_dir):
|
|
148
|
+
return None
|
|
149
|
+
for name in _emoji_name_candidates(cluster):
|
|
150
|
+
for ext in (".png", ".PNG"):
|
|
151
|
+
candidate = os.path.join(assets_dir, name + ext)
|
|
152
|
+
if os.path.isfile(candidate):
|
|
153
|
+
return candidate
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
EMOJI_ASSET_HINT = (
|
|
158
|
+
"point --emoji-assets at a directory of PNGs named by code point (1f389.png): "
|
|
159
|
+
"twemoji/assets/72x72 (Twemoji, CC-BY 4.0) or noto-emoji/png/128 (Noto Emoji, OFL/Apache-2.0) "
|
|
160
|
+
"are the two people already have. The skill has no network at runtime, so the assets must "
|
|
161
|
+
"already exist on this machine -- nothing is ever downloaded")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
|
|
171
|
+
"""(family, file, fontconfig_answered) for the first installed colour emoji family."""
|
|
172
|
+
exe = shutil.which("fc-list")
|
|
173
|
+
if not exe:
|
|
174
|
+
return None, None, False
|
|
175
|
+
for family in _EMOJI_COLOR_FAMILIES:
|
|
176
|
+
try:
|
|
177
|
+
proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
|
|
178
|
+
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
|
|
179
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
180
|
+
return None, None, False
|
|
181
|
+
if proc.returncode != 0:
|
|
182
|
+
return None, None, False
|
|
183
|
+
for line in proc.stdout.splitlines():
|
|
184
|
+
path = line.split(":", 1)[0].strip()
|
|
185
|
+
if path and os.path.exists(path):
|
|
186
|
+
return family, path, True
|
|
187
|
+
return None, None, True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _libass_color_probe() -> "Optional[bool]":
|
|
191
|
+
"""Does THIS ffmpeg render an emoji in colour through libass? Answered by a render, never by
|
|
192
|
+
the font listing: Noto Color Emoji installs happily on builds whose freetype/libass has no
|
|
193
|
+
colour-bitmap path at all, and those render a monochrome outline instead (measured). ~80 ms.
|
|
194
|
+
None means the probe could not be run (no ffmpeg, a failure) -- unknown, not false."""
|
|
195
|
+
exe = shutil.which("ffmpeg")
|
|
196
|
+
if not exe:
|
|
197
|
+
return None
|
|
198
|
+
import tempfile
|
|
199
|
+
with tempfile.TemporaryDirectory() as td:
|
|
200
|
+
srt = os.path.join(td, "e.srt")
|
|
201
|
+
with open(srt, "w", encoding="utf-8") as fh:
|
|
202
|
+
fh.write("1\n00:00:00,000 --> 00:00:01,000\n\U0001F389\n")
|
|
203
|
+
try:
|
|
204
|
+
proc = subprocess.run(
|
|
205
|
+
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
|
206
|
+
"-i", "color=c=black:s=64x64:d=0.04",
|
|
207
|
+
"-vf", "subtitles=" + srt.replace("\\", "/"), "-frames:v", "1",
|
|
208
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
209
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
|
|
210
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
211
|
+
return None
|
|
212
|
+
if proc.returncode != 0 or len(proc.stdout) < 64 * 64 * 3:
|
|
213
|
+
return None
|
|
214
|
+
data = proc.stdout
|
|
215
|
+
for i in range(0, 64 * 64 * 3, 3):
|
|
216
|
+
r, g, b = data[i], data[i + 1], data[i + 2]
|
|
217
|
+
if max(r, g, b) - min(r, g, b) > 40:
|
|
218
|
+
return True
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def emoji_support(assets: "Optional[str]" = None, probe: bool = True) -> "Dict[str, Any]":
|
|
223
|
+
"""What this machine can actually do with emoji, cached per process.
|
|
224
|
+
|
|
225
|
+
`mode` is `color` when a render probe proves libass draws colour, else `png` when an assets
|
|
226
|
+
directory resolves, else `mono` when some installed face has a glyph at all, else `none`.
|
|
227
|
+
An installed colour emoji font proves nothing on its own -- that is why `libass_color` comes
|
|
228
|
+
from a render (see references/gotchas.md#emoji). `probe=False` (`contract --json --static`, and every
|
|
229
|
+
static/JSON-only path) skips the render entirely and leaves `libass_color` unknown.
|
|
230
|
+
"""
|
|
231
|
+
key = (assets or None, bool(probe))
|
|
232
|
+
if key in _EMOJI_SUPPORT_CACHE:
|
|
233
|
+
return dict(_EMOJI_SUPPORT_CACHE[key])
|
|
234
|
+
family, file, fc_answered = _emoji_color_font()
|
|
235
|
+
libass_color = _libass_color_probe() if probe else None
|
|
236
|
+
assets_dir = assets if (assets and os.path.isdir(assets)) else None
|
|
237
|
+
if libass_color:
|
|
238
|
+
mode = "color"
|
|
239
|
+
elif assets_dir:
|
|
240
|
+
mode = "png"
|
|
241
|
+
elif family:
|
|
242
|
+
mode = "mono"
|
|
243
|
+
elif not fc_answered:
|
|
244
|
+
# No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
|
|
245
|
+
# so the honest answer is png-or-none, never "none because fc-list is missing".
|
|
246
|
+
mode = "none"
|
|
247
|
+
else:
|
|
248
|
+
mode = "none"
|
|
249
|
+
if not fc_answered:
|
|
250
|
+
detail = "no fontconfig on this machine; the PNG overlay path needs none"
|
|
251
|
+
elif libass_color:
|
|
252
|
+
detail = f"{family or 'an installed face'} renders in colour through libass on this ffmpeg"
|
|
253
|
+
elif family and libass_color is False:
|
|
254
|
+
detail = f"{family} installed but libass renders it monochrome on this build"
|
|
255
|
+
elif family and libass_color is None:
|
|
256
|
+
detail = f"{family} installed; the colour render probe was not run"
|
|
257
|
+
elif assets_dir:
|
|
258
|
+
detail = "no colour emoji family installed; using the PNG assets directory"
|
|
259
|
+
else:
|
|
260
|
+
detail = "no colour emoji family installed and no --emoji-assets directory"
|
|
261
|
+
result = {"mode": mode, "color_font": family, "color_font_file": file,
|
|
262
|
+
"libass_color": libass_color, "assets": assets_dir,
|
|
263
|
+
"detail": detail, "fix": EMOJI_ASSET_HINT}
|
|
264
|
+
_EMOJI_SUPPORT_CACHE[key] = result
|
|
265
|
+
return dict(result)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]" = None,
|
|
269
|
+
brand: "Optional[dict]" = None) -> "Optional[str]":
|
|
270
|
+
"""--emoji-assets DIR, else the project key, else brand.json, else FFMPEG_SKILL_EMOJI_ASSETS.
|
|
271
|
+
A directory that was named but does not exist is a failed job, never a silent downgrade."""
|
|
272
|
+
brand = brand or {}
|
|
273
|
+
styles = (brand.get("styles") or {}).get("caption") or {}
|
|
274
|
+
for value, where in ((flag, "--emoji-assets"), (project, "the project's text.emoji_assets"),
|
|
275
|
+
(styles.get("emoji_assets"), "brand.json styles.caption.emoji_assets"),
|
|
276
|
+
(brand.get("emoji_assets"), "brand.json emoji_assets"),
|
|
277
|
+
(os.environ.get("FFMPEG_SKILL_EMOJI_ASSETS"), "FFMPEG_SKILL_EMOJI_ASSETS")):
|
|
278
|
+
if not value:
|
|
279
|
+
continue
|
|
280
|
+
if not os.path.isdir(str(value)):
|
|
281
|
+
die(f"{where}: {value} is not a readable directory -- {EMOJI_ASSET_HINT}", kind="input")
|
|
282
|
+
return str(value)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def emoji_filter_chain(plan, base_label, out_label, first_input=1):
|
|
289
|
+
"""(chains, inputs) that composite the planned PNGs on top of `base_label`.
|
|
290
|
+
|
|
291
|
+
`inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
|
|
292
|
+
ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
|
|
293
|
+
a timeline the fade filter can move along; one that does not is a plain `-i`).
|
|
294
|
+
"""
|
|
295
|
+
overlays = plan.get("overlays") or []
|
|
296
|
+
if not overlays:
|
|
297
|
+
return [], []
|
|
298
|
+
# Group by everything that makes two uses of the same PNG a different STREAM: the fade is
|
|
299
|
+
# expressed in the cue's own timeline, so two cues cannot share one faded input.
|
|
300
|
+
def _key(o):
|
|
301
|
+
fades = (round(float(o.get("fade_in") or 0.0), 3), round(float(o.get("fade_out") or 0.0), 3))
|
|
302
|
+
window = (round(float(o["start"]), 3), round(float(o["end"]), 3)) if any(fades) else (None, None)
|
|
303
|
+
return (o["asset"], o["box"]) + fades + window
|
|
304
|
+
|
|
305
|
+
groups: "List[Tuple]" = []
|
|
306
|
+
for o in overlays:
|
|
307
|
+
if _key(o) not in groups:
|
|
308
|
+
groups.append(_key(o))
|
|
309
|
+
chains: List[str] = []
|
|
310
|
+
inputs: "List[List[str]]" = []
|
|
311
|
+
pads: "Dict[Tuple, List[str]]" = {}
|
|
312
|
+
for k, key in enumerate(groups):
|
|
313
|
+
asset, box, fin, fout, gstart, gend = key
|
|
314
|
+
uses = [o for o in overlays if _key(o) == key]
|
|
315
|
+
idx = first_input + k
|
|
316
|
+
labels = [f"e{k}_{j}" for j in range(len(uses))]
|
|
317
|
+
chain = f"[{idx}:v]format=rgba,scale={box}:{box}"
|
|
318
|
+
if fin or fout:
|
|
319
|
+
# -loop 1 gives the still an advancing timeline on the SAME clock as the main video,
|
|
320
|
+
# so the fade times below are the cue's own seconds. The emoji then appears and
|
|
321
|
+
# leaves with the text instead of popping in against a fading line.
|
|
322
|
+
# -t bounds the loop at the cue's end: an unbounded looped still never EOFs and the
|
|
323
|
+
# whole encode hangs (overlay keeps pulling from it after the main video is done).
|
|
324
|
+
inputs.append(["-loop", "1", "-t", f"{gend:.3f}", "-i", asset])
|
|
325
|
+
if fin:
|
|
326
|
+
chain += f",fade=t=in:st={gstart:.3f}:d={fin:.3f}:alpha=1"
|
|
327
|
+
if fout:
|
|
328
|
+
chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
|
|
329
|
+
else:
|
|
330
|
+
inputs.append(["-i", asset])
|
|
331
|
+
if len(labels) > 1:
|
|
332
|
+
chain += f",split={len(labels)}"
|
|
333
|
+
chains.append(chain + "".join(f"[{l}]" for l in labels))
|
|
334
|
+
pads[key] = labels
|
|
335
|
+
cur = base_label
|
|
336
|
+
remaining = {key: list(v) for key, v in pads.items()}
|
|
337
|
+
for j, o in enumerate(overlays):
|
|
338
|
+
label = remaining[_key(o)].pop(0)
|
|
339
|
+
nxt = out_label if j == len(overlays) - 1 else f"eov{j}"
|
|
340
|
+
x = o["x"]
|
|
341
|
+
x = f"'{x}'" if isinstance(x, str) else x
|
|
342
|
+
# No eof_action=pass here: a PNG input is a SINGLE frame at pts 0, and eof_action=pass
|
|
343
|
+
# switches off overlay's default "hold the last frame of the secondary input", so the
|
|
344
|
+
# asset would be composited on frame 0 only and vanish for the rest of the cue (that is
|
|
345
|
+
# exactly what shipped first). eof_action=repeat (the default) holds the still for the
|
|
346
|
+
# whole timeline; enable= is what confines it to the cue's window.
|
|
347
|
+
chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
|
|
348
|
+
f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
|
|
349
|
+
cur = nxt
|
|
350
|
+
return chains, inputs
|