ffmpeg-skill 1.14.0 → 1.15.1
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/README.md +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
package/scripts/_contract.py
CHANGED
|
@@ -768,11 +768,11 @@ def doctor() -> Dict[str, Any]:
|
|
|
768
768
|
"ok": not missing_required and not unknown_required,
|
|
769
769
|
"tools": _tool_usability(state),
|
|
770
770
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
771
|
-
"fonts": _fonts_capability(),
|
|
771
|
+
"fonts": _fonts_capability(probe=True),
|
|
772
772
|
}
|
|
773
773
|
|
|
774
774
|
|
|
775
|
-
def _fonts_capability() -> Dict[str, Any]:
|
|
775
|
+
def _fonts_capability(probe: bool = True) -> Dict[str, Any]:
|
|
776
776
|
"""The default drawtext family (issue #66) plus, since 1.12, one entry per script the tools
|
|
777
777
|
can detect: which languages this machine can actually RENDER, not just which filters exist.
|
|
778
778
|
|
|
@@ -781,7 +781,7 @@ def _fonts_capability() -> Dict[str, Any]:
|
|
|
781
781
|
here follows). Informational like the default font and gpu_encoders: a machine with no Thai
|
|
782
782
|
font is not a broken install, it is a machine that must not be asked to burn Thai captions.
|
|
783
783
|
"""
|
|
784
|
-
from _common import SCRIPTS, font_for_script, script_font_status
|
|
784
|
+
from _common import SCRIPTS, font_for_script, script_font_status, emoji_support
|
|
785
785
|
|
|
786
786
|
font = _default_font()
|
|
787
787
|
result = _font_available(font)
|
|
@@ -794,7 +794,12 @@ def _fonts_capability() -> Dict[str, Any]:
|
|
|
794
794
|
# distinction the tools refuse or continue on.
|
|
795
795
|
status = script_font_status(script)
|
|
796
796
|
scripts[script] = {"status": status, "file": font_for_script(script) if status == "available" else None}
|
|
797
|
-
|
|
797
|
+
# 1.15: emoji are a separate question from the writing system, and the only honest answer is a
|
|
798
|
+
# render (an installed colour emoji font proves nothing -- libass on this build may still draw
|
|
799
|
+
# it monochrome). `probe=False` -- `contract --json --static`, and every static/JSON-only path -- skips
|
|
800
|
+
# that render, exactly as it skips the rest of the environment detection.
|
|
801
|
+
return {"default_font": font, "status": result["status"], "detail": result["detail"],
|
|
802
|
+
"scripts": scripts, "emoji": emoji_support(probe=probe)}
|
|
798
803
|
|
|
799
804
|
|
|
800
805
|
def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
|
|
@@ -819,6 +824,16 @@ def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
|
|
|
819
824
|
parts.append("no font for " + " ".join(by_state["missing"]))
|
|
820
825
|
if by_state["unknown"]:
|
|
821
826
|
parts.append("unknown (no fontconfig) " + " ".join(by_state["unknown"]))
|
|
827
|
+
emoji = fonts.get("emoji") or {}
|
|
828
|
+
mode = emoji.get("mode")
|
|
829
|
+
if mode == "color":
|
|
830
|
+
parts.append("emoji colour")
|
|
831
|
+
elif mode == "png":
|
|
832
|
+
parts.append("emoji colour (assets)")
|
|
833
|
+
elif mode == "mono":
|
|
834
|
+
parts.append("emoji monochrome -- --emoji-assets DIR for colour")
|
|
835
|
+
elif mode:
|
|
836
|
+
parts.append("emoji none")
|
|
822
837
|
return "; ".join(parts)
|
|
823
838
|
|
|
824
839
|
|
package/scripts/caption.py
CHANGED
|
@@ -38,10 +38,11 @@ import re
|
|
|
38
38
|
import sys
|
|
39
39
|
import unicodedata
|
|
40
40
|
from pathlib import Path
|
|
41
|
-
from typing import List, Optional, Tuple
|
|
41
|
+
from typing import Dict, List, Optional, Tuple
|
|
42
42
|
|
|
43
43
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
|
|
44
|
-
from
|
|
44
|
+
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
45
|
+
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, LEADING_VOWELS, NO_SPACE_SCRIPTS, _char_em, _is_mark, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
|
|
45
46
|
|
|
46
47
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
47
48
|
|
|
@@ -298,89 +299,41 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
298
299
|
return out
|
|
299
300
|
|
|
300
301
|
|
|
301
|
-
# --------------------------------------------------------------------------- readable cues (1.12)
|
|
302
|
-
# Average advance width per character, in em (a fraction of the font size). Proportional Latin text
|
|
303
|
-
# averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
|
|
304
|
-
# Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
|
|
305
|
-
# real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
|
|
306
|
-
# shaping, while a cue wrapped from an average is right to within a character on every line.
|
|
307
|
-
# (Latin is measured per character from LATIN_EM below, not from this average.)
|
|
308
|
-
ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
|
|
309
|
-
"ru": 0.55, "el": 0.55, "latin": 0.55}
|
|
310
302
|
# How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
|
|
311
303
|
# 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
|
|
312
304
|
SAFE_WIDTH_FRACTION = 0.9
|
|
313
|
-
#
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
|
|
317
|
-
# against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
|
|
318
|
-
# the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
|
|
319
|
-
# past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
|
|
320
|
-
# un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
|
|
321
|
-
# 0.57 lowercase and anything else Latin-ish).
|
|
322
|
-
LATIN_EM = {
|
|
323
|
-
' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
|
|
324
|
-
'(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
|
|
325
|
-
'1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
|
|
326
|
-
'9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
|
|
327
|
-
'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
|
|
328
|
-
'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
|
|
329
|
-
'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
|
|
330
|
-
'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
|
|
331
|
-
'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
|
|
332
|
-
'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
|
|
333
|
-
'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,
|
|
334
|
-
'{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
|
|
335
|
-
}
|
|
336
|
-
# Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
|
|
337
|
-
# between them and the base that follows.
|
|
338
|
-
LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _is_mark(ch: str) -> bool:
|
|
342
|
-
"""A character that hangs off the one before it: a combining mark (any script) or one of the
|
|
343
|
-
Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
|
|
344
|
-
return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def _char_em(ch: str) -> float:
|
|
348
|
-
# CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
|
|
349
|
-
# full-width grid as the ideographs they sit between, even though they are not "Han" to a
|
|
350
|
-
# script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
|
|
351
|
-
cp = ord(ch)
|
|
352
|
-
# A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
|
|
353
|
-
# charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
|
|
354
|
-
if unicodedata.combining(ch) != 0 or unicodedata.category(ch) == "Mn":
|
|
355
|
-
return 0.0
|
|
356
|
-
if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
|
|
357
|
-
return 1.0
|
|
358
|
-
script = char_script(ch)
|
|
359
|
-
if script == "latin":
|
|
360
|
-
if ch in LATIN_EM:
|
|
361
|
-
return LATIN_EM[ch]
|
|
362
|
-
if ch.isupper() or ch.isdigit():
|
|
363
|
-
return 0.7
|
|
364
|
-
return 0.57
|
|
365
|
-
return ADVANCE_EM.get(script, 0.55)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def text_width_em(text: str) -> float:
|
|
369
|
-
"""Width of `text` in em, from the per-script average advance table."""
|
|
370
|
-
return sum(_char_em(ch) for ch in text)
|
|
305
|
+
# ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
|
|
306
|
+
# single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
|
|
307
|
+
ORPHAN_MIN_EM = 1.1
|
|
371
308
|
|
|
372
309
|
|
|
373
310
|
def _atoms(line: str) -> List[Tuple[str, bool]]:
|
|
374
311
|
"""Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
|
|
375
|
-
character, one per whitespace-delimited word otherwise -- each with
|
|
376
|
-
before it in the original. The flag is what puts the text back together
|
|
377
|
-
"Hello 世界" keeps its space, "世界です" gains none."""
|
|
312
|
+
character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
|
|
313
|
+
whether a space stood before it in the original. The flag is what puts the text back together
|
|
314
|
+
exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
|
|
378
315
|
out: List[Tuple[str, bool]] = []
|
|
379
316
|
word = ""
|
|
380
317
|
spaced = False # a space stands before the atom being built
|
|
381
318
|
pending = False # a space stands before the NEXT atom
|
|
382
319
|
attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
|
|
383
|
-
|
|
320
|
+
# An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
|
|
321
|
+
# between a base and its skin-tone modifier (the same rule combining marks already follow).
|
|
322
|
+
clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
|
|
323
|
+
i = 0
|
|
324
|
+
while i < len(line):
|
|
325
|
+
ch = line[i]
|
|
326
|
+
if i in clusters:
|
|
327
|
+
cluster = line[i:i + clusters[i]]
|
|
328
|
+
if word:
|
|
329
|
+
out.append((word, spaced))
|
|
330
|
+
word = ""
|
|
331
|
+
out.append((cluster, pending))
|
|
332
|
+
pending = False
|
|
333
|
+
attach_next = False
|
|
334
|
+
i += clusters[i]
|
|
335
|
+
continue
|
|
336
|
+
i += 1
|
|
384
337
|
if char_script(ch) in NO_SPACE_SCRIPTS:
|
|
385
338
|
if word:
|
|
386
339
|
out.append((word, spaced))
|
|
@@ -414,29 +367,240 @@ def _join(left: str, atom: str, spaced: bool) -> str:
|
|
|
414
367
|
return left + (" " if spaced else "") + atom
|
|
415
368
|
|
|
416
369
|
|
|
417
|
-
def
|
|
370
|
+
def _break_spaced(first: str, second: str) -> bool:
|
|
371
|
+
"""Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
|
|
372
|
+
there -- a CJK/Thai break sits between two characters that were written with nothing between
|
|
373
|
+
them, and re-joining them with a space would insert a character the cue never had."""
|
|
374
|
+
if not first or not second:
|
|
375
|
+
return False
|
|
376
|
+
return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
|
|
377
|
+
and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _fix_orphans(lines: List[str], max_em: float) -> List[str]:
|
|
381
|
+
"""No last line that is a single stranded atom.
|
|
382
|
+
|
|
383
|
+
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
384
|
+
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
385
|
+
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
386
|
+
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
387
|
+
itself, so a two-word cue is never made worse."""
|
|
388
|
+
lines = list(lines)
|
|
389
|
+
for _ in range(len(lines)):
|
|
390
|
+
if len(lines) < 2:
|
|
391
|
+
break
|
|
392
|
+
tail = _atoms(lines[-1])
|
|
393
|
+
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
394
|
+
break
|
|
395
|
+
prev = _atoms(lines[-2])
|
|
396
|
+
if len(prev) < 2:
|
|
397
|
+
break
|
|
398
|
+
moved, spaced = prev[-1]
|
|
399
|
+
new_prev = ""
|
|
400
|
+
for atom, sp in prev[:-1]:
|
|
401
|
+
new_prev = _join(new_prev, atom, sp)
|
|
402
|
+
new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
|
|
403
|
+
if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
404
|
+
break
|
|
405
|
+
lines[-2], lines[-1] = new_prev, new_last
|
|
406
|
+
return lines
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _rebalance(lines: List[str], max_em: float) -> List[str]:
|
|
410
|
+
"""Move each break to the one that minimises the widest line of the pair, without changing the
|
|
411
|
+
line count.
|
|
412
|
+
|
|
413
|
+
Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
|
|
414
|
+
`"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
|
|
415
|
+
non-spaced script has no phrase structure in its atom list, so moving the break there only
|
|
416
|
+
moves the ragged edge. A break is never placed before a punctuation-only atom."""
|
|
417
|
+
if len(lines) < 2:
|
|
418
|
+
return lines
|
|
419
|
+
out = list(lines)
|
|
420
|
+
for i in range(len(out) - 1):
|
|
421
|
+
first, second = out[i], out[i + 1]
|
|
422
|
+
tail_atoms = _atoms(second)
|
|
423
|
+
if tail_atoms:
|
|
424
|
+
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
425
|
+
atoms = _atoms(first) + tail_atoms
|
|
426
|
+
if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
|
|
427
|
+
continue
|
|
428
|
+
best = None
|
|
429
|
+
for cut in range(1, len(atoms)):
|
|
430
|
+
if not atoms[cut][1]:
|
|
431
|
+
continue # only break where a space stood
|
|
432
|
+
if all(not ch.isalnum() for ch in atoms[cut][0]):
|
|
433
|
+
continue # never strand punctuation at the start of a line
|
|
434
|
+
a = b = ""
|
|
435
|
+
for atom, sp in atoms[:cut]:
|
|
436
|
+
a = _join(a, atom, sp)
|
|
437
|
+
for atom, sp in atoms[cut:]:
|
|
438
|
+
b = _join(b, atom, sp)
|
|
439
|
+
wa, wb = text_width_em(a), text_width_em(b)
|
|
440
|
+
if max(wa, wb) > max_em:
|
|
441
|
+
continue
|
|
442
|
+
key = (max(wa, wb), abs(wa - wb))
|
|
443
|
+
if best is None or key < best[0]:
|
|
444
|
+
best = (key, a, b)
|
|
445
|
+
if best is not None:
|
|
446
|
+
out[i], out[i + 1] = best[1], best[2]
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def wrap_text(text: str, max_em: float, *, balance: bool = True) -> List[str]:
|
|
418
451
|
"""Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
|
|
419
452
|
|
|
420
453
|
An atom wider than the whole line (one very long word) is left alone on its line rather than
|
|
421
|
-
cut mid-word: an over-long line is readable, a chopped word is not.
|
|
454
|
+
cut mid-word: an over-long line is readable, a chopped word is not. Two post-passes then make
|
|
455
|
+
the result readable rather than merely legal (1.15): no one-character orphan line, and for
|
|
456
|
+
spaced scripts a break chosen to minimise the widest line instead of greedily.
|
|
422
457
|
"""
|
|
423
458
|
lines: List[str] = []
|
|
424
459
|
for raw in text.split("\n"):
|
|
425
460
|
if not raw.strip():
|
|
426
461
|
continue
|
|
427
462
|
current = ""
|
|
463
|
+
chunk: List[str] = []
|
|
428
464
|
for atom, spaced in _atoms(raw):
|
|
429
465
|
candidate = _join(current, atom, spaced)
|
|
430
466
|
if current and text_width_em(candidate) > max_em:
|
|
431
|
-
|
|
467
|
+
chunk.append(current)
|
|
432
468
|
current = atom
|
|
433
469
|
else:
|
|
434
470
|
current = candidate
|
|
435
471
|
if current:
|
|
436
|
-
|
|
472
|
+
chunk.append(current)
|
|
473
|
+
if balance and len(chunk) > 1:
|
|
474
|
+
fixed = _fix_orphans(chunk, max_em)
|
|
475
|
+
rebalanced = _rebalance(fixed, max_em)
|
|
476
|
+
if len(rebalanced) == len(chunk):
|
|
477
|
+
chunk = rebalanced
|
|
478
|
+
else:
|
|
479
|
+
chunk = fixed
|
|
480
|
+
lines.extend(chunk)
|
|
437
481
|
return lines or [text]
|
|
438
482
|
|
|
439
483
|
|
|
484
|
+
# --------------------------------------------------------------------------- emoji (1.15)
|
|
485
|
+
def _cue_lines(text: str) -> List[str]:
|
|
486
|
+
return [l for l in text.split("\n")]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
490
|
+
"""Decide how this run draws the emoji in `cues`, and where each PNG goes.
|
|
491
|
+
|
|
492
|
+
Returns (cues, plan) where `cues` may have had its emoji replaced by EMOJI_SENTINEL (the PNG
|
|
493
|
+
route) or stripped (`--emoji none`), and `plan` is the `emoji` result key plus the overlay
|
|
494
|
+
entries the filter graph needs. `None` plan means "nothing to do": no emoji in the text.
|
|
495
|
+
"""
|
|
496
|
+
clusters_all = [cl for _s, _e, t in cues for _i, cl in emoji_clusters(t)]
|
|
497
|
+
if not clusters_all:
|
|
498
|
+
return cues, None
|
|
499
|
+
assets = resolve_emoji_assets(getattr(args, "emoji_assets", None), None, brand)
|
|
500
|
+
want = getattr(args, "emoji", "auto")
|
|
501
|
+
support = emoji_support(assets, probe=True)
|
|
502
|
+
mode = support["mode"] if want == "auto" else want
|
|
503
|
+
if want == "color" and not support["libass_color"]:
|
|
504
|
+
die("--emoji color: this ffmpeg renders emoji monochrome through libass "
|
|
505
|
+
f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono",
|
|
506
|
+
kind="input")
|
|
507
|
+
if want == "png" and not assets:
|
|
508
|
+
die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
|
|
509
|
+
plan = {"mode": mode, "count": len(clusters_all),
|
|
510
|
+
"clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
|
|
511
|
+
"assets": assets, "missing": [], "overlays": []}
|
|
512
|
+
if mode == "none":
|
|
513
|
+
out = []
|
|
514
|
+
for start, end, text in cues:
|
|
515
|
+
for cl in {cl for _i, cl in emoji_clusters(text)}:
|
|
516
|
+
text = text.replace(cl, "")
|
|
517
|
+
out.append((start, end, re.sub(r"[ \t]{2,}", " ", text).strip()))
|
|
518
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
519
|
+
return out, plan
|
|
520
|
+
if mode in ("color", "mono"):
|
|
521
|
+
if mode == "mono":
|
|
522
|
+
info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
523
|
+
"--emoji-assets DIR for colour). " + support["detail"])
|
|
524
|
+
return cues, plan
|
|
525
|
+
# --- the PNG overlay route -------------------------------------------------------------
|
|
526
|
+
if not play_w or not play_h:
|
|
527
|
+
return cues, plan
|
|
528
|
+
scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
|
|
529
|
+
# --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
|
|
530
|
+
# pops in against a line that is still fading up. These match the \fad values below.
|
|
531
|
+
fade_in, fade_out = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
|
|
532
|
+
"slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
|
|
533
|
+
size_px = args.size * play_h / 288.0
|
|
534
|
+
margin_px = args.margin * play_h / 288.0
|
|
535
|
+
line_h = size_px * 1.2
|
|
536
|
+
box_px = size_px * scale
|
|
537
|
+
align = ALIGN[args.position]
|
|
538
|
+
out_cues = []
|
|
539
|
+
for start, end, text in cues:
|
|
540
|
+
lines = _cue_lines(text)
|
|
541
|
+
n = len(lines)
|
|
542
|
+
new_lines = []
|
|
543
|
+
for i, line in enumerate(lines):
|
|
544
|
+
if align in (7, 8, 9):
|
|
545
|
+
y_top = margin_px + i * line_h
|
|
546
|
+
elif align in (4, 5, 6):
|
|
547
|
+
y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
|
|
548
|
+
else:
|
|
549
|
+
y_top = play_h - margin_px - (n - i) * line_h
|
|
550
|
+
line_w = text_width_em(line, scale) * size_px
|
|
551
|
+
if align in (1, 4, 7):
|
|
552
|
+
x0 = margin_px
|
|
553
|
+
elif align in (3, 6, 9):
|
|
554
|
+
x0 = play_w - margin_px - line_w
|
|
555
|
+
else:
|
|
556
|
+
x0 = (play_w - line_w) / 2.0
|
|
557
|
+
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
558
|
+
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
559
|
+
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
560
|
+
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
561
|
+
rebuilt = ""
|
|
562
|
+
cursor = 0
|
|
563
|
+
for idx, cluster in emoji_clusters(line):
|
|
564
|
+
prefix = line[:idx]
|
|
565
|
+
asset = emoji_asset_for(cluster, assets)
|
|
566
|
+
name = emoji_codepoint_name(cluster)
|
|
567
|
+
if not asset:
|
|
568
|
+
if name not in plan["missing"]:
|
|
569
|
+
plan["missing"].append(name)
|
|
570
|
+
rebuilt += line[cursor:idx + len(cluster)]
|
|
571
|
+
cursor = idx + len(cluster)
|
|
572
|
+
continue
|
|
573
|
+
if rtl:
|
|
574
|
+
x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
|
|
575
|
+
else:
|
|
576
|
+
x = x0 + text_width_em(prefix, scale) * size_px
|
|
577
|
+
y = y_top + (line_h - box_px) / 2.0
|
|
578
|
+
plan["overlays"].append({
|
|
579
|
+
"asset": asset, "cluster": name,
|
|
580
|
+
"x": int(round(max(0.0, min(x, play_w - box_px)))),
|
|
581
|
+
"y": int(round(max(0.0, min(y, play_h - box_px)))),
|
|
582
|
+
"start": round(start, 3), "end": round(end, 3), "box": int(round(box_px)),
|
|
583
|
+
"fade_in": round(min(fade_in, max(0.0, (end - start) / 2.0)), 3),
|
|
584
|
+
"fade_out": round(min(fade_out, max(0.0, (end - start) / 2.0)), 3)})
|
|
585
|
+
rebuilt += line[cursor:idx] + EMOJI_SENTINEL
|
|
586
|
+
cursor = idx + len(cluster)
|
|
587
|
+
rebuilt += line[cursor:]
|
|
588
|
+
new_lines.append(rebuilt)
|
|
589
|
+
out_cues.append((start, end, "\n".join(new_lines)))
|
|
590
|
+
# `or 60` would swallow the one value that means "no overlays at all".
|
|
591
|
+
_max = getattr(args, "emoji_max", None)
|
|
592
|
+
limit = 60 if _max is None else int(_max)
|
|
593
|
+
if len(plan["overlays"]) > limit:
|
|
594
|
+
die(f"{len(plan['overlays'])} emoji overlays would be built for this job (limit {limit}, "
|
|
595
|
+
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
596
|
+
"linearly -- split the job, or use --emoji none", kind="input")
|
|
597
|
+
if plan["missing"]:
|
|
598
|
+
info("warning: no PNG in the assets directory for " + ", ".join(plan["missing"]) +
|
|
599
|
+
" -- those clusters are drawn by the text font instead")
|
|
600
|
+
plan["box_px"] = int(round(box_px))
|
|
601
|
+
return out_cues, plan
|
|
602
|
+
|
|
603
|
+
|
|
440
604
|
def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
|
|
441
605
|
min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
|
|
442
606
|
"""Shift, wrap, split and lengthen cues so they can actually be read.
|
|
@@ -447,7 +611,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
447
611
|
proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
|
|
448
612
|
cue's start. Returns the new cues and a count of what changed.
|
|
449
613
|
"""
|
|
450
|
-
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0}
|
|
614
|
+
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0}
|
|
451
615
|
staged: List[Tuple[float, float, str]] = []
|
|
452
616
|
for start, end, text in cues:
|
|
453
617
|
if offset:
|
|
@@ -461,6 +625,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
461
625
|
lines = wrap_text(text, max_em)
|
|
462
626
|
if lines != [l for l in text.split("\n") if l.strip()]:
|
|
463
627
|
stats["wrapped"] += 1
|
|
628
|
+
if lines != wrap_text(text, max_em, balance=False):
|
|
629
|
+
stats["rebalanced"] += 1
|
|
464
630
|
if len(lines) > max_lines:
|
|
465
631
|
chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
|
|
466
632
|
weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
|
|
@@ -488,7 +654,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
488
654
|
|
|
489
655
|
def report_layout(stats: dict) -> None:
|
|
490
656
|
"""One info line, only when a cue actually changed."""
|
|
491
|
-
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "split", "extended", "dropped") if stats.get(k)]
|
|
657
|
+
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "split", "extended", "dropped") if stats.get(k)]
|
|
492
658
|
if parts:
|
|
493
659
|
info("cues: " + ", ".join(parts))
|
|
494
660
|
|
|
@@ -628,15 +794,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
628
794
|
]
|
|
629
795
|
lines = []
|
|
630
796
|
for start, end, text in cues:
|
|
631
|
-
text = text.replace("\n", "\\N")
|
|
632
797
|
# ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
|
|
633
798
|
# commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
|
|
634
799
|
# all effectively user-controlled) that happens to contain braces would otherwise be
|
|
635
800
|
# interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
|
|
636
|
-
# rescale, or recolor itself or later text instead of just being read out.
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
|
|
801
|
+
# rescale, or recolor itself or later text instead of just being read out. libass has real
|
|
802
|
+
# escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
|
|
803
|
+
# a cue that says "use {curly} braces" is read out with its braces, and still cannot open
|
|
804
|
+
# an override block. Newlines become \N in the same pass.
|
|
805
|
+
text = ass_escape(text)
|
|
640
806
|
fx = ""
|
|
641
807
|
if args.animate == "fade":
|
|
642
808
|
fx = "{\\fad(200,200)}"
|
|
@@ -649,7 +815,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
649
815
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
650
816
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
651
817
|
segments = body.split("\\N")
|
|
652
|
-
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
818
|
+
words = [w for seg in segments for w in seg.split(" ") if w and w.strip(EMOJI_SENTINEL)]
|
|
653
819
|
# real word timings from the transcript beat both the energy estimate and the even
|
|
654
820
|
# split -- they are what the speaker actually did, not a proxy for it
|
|
655
821
|
durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
|
|
@@ -663,8 +829,14 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
663
829
|
out_segments = []
|
|
664
830
|
for seg in segments:
|
|
665
831
|
ws = [w for w in seg.split(" ") if w]
|
|
666
|
-
|
|
832
|
+
# An emoji placeholder is its own ZERO-duration \kf segment: the highlight sweeps
|
|
833
|
+
# past the reserved gap without spending cue time on a glyph nobody sees (rendered
|
|
834
|
+
# and confirmed -- libass keeps the full gap inside a karaoke run).
|
|
835
|
+
out_segments.append(" ".join(
|
|
836
|
+
("{\\kf0}" + w) if not w.strip(EMOJI_SENTINEL) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
667
837
|
body = "\\N".join(out_segments)
|
|
838
|
+
if EMOJI_SENTINEL in body:
|
|
839
|
+
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
668
840
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
669
841
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
670
842
|
fh.write("\n".join(header + lines) + "\n")
|
|
@@ -765,6 +937,20 @@ def main() -> int:
|
|
|
765
937
|
help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
|
|
766
938
|
"zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
|
|
767
939
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
940
|
+
emo = ap.add_argument_group("emoji (1.15)")
|
|
941
|
+
emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
|
|
942
|
+
help="how emoji in the cues are drawn: 'auto' picks the best this machine can do "
|
|
943
|
+
"(doctor --json .fonts.emoji), 'color' insists on a colour-capable libass, "
|
|
944
|
+
"'png' composites the --emoji-assets PNGs, 'mono' draws whatever glyph the text "
|
|
945
|
+
"font has, 'none' strips them")
|
|
946
|
+
emo.add_argument("--emoji-assets", metavar="DIR",
|
|
947
|
+
help="directory of emoji PNGs named by code point (1f389.png, 1f1ef-1f1f5.png) -- "
|
|
948
|
+
"Twemoji's assets/72x72 or Noto Emoji's png/128. Nothing is ever downloaded; "
|
|
949
|
+
"also read from brand.json styles.caption.emoji_assets and FFMPEG_SKILL_EMOJI_ASSETS")
|
|
950
|
+
emo.add_argument("--emoji-scale", type=float, default=1.0,
|
|
951
|
+
help="emoji box as a multiple of the line's font size (default 1.0)")
|
|
952
|
+
emo.add_argument("--emoji-max", type=int, default=60,
|
|
953
|
+
help="most emoji overlays one run may build (default 60)")
|
|
768
954
|
sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
|
|
769
955
|
sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
|
|
770
956
|
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
@@ -993,11 +1179,34 @@ def main() -> int:
|
|
|
993
1179
|
if not args.fonts_dir:
|
|
994
1180
|
args.fonts_dir = os.path.dirname(font_file)
|
|
995
1181
|
|
|
996
|
-
|
|
1182
|
+
# Emoji (1.15). Decided once, on the cues the burn will actually use: libass cannot place a
|
|
1183
|
+
# PNG, so the text keeps its place in the ASS (with the gap reserved) and each emoji becomes an
|
|
1184
|
+
# overlay composited after the ass= filter. A cue file that has no emoji costs nothing here.
|
|
1185
|
+
emoji_plan = None
|
|
1186
|
+
emoji_cues = None
|
|
1187
|
+
if not args.ass:
|
|
1188
|
+
if args.text or args.transcribe:
|
|
1189
|
+
src_cues = cues
|
|
1190
|
+
elif planned_cues is not None:
|
|
1191
|
+
src_cues = planned_cues
|
|
1192
|
+
elif os.path.exists(srt_path or ""):
|
|
1193
|
+
src_cues = parse_srt(srt_path)
|
|
1194
|
+
else:
|
|
1195
|
+
src_cues = []
|
|
1196
|
+
if src_cues and has_emoji("\n".join(t for _s, _e, t in src_cues)):
|
|
1197
|
+
emoji_cues, emoji_plan = plan_emoji(src_cues, args, play_w, play_h,
|
|
1198
|
+
brand if args.brand else None)
|
|
1199
|
+
# the PNG route and --emoji none both change the drawn text, so they need the generated ASS
|
|
1200
|
+
force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
|
|
1201
|
+
if emoji_plan and emoji_plan.get("box_px"):
|
|
1202
|
+
args._emoji_box_px = emoji_plan["box_px"]
|
|
1203
|
+
|
|
1204
|
+
if (args.animate != "none" or args.karaoke or force_ass) and not args.ass:
|
|
997
1205
|
# both sources are already laid out: `cues` above, and srt_path was rewritten in place of
|
|
998
1206
|
# the caller's file when --offset/--max-lines/--min-duration changed anything
|
|
999
|
-
cues_for_ass =
|
|
1000
|
-
|
|
1207
|
+
cues_for_ass = emoji_cues if emoji_cues is not None else (
|
|
1208
|
+
cues if (args.text or args.transcribe) else (
|
|
1209
|
+
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1001
1210
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1002
1211
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1003
1212
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
@@ -1040,15 +1249,40 @@ def main() -> int:
|
|
|
1040
1249
|
if args.fonts_dir:
|
|
1041
1250
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
1042
1251
|
|
|
1043
|
-
cmd = ffmpeg_base() + ["-i", args.input
|
|
1252
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1253
|
+
chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout") if emoji_plan else ([], [])
|
|
1254
|
+
if chains:
|
|
1255
|
+
for spec in emoji_inputs:
|
|
1256
|
+
cmd += spec
|
|
1257
|
+
asset = spec[-1]
|
|
1258
|
+
if asset not in STATE.plan_inputs:
|
|
1259
|
+
STATE.plan_inputs.append(asset)
|
|
1260
|
+
graph = ";".join([f"[0:v]{vf}[vsub]"] + chains)
|
|
1261
|
+
cmd += ["-filter_complex", graph, "-map", "[vout]"]
|
|
1262
|
+
else:
|
|
1263
|
+
cmd += ["-map", "0:v:0", "-vf", vf]
|
|
1044
1264
|
if meta.get("audio"):
|
|
1045
1265
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
1046
|
-
cmd +=
|
|
1266
|
+
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
1047
1267
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
1048
1268
|
run(cmd)
|
|
1049
1269
|
result = probe(output, role="output")
|
|
1050
1270
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
1051
|
-
|
|
1271
|
+
extra = {"notes": side_notes} if side_notes else {}
|
|
1272
|
+
if emoji_plan:
|
|
1273
|
+
notes = list(extra.get("notes") or [])
|
|
1274
|
+
if emoji_plan["mode"] == "mono":
|
|
1275
|
+
notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
1276
|
+
"--emoji-assets DIR for colour)")
|
|
1277
|
+
if emoji_plan["mode"] == "none":
|
|
1278
|
+
notes.append("emoji stripped from the drawn text (--emoji none)")
|
|
1279
|
+
if emoji_plan["missing"]:
|
|
1280
|
+
notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
|
|
1281
|
+
if notes:
|
|
1282
|
+
extra["notes"] = notes
|
|
1283
|
+
extra["emoji"] = {k: v for k, v in emoji_plan.items() if k not in ("overlays", "box_px")}
|
|
1284
|
+
extra["emoji"]["overlays"] = len(emoji_plan.get("overlays") or [])
|
|
1285
|
+
emit(output, **extra)
|
|
1052
1286
|
return 0
|
|
1053
1287
|
|
|
1054
1288
|
|