ffmpeg-skill 1.17.3 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 `abc‍def`
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