ffmpeg-skill 1.14.0 → 1.15.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.
@@ -15,6 +15,7 @@ import re
15
15
  import shutil
16
16
  import subprocess
17
17
  import sys
18
+ import unicodedata
18
19
  from fractions import Fraction
19
20
  from pathlib import Path
20
21
  from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -520,6 +521,10 @@ def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
520
521
  for key, value in doc.items():
521
522
  if key not in brief and key not in _BRIEF_DROP:
522
523
  brief[key] = value
524
+ # the emoji report is a full inventory in the long document; brief keeps the two fields a
525
+ # caller branches on (did colour happen, and how many)
526
+ if isinstance(brief.get("emoji"), dict):
527
+ brief["emoji"] = {k: v for k, v in brief["emoji"].items() if k in ("mode", "count")}
523
528
  return brief
524
529
 
525
530
 
@@ -978,6 +983,8 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
978
983
  info(("[dry-run] $ " if ctx.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd), ctx=ctx)
979
984
  if ctx.dry_run and is_ffmpeg:
980
985
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
986
+ if is_ffmpeg:
987
+ flush_drawtext_textfiles(cmd)
981
988
  with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
982
989
  exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
983
990
  proc = _execute(exec_cmd)
@@ -1733,17 +1740,18 @@ def default_font_file(font_name: str) -> Optional[str]:
1733
1740
  # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
1734
1741
  # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
1735
1742
  # warning (a video full of boxes is not a delivery).
1736
- SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "th", "ru", "el", "latin")
1743
+ SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
1737
1744
 
1738
1745
  LANGUAGE_NAMES = {
1739
1746
  "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
1740
1747
  "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
1741
- "el": "Greek", "latin": "Latin",
1748
+ "el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
1742
1749
  }
1743
1750
 
1744
1751
  # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
1745
1752
  # every CJK font that claims zh carries; the rest are the plain two-letter codes).
1746
- FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el"}
1753
+ FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
1754
+ "bn": "bn", "ta": "ta", "lo": "lo"}
1747
1755
 
1748
1756
  # Families tried in order, best first. The names are matched case-insensitively against the start
1749
1757
  # of any family fontconfig reports for a file, so "Noto Sans CJK JP" also matches
@@ -1756,6 +1764,9 @@ PREFERRED_FAMILIES = {
1756
1764
  "he": ["Noto Sans Hebrew", "Noto Serif Hebrew", "DejaVu Sans", "FreeSans", "FreeSerif"],
1757
1765
  "hi": ["Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari", "Mangal", "Nirmala UI", "Samyak Devanagari", "FreeSans", "FreeSerif"],
1758
1766
  "th": ["Noto Sans Thai", "Noto Serif Thai", "Loma", "Garuda", "Waree", "Umpush", "Norasi", "Sarabun", "Leelawadee UI", "FreeSerif"],
1767
+ "bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
1768
+ "ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
1769
+ "lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
1759
1770
  "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1760
1771
  "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1761
1772
  }
@@ -1769,6 +1780,9 @@ WINDOWS_FONTS = {
1769
1780
  "he": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
1770
1781
  "hi": [("mangal.ttf", "Mangal"), ("Nirmala.ttf", "Nirmala UI"), ("NirmalaB.ttf", "Nirmala UI")],
1771
1782
  "th": [("leelawui.ttf", "Leelawadee UI"), ("leelawad.ttf", "Leelawadee"), ("tahoma.ttf", "Tahoma")],
1783
+ "bn": [("Nirmala.ttf", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
1784
+ "ta": [("Nirmala.ttf", "Nirmala UI"), ("latha.ttf", "Latha")],
1785
+ "lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
1772
1786
  "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1773
1787
  "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1774
1788
  }
@@ -1780,16 +1794,487 @@ _SCRIPT_RANGES = (
1780
1794
  ("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF))),
1781
1795
  ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
1782
1796
  ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
1797
+ ("bn", ((0x0980, 0x09FF),)),
1798
+ ("ta", ((0x0B80, 0x0BFF),)),
1783
1799
  ("th", ((0x0E00, 0x0E7F),)),
1800
+ ("lo", ((0x0E80, 0x0EFF),)),
1784
1801
  ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
1785
1802
  ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
1786
1803
  )
1787
1804
 
1788
1805
 
1806
+ # --------------------------------------------------------------------------- emoji (1.15)
1807
+ # Emoji are orthogonal to the writing system: "やった 🎉" is Japanese AND emoji. They are detected
1808
+ # separately from detect_script() so a cue's font resolution is still decided by its letters.
1809
+ EMOJI_RANGES = (
1810
+ (0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
1811
+ (0x1F000, 0x1F0FF), # mahjong/domino/playing cards
1812
+ (0x2600, 0x27BF), # misc symbols + dingbats
1813
+ (0x2B00, 0x2BFF), # misc symbols and arrows
1814
+ (0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
1815
+ (0x1F1E6, 0x1F1FF), # regional indicators (flags)
1816
+ (0x20E3, 0x20E3), # combining enclosing keycap
1817
+ (0x1F3FB, 0x1F3FF), # skin-tone modifiers
1818
+ )
1819
+ # U+200D ZWJ is deliberately NOT in EMOJI_RANGES: it is ordinary Indic/Persian orthography
1820
+ # (क्‍ष is ka + virama + ZWJ + ssa) and only becomes emoji glue *between two emoji bases*.
1821
+ # Characters that never START a cluster: they bind to whatever stands before them.
1822
+ _EMOJI_TAIL = frozenset({0x200D, 0xFE0F, 0x20E3} | set(range(0x1F3FB, 0x1F400)))
1823
+ _EMOJI_REGIONAL = range(0x1F1E6, 0x1F200)
1824
+ _ZWJ = 0x200D
1825
+ _VS15 = 0xFE0E # text-presentation selector: "draw this as a character, not as an emoji"
1826
+ _VS16 = 0xFE0F
1827
+ _KEYCAP = 0x20E3
1828
+ _KEYCAP_BASES = frozenset("0123456789#*")
1829
+
1830
+
1831
+ def _is_emoji_char(ch: str) -> bool:
1832
+ cp = ord(ch)
1833
+ return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
1834
+
1835
+
1836
+ def _is_emoji_base(ch: str) -> bool:
1837
+ """Can this character START an emoji cluster? Pictographs and regional indicators can;
1838
+ the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
1839
+ emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
1840
+ ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap."""
1841
+ return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
1842
+
1843
+
1844
+ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
1845
+ """(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
1846
+ and skin-tone modifiers kept together -- 👩‍💻 is one cluster, not three, and 1️⃣ starts at the
1847
+ digit even though the digit is not itself an emoji character.
1848
+
1849
+ A cluster can only START at an emoji base (a pictograph, a regional indicator) or at a keycap
1850
+ base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
1851
+ a starter and never a tail on its own: `क्‍ष` (Hindi ka + virama + ZWJ + ssa) and `abc‍def`
1852
+ contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
1853
+ not an emoji -- the author asked for the character, not the picture.
1854
+ """
1855
+ out: "List[Tuple[int, str]]" = []
1856
+ i = 0
1857
+ n = len(text or "")
1858
+ while i < n:
1859
+ ch = text[i]
1860
+ start = i
1861
+ if _is_emoji_base(ch):
1862
+ j = i + 1
1863
+ if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
1864
+ i = j + 1
1865
+ continue
1866
+ elif ch in _KEYCAP_BASES:
1867
+ j = i + 1
1868
+ if j < n and ord(text[j]) == _VS16:
1869
+ j += 1
1870
+ if not (j < n and ord(text[j]) == _KEYCAP):
1871
+ i += 1
1872
+ continue
1873
+ j += 1
1874
+ else:
1875
+ i += 1
1876
+ continue
1877
+ # extend: modifiers bind rightwards, a ZWJ only when a real emoji base follows it
1878
+ while j < n:
1879
+ cp = ord(text[j])
1880
+ if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
1881
+ j += 1
1882
+ continue
1883
+ if cp == _ZWJ and j + 1 < n and _is_emoji_base(text[j + 1]):
1884
+ j += 2
1885
+ continue
1886
+ if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
1887
+ j += 1
1888
+ continue
1889
+ break
1890
+ out.append((start, text[start:j]))
1891
+ i = j
1892
+ return out
1893
+
1894
+
1895
+ def has_emoji(text: str) -> bool:
1896
+ return bool(emoji_clusters(text or ""))
1897
+
1898
+
1899
+ def emoji_codepoint_name(cluster: str) -> str:
1900
+ """The asset filename stem for a cluster: lowercase hex code points joined by '-', the
1901
+ Twemoji/Noto convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5)."""
1902
+ return "-".join(f"{ord(c):x}" for c in cluster)
1903
+
1904
+
1905
+ def _emoji_name_candidates(cluster: str) -> "List[str]":
1906
+ """Asset stems to try, most specific first: exact, without VS16, without skin tone, the ZWJ
1907
+ sequence reduced to its first code point, the bare base."""
1908
+ cps = [ord(c) for c in cluster]
1909
+ names = [emoji_codepoint_name(cluster)]
1910
+
1911
+ def add(seq):
1912
+ name = "-".join(f"{c:x}" for c in seq)
1913
+ if name and name not in names:
1914
+ names.append(name)
1915
+ add([c for c in cps if c != 0xFE0F])
1916
+ add([c for c in cps if c != 0xFE0F and not (0x1F3FB <= c <= 0x1F3FF)])
1917
+ if 0x200D in cps:
1918
+ add([cps[0]])
1919
+ add([cps[0]])
1920
+ return names
1921
+
1922
+
1923
+ def emoji_asset_for(cluster: str, assets_dir: "Optional[str]") -> "Optional[str]":
1924
+ """The PNG for `cluster` under `assets_dir`, or None when nothing matches."""
1925
+ if not assets_dir or not os.path.isdir(assets_dir):
1926
+ return None
1927
+ for name in _emoji_name_candidates(cluster):
1928
+ for ext in (".png", ".PNG"):
1929
+ candidate = os.path.join(assets_dir, name + ext)
1930
+ if os.path.isfile(candidate):
1931
+ return candidate
1932
+ return None
1933
+
1934
+
1935
+ EMOJI_ASSET_HINT = (
1936
+ "point --emoji-assets at a directory of PNGs named by code point (1f389.png): "
1937
+ "twemoji/assets/72x72 (Twemoji, CC-BY 4.0) or noto-emoji/png/128 (Noto Emoji, OFL/Apache-2.0) "
1938
+ "are the two people already have. The skill has no network at runtime, so the assets must "
1939
+ "already exist on this machine -- nothing is ever downloaded")
1940
+
1941
+ _EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
1942
+ _EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
1943
+
1944
+
1945
+ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
1946
+ """(family, file, fontconfig_answered) for the first installed colour emoji family."""
1947
+ exe = shutil.which("fc-list")
1948
+ if not exe:
1949
+ return None, None, False
1950
+ for family in _EMOJI_COLOR_FAMILIES:
1951
+ try:
1952
+ proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
1953
+ stderr=subprocess.DEVNULL, text=True, timeout=10)
1954
+ except (subprocess.TimeoutExpired, OSError):
1955
+ return None, None, False
1956
+ if proc.returncode != 0:
1957
+ return None, None, False
1958
+ for line in proc.stdout.splitlines():
1959
+ path = line.split(":", 1)[0].strip()
1960
+ if path and os.path.exists(path):
1961
+ return family, path, True
1962
+ return None, None, True
1963
+
1964
+
1965
+ def _libass_color_probe() -> "Optional[bool]":
1966
+ """Does THIS ffmpeg render an emoji in colour through libass? Answered by a render, never by
1967
+ the font listing: Noto Color Emoji installs happily on builds whose freetype/libass has no
1968
+ colour-bitmap path at all, and those render a monochrome outline instead (measured). ~80 ms.
1969
+ None means the probe could not be run (no ffmpeg, a failure) -- unknown, not false."""
1970
+ exe = shutil.which("ffmpeg")
1971
+ if not exe:
1972
+ return None
1973
+ import tempfile
1974
+ with tempfile.TemporaryDirectory() as td:
1975
+ srt = os.path.join(td, "e.srt")
1976
+ with open(srt, "w", encoding="utf-8") as fh:
1977
+ fh.write("1\n00:00:00,000 --> 00:00:01,000\n\U0001F389\n")
1978
+ try:
1979
+ proc = subprocess.run(
1980
+ [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
1981
+ "-i", "color=c=black:s=64x64:d=0.04",
1982
+ "-vf", "subtitles=" + srt.replace("\\", "/"), "-frames:v", "1",
1983
+ "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
1984
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
1985
+ except (subprocess.TimeoutExpired, OSError):
1986
+ return None
1987
+ if proc.returncode != 0 or len(proc.stdout) < 64 * 64 * 3:
1988
+ return None
1989
+ data = proc.stdout
1990
+ for i in range(0, 64 * 64 * 3, 3):
1991
+ r, g, b = data[i], data[i + 1], data[i + 2]
1992
+ if max(r, g, b) - min(r, g, b) > 40:
1993
+ return True
1994
+ return False
1995
+
1996
+
1997
+ def emoji_support(assets: "Optional[str]" = None, probe: bool = True) -> "Dict[str, Any]":
1998
+ """What this machine can actually do with emoji, cached per process.
1999
+
2000
+ `mode` is `color` when a render probe proves libass draws colour, else `png` when an assets
2001
+ directory resolves, else `mono` when some installed face has a glyph at all, else `none`.
2002
+ An installed colour emoji font proves nothing on its own -- that is why `libass_color` comes
2003
+ from a render (see references/gotchas.md#emoji). `probe=False` (`contract --json --static`, and every
2004
+ static/JSON-only path) skips the render entirely and leaves `libass_color` unknown.
2005
+ """
2006
+ key = (assets or None, bool(probe))
2007
+ if key in _EMOJI_SUPPORT_CACHE:
2008
+ return dict(_EMOJI_SUPPORT_CACHE[key])
2009
+ family, file, fc_answered = _emoji_color_font()
2010
+ libass_color = _libass_color_probe() if probe else None
2011
+ assets_dir = assets if (assets and os.path.isdir(assets)) else None
2012
+ if libass_color:
2013
+ mode = "color"
2014
+ elif assets_dir:
2015
+ mode = "png"
2016
+ elif family:
2017
+ mode = "mono"
2018
+ elif not fc_answered:
2019
+ # No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
2020
+ # so the honest answer is png-or-none, never "none because fc-list is missing".
2021
+ mode = "none"
2022
+ else:
2023
+ mode = "none"
2024
+ if not fc_answered:
2025
+ detail = "no fontconfig on this machine; the PNG overlay path needs none"
2026
+ elif libass_color:
2027
+ detail = f"{family or 'an installed face'} renders in colour through libass on this ffmpeg"
2028
+ elif family and libass_color is False:
2029
+ detail = f"{family} installed but libass renders it monochrome on this build"
2030
+ elif family and libass_color is None:
2031
+ detail = f"{family} installed; the colour render probe was not run"
2032
+ elif assets_dir:
2033
+ detail = "no colour emoji family installed; using the PNG assets directory"
2034
+ else:
2035
+ detail = "no colour emoji family installed and no --emoji-assets directory"
2036
+ result = {"mode": mode, "color_font": family, "color_font_file": file,
2037
+ "libass_color": libass_color, "assets": assets_dir,
2038
+ "detail": detail, "fix": EMOJI_ASSET_HINT}
2039
+ _EMOJI_SUPPORT_CACHE[key] = result
2040
+ return dict(result)
2041
+
2042
+
2043
+ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]" = None,
2044
+ brand: "Optional[dict]" = None) -> "Optional[str]":
2045
+ """--emoji-assets DIR, else the project key, else brand.json, else FFMPEG_SKILL_EMOJI_ASSETS.
2046
+ A directory that was named but does not exist is a failed job, never a silent downgrade."""
2047
+ brand = brand or {}
2048
+ styles = (brand.get("styles") or {}).get("caption") or {}
2049
+ for value, where in ((flag, "--emoji-assets"), (project, "the project's text.emoji_assets"),
2050
+ (styles.get("emoji_assets"), "brand.json styles.caption.emoji_assets"),
2051
+ (brand.get("emoji_assets"), "brand.json emoji_assets"),
2052
+ (os.environ.get("FFMPEG_SKILL_EMOJI_ASSETS"), "FFMPEG_SKILL_EMOJI_ASSETS")):
2053
+ if not value:
2054
+ continue
2055
+ if not os.path.isdir(str(value)):
2056
+ die(f"{where}: {value} is not a readable directory -- {EMOJI_ASSET_HINT}", kind="input")
2057
+ return str(value)
2058
+ return None
2059
+
2060
+
2061
+ # --------------------------------------------------------------------------- text measurement (1.12)
2062
+ # Moved here in 1.15 so graphics.py's ASS route and the emoji placement share caption.py's table.
2063
+ # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
2064
+ # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
2065
+ # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
2066
+ # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
2067
+ # shaping, while a cue wrapped from an average is right to within a character on every line.
2068
+ # (Latin is measured per character from LATIN_EM below, not from this average.)
2069
+ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
2070
+ "ru": 0.55, "el": 0.55, "latin": 0.55}
2071
+ # Scripts written without spaces: a line breaks between any two characters.
2072
+ NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
2073
+ # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
2074
+ # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
2075
+ # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
2076
+ # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
2077
+ # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
2078
+ # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
2079
+ # 0.57 lowercase and anything else Latin-ish).
2080
+ LATIN_EM = {
2081
+ ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
2082
+ '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
2083
+ '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
2084
+ '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
2085
+ 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
2086
+ 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
2087
+ 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
2088
+ 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
2089
+ 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
2090
+ 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
2091
+ '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,
2092
+ '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
2093
+ }
2094
+ # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
2095
+ # between them and the base that follows.
2096
+ LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
2097
+
2098
+
2099
+ def _is_mark(ch: str) -> bool:
2100
+ """A character that hangs off the one before it: a combining mark (any script) or one of the
2101
+ Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
2102
+ return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
2103
+
2104
+
2105
+ def _char_em(ch: str) -> float:
2106
+ # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
2107
+ # full-width grid as the ideographs they sit between, even though they are not "Han" to a
2108
+ # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
2109
+ cp = ord(ch)
2110
+ # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
2111
+ # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
2112
+ if unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Cf"):
2113
+ # "Cf" catches ZWJ/ZWNJ: an Indic joiner is orthography, and it advances the pen by
2114
+ # nothing -- charging it a full em (it used to count as "emoji") shrank a Hindi line.
2115
+ return 0.0
2116
+ if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
2117
+ return 1.0
2118
+ script = char_script(ch)
2119
+ if script == "emoji":
2120
+ # 1.15: an emoji is drawn (or reserved) at a full em box, not at Latin's 0.57 -- counting
2121
+ # it as Latin overflowed the safe area on an emoji-heavy line.
2122
+ return 1.0
2123
+ if script == "latin":
2124
+ if ch in LATIN_EM:
2125
+ return LATIN_EM[ch]
2126
+ if ch.isupper() or ch.isdigit():
2127
+ return 0.7
2128
+ return 0.57
2129
+ return ADVANCE_EM.get(script, 0.55)
2130
+
2131
+
2132
+ def text_width_em(text: str, emoji_em: float = 1.0) -> float:
2133
+ """Width of `text` in em, from the per-script average advance table. `emoji_em` is what one
2134
+ emoji cluster costs (--emoji-scale), so a wrap counts the box that will actually be drawn."""
2135
+ total = 0.0
2136
+ spans = {i: len(c) for i, c in emoji_clusters(text)}
2137
+ i = 0
2138
+ while i < len(text):
2139
+ if i in spans:
2140
+ total += emoji_em
2141
+ i += spans[i]
2142
+ continue
2143
+ total += _char_em(text[i])
2144
+ i += 1
2145
+ return total
2146
+
2147
+
2148
+ def emoji_filter_chain(plan, base_label, out_label, first_input=1):
2149
+ """(chains, inputs) that composite the planned PNGs on top of `base_label`.
2150
+
2151
+ `inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
2152
+ ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
2153
+ a timeline the fade filter can move along; one that does not is a plain `-i`).
2154
+ """
2155
+ overlays = plan.get("overlays") or []
2156
+ if not overlays:
2157
+ return [], []
2158
+ # Group by everything that makes two uses of the same PNG a different STREAM: the fade is
2159
+ # expressed in the cue's own timeline, so two cues cannot share one faded input.
2160
+ def _key(o):
2161
+ fades = (round(float(o.get("fade_in") or 0.0), 3), round(float(o.get("fade_out") or 0.0), 3))
2162
+ window = (round(float(o["start"]), 3), round(float(o["end"]), 3)) if any(fades) else (None, None)
2163
+ return (o["asset"], o["box"]) + fades + window
2164
+
2165
+ groups: "List[Tuple]" = []
2166
+ for o in overlays:
2167
+ if _key(o) not in groups:
2168
+ groups.append(_key(o))
2169
+ chains: List[str] = []
2170
+ inputs: "List[List[str]]" = []
2171
+ pads: "Dict[Tuple, List[str]]" = {}
2172
+ for k, key in enumerate(groups):
2173
+ asset, box, fin, fout, gstart, gend = key
2174
+ uses = [o for o in overlays if _key(o) == key]
2175
+ idx = first_input + k
2176
+ labels = [f"e{k}_{j}" for j in range(len(uses))]
2177
+ chain = f"[{idx}:v]format=rgba,scale={box}:{box}"
2178
+ if fin or fout:
2179
+ # -loop 1 gives the still an advancing timeline on the SAME clock as the main video,
2180
+ # so the fade times below are the cue's own seconds. The emoji then appears and
2181
+ # leaves with the text instead of popping in against a fading line.
2182
+ # -t bounds the loop at the cue's end: an unbounded looped still never EOFs and the
2183
+ # whole encode hangs (overlay keeps pulling from it after the main video is done).
2184
+ inputs.append(["-loop", "1", "-t", f"{gend:.3f}", "-i", asset])
2185
+ if fin:
2186
+ chain += f",fade=t=in:st={gstart:.3f}:d={fin:.3f}:alpha=1"
2187
+ if fout:
2188
+ chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
2189
+ else:
2190
+ inputs.append(["-i", asset])
2191
+ if len(labels) > 1:
2192
+ chain += f",split={len(labels)}"
2193
+ chains.append(chain + "".join(f"[{l}]" for l in labels))
2194
+ pads[key] = labels
2195
+ cur = base_label
2196
+ remaining = {key: list(v) for key, v in pads.items()}
2197
+ for j, o in enumerate(overlays):
2198
+ label = remaining[_key(o)].pop(0)
2199
+ nxt = out_label if j == len(overlays) - 1 else f"eov{j}"
2200
+ x = o["x"]
2201
+ x = f"'{x}'" if isinstance(x, str) else x
2202
+ # No eof_action=pass here: a PNG input is a SINGLE frame at pts 0, and eof_action=pass
2203
+ # switches off overlay's default "hold the last frame of the secondary input", so the
2204
+ # asset would be composited on frame 0 only and vanish for the rest of the cue (that is
2205
+ # exactly what shipped first). eof_action=repeat (the default) holds the still for the
2206
+ # whole timeline; enable= is what confines it to the cue's window.
2207
+ chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
2208
+ f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
2209
+ cur = nxt
2210
+ return chains, inputs
2211
+
2212
+
2213
+ # --------------------------------------------------------------------------- shaping (1.15)
2214
+ # Scripts whose correct rendering needs harfbuzz-class reordering and re-clustering (Indic matras,
2215
+ # Thai/Lao mark stacking). drawtext does NOT use harfbuzz even in an --enable-libharfbuzz build, so
2216
+ # these come out wrong through drawtext on every build and must go through libass. Arabic and
2217
+ # Hebrew are NOT here: drawtext's text_shaping uses fribidi, which does bidi and Arabic joining
2218
+ # correctly -- they only join this set on a build compiled without fribidi.
2219
+ SHAPING_SCRIPTS = frozenset({"hi", "bn", "ta", "te", "kn", "ml", "gu", "pa", "si", "th", "lo", "km", "my"})
2220
+ BIDI_SCRIPTS = frozenset({"ar", "he"})
2221
+ _SHAPING_BUILD_CACHE: "Dict[str, bool]" = {}
2222
+
2223
+
2224
+ def drawtext_shaping() -> "Dict[str, bool]":
2225
+ """Which shaping libraries THIS ffmpeg was built with, from -buildconf (falling back to the
2226
+ `configuration:` line of -version). Cached per process."""
2227
+ if _SHAPING_BUILD_CACHE:
2228
+ return dict(_SHAPING_BUILD_CACHE)
2229
+ text = ""
2230
+ exe = shutil.which("ffmpeg")
2231
+ if exe:
2232
+ for flag in ("-buildconf", "-version"):
2233
+ try:
2234
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
2235
+ stderr=subprocess.STDOUT, text=True, timeout=10)
2236
+ except (subprocess.TimeoutExpired, OSError):
2237
+ break
2238
+ if proc.returncode == 0 and proc.stdout.strip():
2239
+ text = proc.stdout
2240
+ break
2241
+ _SHAPING_BUILD_CACHE.update({"fribidi": "--enable-libfribidi" in text,
2242
+ "harfbuzz": "--enable-libharfbuzz" in text})
2243
+ return dict(_SHAPING_BUILD_CACHE)
2244
+
2245
+
2246
+ def needs_shaping(script: str) -> bool:
2247
+ """Whether drawtext would render `script` wrongly on this build."""
2248
+ if script in SHAPING_SCRIPTS:
2249
+ return True
2250
+ return script in BIDI_SCRIPTS and not drawtext_shaping()["fribidi"]
2251
+
2252
+
2253
+ def font_family_of_file(path: str) -> "Optional[str]":
2254
+ """The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
2255
+ the file directly; without fontconfig the file stem is the honest best guess."""
2256
+ if not path or not os.path.isfile(path):
2257
+ return None
2258
+ exe = shutil.which("fc-scan")
2259
+ if exe:
2260
+ try:
2261
+ proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
2262
+ stderr=subprocess.DEVNULL, text=True, timeout=10)
2263
+ if proc.returncode == 0 and proc.stdout.strip():
2264
+ return proc.stdout.strip().splitlines()[0].strip()
2265
+ except (subprocess.TimeoutExpired, OSError):
2266
+ pass
2267
+ return Path(path).stem
2268
+
2269
+
1789
2270
  def char_script(ch: str) -> str:
1790
2271
  """The script of one character: one of SCRIPTS, or "latin" for anything else (including
1791
2272
  digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
1792
2273
  cp = ord(ch)
2274
+ # 1.15: an emoji cluster is not Latin. detect_script() skips "emoji" the way it skips "latin",
2275
+ # so font resolution still follows the letters around it.
2276
+ if _is_emoji_char(ch):
2277
+ return "emoji"
1793
2278
  for name, ranges in _SCRIPT_RANGES:
1794
2279
  for lo, hi in ranges:
1795
2280
  if lo <= cp <= hi:
@@ -1812,7 +2297,7 @@ def detect_script(text: str, lang: "Optional[str]" = None) -> str:
1812
2297
  kana = 0
1813
2298
  for ch in text or "":
1814
2299
  s = char_script(ch)
1815
- if s == "latin":
2300
+ if s in ("latin", "emoji"):
1816
2301
  continue
1817
2302
  if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
1818
2303
  kana += 1
@@ -2056,36 +2541,23 @@ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Opti
2056
2541
 
2057
2542
 
2058
2543
  def escape_drawtext(text: str) -> str:
2059
- """Escape `text` for use as a single-quoted drawtext option value (`text='<this>'`).
2060
-
2061
- Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash
2062
- escape regardless of the surrounding quotes -- the graph parser still splits on an
2063
- unescaped `,`/`;` or ends an option list on an unescaped `:`/`[`/`]` even while
2064
- "inside" a quoted value. The quote character itself has no reliable backslash
2065
- escape at all: `\\'` and the POSIX shell `'\\''` close-insert-reopen trick both
2066
- parse fine in a simple `-vf` chain, but silently corrupt a `-filter_complex` chain
2067
- that uses explicit `[label]` pads -- confirmed by rendering the result: the text
2068
- value doesn't end where the quote closes it, and trailing option names/values
2069
- (fontfile=..., fontsize=...) leak into the rendered picture as literal text
2070
- instead of being parsed as options. A quote is therefore dropped outright rather
2071
- than escaped -- losing one apostrophe from a label is a fine trade for "the
2072
- filter graph parses the way the code intends, on every call shape this codebase
2073
- uses it in".
2074
-
2075
- `%` has the same problem the quote character did: `\%` is not a real escape as
2076
- far as drawtext's own text-expansion scanner (on by default, `expansion=normal`,
2077
- for `%{pts}`/`%{localtime}`/etc.) is concerned -- a bare backslash-escaped `%`
2078
- always logs "Stray % near ..." (confirmed with the minimal case
2079
- `text='100\%done'`), which is merely noisy on one ffmpeg
2080
- build (the warning is printed, the file still gets written) but a hard filtering
2081
- failure that writes no output at all on another. Every caller of this function
2082
- only ever wants a literal label, never `%{...}` expansion, so `%` is dropped
2083
- outright rather than chasing a per-build-safe escape (`expansion=none` on the
2084
- filter would also fix it, but needs touching every drawtext= call site instead
2085
- of the one shared helper). Control characters (newline, tab, ...) are dropped
2086
- for the same reason: none are meaningful in a one-line burnt-in label, and
2087
- unlike the graph-special characters above, ffmpeg's own text-expansion scanner
2088
- -- not just the graph parser -- is involved in whether they're actually safe."""
2544
+ """Escape a FONT NAME for a single-quoted drawtext option value (`font='<this>'`).
2545
+
2546
+ Since 1.15 this is no longer the route for drawn TEXT -- use drawtext_text_opts(), which puts
2547
+ the text in a file and keeps `\'` and `%` verbatim. It remains the escape for the font-name
2548
+ fallback, where the value is a family name that never legitimately contains a quote or a
2549
+ percent sign.
2550
+
2551
+ Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash escape
2552
+ regardless of the surrounding quotes -- the graph parser still splits on an unescaped `,`/`;`
2553
+ or ends an option list on an unescaped `:`/`[`/`]` even while "inside" a quoted value. The
2554
+ quote character itself has no reliable backslash escape at all: `\\'` and the POSIX shell
2555
+ close-insert-reopen trick both parse fine in a simple `-vf` chain but silently corrupt a
2556
+ `-filter_complex` chain that uses explicit `[label]` pads (confirmed by rendering the result:
2557
+ trailing option names leak into the picture as literal text). `%` has the same problem as far
2558
+ as drawtext's own expansion scanner is concerned. Both are therefore dropped here rather than
2559
+ escaped -- which is exactly why drawn text no longer comes through this function.
2560
+ """
2089
2561
  text = re.sub(r"[\x00-\x1f\x7f]", "", text)
2090
2562
  return (
2091
2563
  text.replace("'", "")
@@ -2099,6 +2571,90 @@ def escape_drawtext(text: str) -> str:
2099
2571
  )
2100
2572
 
2101
2573
 
2574
+ _DRAWTEXT_TMPDIR: "Optional[str]" = None
2575
+ _DRAWTEXT_PENDING: "Dict[str, str]" = {}
2576
+
2577
+
2578
+ def _drawtext_tmpdir(create: bool = True) -> str:
2579
+ """The private, per-run directory drawn-text files live in.
2580
+
2581
+ tempfile.mkdtemp() creates it 0700 under a name nobody can guess, which is the whole point:
2582
+ the 1.15.0 shape (a fixed, world-writable `/tmp/ffmpeg-skill-text` entered with
2583
+ makedirs(exist_ok=True) and content-addressed filenames) let any other user on the machine
2584
+ pre-create the directory or plant a symlink at a predictable name, and handed the second
2585
+ user of a shared box a PermissionError out of filter construction instead of a `kind: input`
2586
+ refusal. The directory is removed when the process ends, whether it succeeded or failed.
2587
+ """
2588
+ global _DRAWTEXT_TMPDIR
2589
+ import tempfile
2590
+ if _DRAWTEXT_TMPDIR and os.path.isdir(_DRAWTEXT_TMPDIR):
2591
+ return _DRAWTEXT_TMPDIR
2592
+ if not create:
2593
+ # --dry-run names the path it WOULD use and creates nothing (a dry run writes nothing).
2594
+ return os.path.join(tempfile.gettempdir(), "ffmpeg-skill-text-%d" % os.getpid())
2595
+ import atexit
2596
+ _DRAWTEXT_TMPDIR = tempfile.mkdtemp(prefix="ffmpeg-skill-text-")
2597
+ atexit.register(shutil.rmtree, _DRAWTEXT_TMPDIR, True)
2598
+ return _DRAWTEXT_TMPDIR
2599
+
2600
+
2601
+ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
2602
+ """Write the drawn-text files this command actually names, and return their paths.
2603
+
2604
+ The text is registered when the filter STRING is built, but a filter string is not a run:
2605
+ graphics.py builds the drawtext graph even on a job that is finally rendered through libass,
2606
+ and every tool builds one under --dry-run. Writing here -- from run(), past the dry-run
2607
+ return, against the command that is about to be executed -- is what keeps both of those from
2608
+ leaving a file behind.
2609
+ """
2610
+ if not _DRAWTEXT_PENDING:
2611
+ return []
2612
+ joined = " ".join(str(a) for a in cmd)
2613
+ written = []
2614
+ for path, body in list(_DRAWTEXT_PENDING.items()):
2615
+ # match on the unique file name, not the full path: inside a filter string the path is
2616
+ # escaped (a Windows drive colon becomes `C\\:`, and the separators are forward slashes),
2617
+ # so the registered spelling never appears verbatim in the command
2618
+ if os.path.basename(path) not in joined or os.path.exists(path):
2619
+ continue
2620
+ # O_NOFOLLOW exists on POSIX only; the directory is private (mkdtemp, 0700) so the
2621
+ # symlink guard is belt and braces there and unavailable on Windows
2622
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
2623
+ fd = os.open(path, flags, 0o600)
2624
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
2625
+ fh.write(body)
2626
+ written.append(path)
2627
+ return written
2628
+
2629
+
2630
+ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
2631
+ """`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
2632
+ every character on every build shape this repo uses.
2633
+
2634
+ The filter-graph parser never sees the text at all: only the PATH is parsed, and
2635
+ escape_filter_path() already handles that. `expansion=none` switches off drawtext's own
2636
+ `%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
2637
+ and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
2638
+ `]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
2639
+ done"` losing both characters. Control characters are still stripped: a one-line burnt-in
2640
+ label has no use for them.
2641
+
2642
+ The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
2643
+ removed when the process ends. It is *registered* here and written by run() only if the
2644
+ command about to run actually names it, so --dry-run and the ASS route write nothing; a
2645
+ printed plan therefore names a path that no longer exists once the run is over, which is the
2646
+ same promise every other temp file in this skill makes.
2647
+ """
2648
+ cleaned = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
2649
+ import hashlib
2650
+ name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
2651
+ if tmpdir is None:
2652
+ tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
2653
+ path = os.path.join(tmpdir, name)
2654
+ _DRAWTEXT_PENDING[path] = cleaned
2655
+ return f"textfile={escape_filter_path(path)}:expansion=none"
2656
+
2657
+
2102
2658
  def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
2103
2659
  """Force a constant frame rate on output when the source looks VFR (or fps is given).
2104
2660