ffmpeg-skill 1.11.0 → 1.12.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.
@@ -1604,6 +1604,20 @@ def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
1604
1604
  return 0.0 # unreachable
1605
1605
 
1606
1606
 
1607
+ def signed_time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
1608
+ """time_arg() for a flag that may also be negative (an offset, not a point in time): a single
1609
+ leading '-'/'+' is taken as the sign and the rest goes through the ordinary time grammar, so
1610
+ `--offset -00:00:02`, `--offset -1.5` and `--offset 0:02` all mean what they read as."""
1611
+ text = (value or "").strip()
1612
+ sign = 1.0
1613
+ if text[:1] in "+-":
1614
+ sign = -1.0 if text[0] == "-" else 1.0
1615
+ text = text[1:].strip()
1616
+ if not text:
1617
+ die(f"{flag} {value!r}: not a time (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff)")
1618
+ return sign * time_arg(text, flag, fps)
1619
+
1620
+
1607
1621
  def fmt_srt_time(seconds: float) -> str:
1608
1622
  if seconds < 0:
1609
1623
  seconds = 0.0
@@ -1710,6 +1724,334 @@ def default_font_file(font_name: str) -> Optional[str]:
1710
1724
  return path if path and os.path.exists(path) else None
1711
1725
 
1712
1726
 
1727
+ # --------------------------------------------------------------------------- script detection
1728
+ # 1.12: non-Latin caption/overlay text used to render as tofu (empty boxes) whenever the default
1729
+ # family carried no glyphs for it -- silently, because fontconfig substitutes SOMETHING for every
1730
+ # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
1731
+ # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
1732
+ # warning (a video full of boxes is not a delivery).
1733
+ SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "th", "ru", "el", "latin")
1734
+
1735
+ LANGUAGE_NAMES = {
1736
+ "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
1737
+ "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
1738
+ "el": "Greek", "latin": "Latin",
1739
+ }
1740
+
1741
+ # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
1742
+ # every CJK font that claims zh carries; the rest are the plain two-letter codes).
1743
+ FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el"}
1744
+
1745
+ # Families tried in order, best first. The names are matched case-insensitively against the start
1746
+ # of any family fontconfig reports for a file, so "Noto Sans CJK JP" also matches
1747
+ # "Noto Sans CJK JP Black". Anything not listed still qualifies -- it just sorts after these.
1748
+ PREFERRED_FAMILIES = {
1749
+ "ja": ["Noto Sans CJK JP", "Noto Serif CJK JP", "Noto Sans JP", "Source Han Sans", "IPAPGothic", "IPAGothic", "IPA", "VL Gothic", "TakaoGothic", "WenQuanYi Zen Hei"],
1750
+ "zh": ["Noto Sans CJK SC", "Noto Serif CJK SC", "Noto Sans SC", "Source Han Sans", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "Droid Sans Fallback"],
1751
+ "ko": ["Noto Sans CJK KR", "Noto Serif CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "Nanum Gothic", "Malgun Gothic", "WenQuanYi Zen Hei"],
1752
+ "ar": ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri", "Scheherazade", "DejaVu Sans", "FreeSans", "FreeSerif"],
1753
+ "he": ["Noto Sans Hebrew", "Noto Serif Hebrew", "DejaVu Sans", "FreeSans", "FreeSerif"],
1754
+ "hi": ["Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari", "Mangal", "Nirmala UI", "Samyak Devanagari", "FreeSans", "FreeSerif"],
1755
+ "th": ["Noto Sans Thai", "Noto Serif Thai", "Loma", "Garuda", "Waree", "Umpush", "Norasi", "Sarabun", "Leelawadee UI", "FreeSerif"],
1756
+ "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1757
+ "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1758
+ }
1759
+
1760
+ # Windows has no fontconfig: the system fonts are looked up by file name instead, best first.
1761
+ WINDOWS_FONTS = {
1762
+ "ko": [("malgun.ttf", "Malgun Gothic"), ("gulim.ttc", "Gulim"), ("batang.ttc", "Batang")],
1763
+ "zh": [("msyh.ttc", "Microsoft YaHei"), ("simhei.ttf", "SimHei"), ("simsun.ttc", "SimSun")],
1764
+ "ja": [("meiryo.ttc", "Meiryo"), ("YuGothM.ttc", "Yu Gothic Medium"), ("YuGothR.ttc", "Yu Gothic"), ("msgothic.ttc", "MS Gothic")],
1765
+ "ar": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
1766
+ "he": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
1767
+ "hi": [("mangal.ttf", "Mangal"), ("Nirmala.ttf", "Nirmala UI"), ("NirmalaB.ttf", "Nirmala UI")],
1768
+ "th": [("leelawui.ttf", "Leelawadee UI"), ("leelawad.ttf", "Leelawadee"), ("tahoma.ttf", "Tahoma")],
1769
+ "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1770
+ "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1771
+ }
1772
+
1773
+ _SCRIPT_RANGES = (
1774
+ ("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
1775
+ ("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
1776
+ ("han", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x2A6DF))),
1777
+ ("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF))),
1778
+ ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
1779
+ ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
1780
+ ("th", ((0x0E00, 0x0E7F),)),
1781
+ ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
1782
+ ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
1783
+ )
1784
+
1785
+
1786
+ def char_script(ch: str) -> str:
1787
+ """The script of one character: one of SCRIPTS, or "latin" for anything else (including
1788
+ digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
1789
+ cp = ord(ch)
1790
+ for name, ranges in _SCRIPT_RANGES:
1791
+ for lo, hi in ranges:
1792
+ if lo <= cp <= hi:
1793
+ return "ja" if name == "kana" else ("zh" if name == "han" else name)
1794
+ return "latin"
1795
+
1796
+
1797
+ def detect_script(text: str, lang: "Optional[str]" = None) -> str:
1798
+ """Which script `text` is written in, as one of SCRIPTS.
1799
+
1800
+ Hangul wins for Korean, any kana makes the whole string Japanese (Japanese mixes kana and
1801
+ Han), Han alone is Chinese. Mixed text is decided by character count: the non-Latin script
1802
+ with the most characters wins, ties going to whichever appeared first, and text with no
1803
+ non-Latin characters at all is "latin". `lang` (a --lang/--language hint, or brand.json's
1804
+ `lang`) only resolves the one ambiguity the characters genuinely cannot: Han with no kana
1805
+ is Chinese by default but Japanese (or Korean hanja) when the caller says so.
1806
+ """
1807
+ counts: "Dict[str, int]" = {}
1808
+ order: "List[str]" = []
1809
+ kana = 0
1810
+ for ch in text or "":
1811
+ s = char_script(ch)
1812
+ if s == "latin":
1813
+ continue
1814
+ if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
1815
+ kana += 1
1816
+ if s not in counts:
1817
+ order.append(s)
1818
+ counts[s] = counts.get(s, 0) + 1
1819
+ if kana: # Japanese: the Han characters in the same string are Japanese too
1820
+ counts["ja"] = counts.pop("ja", 0) + counts.pop("zh", 0)
1821
+ order = [s for s in order if s != "zh"]
1822
+ if not counts:
1823
+ return "latin"
1824
+ best = max(counts, key=lambda s: (counts[s], -order.index(s)))
1825
+ hint = (lang or "").strip().lower().replace("_", "-").split("-")[0]
1826
+ if not kana and best == "zh" and hint in ("ja", "zh", "ko"):
1827
+ return hint # Han-only text: only the caller knows whether it is Chinese, Japanese or hanja
1828
+ return best
1829
+
1830
+
1831
+ _SCRIPT_FONT_CACHE: "Dict[Tuple[str, Optional[str]], Optional[Tuple[str, str]]]" = {}
1832
+
1833
+
1834
+ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
1835
+ """(file, families) for every font fontconfig says covers `fc_lang`.
1836
+
1837
+ `[]` means fontconfig answered and nothing covers the language; `None` means it could not be
1838
+ asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
1839
+ "missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
1840
+ """
1841
+ exe = shutil.which("fc-list")
1842
+ if not exe:
1843
+ return None
1844
+ try:
1845
+ proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
1846
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
1847
+ except (subprocess.TimeoutExpired, OSError):
1848
+ return None
1849
+ if proc.returncode != 0:
1850
+ return None
1851
+ out = []
1852
+ for line in proc.stdout.splitlines():
1853
+ if ": " not in line:
1854
+ continue
1855
+ path, _, families = line.partition(": ")
1856
+ path = path.strip()
1857
+ if not path or not os.path.exists(path):
1858
+ continue
1859
+ names = [f.replace("\\-", "-").strip() for f in families.split(",") if f.strip()]
1860
+ out.append((path, names or [Path(path).stem]))
1861
+ return out
1862
+
1863
+
1864
+ def _family_rank(families: "Sequence[str]", preferred: "Sequence[str]") -> int:
1865
+ for i, want in enumerate(preferred):
1866
+ w = want.lower()
1867
+ if any(f.lower().startswith(w) for f in families):
1868
+ return i
1869
+ return len(preferred)
1870
+
1871
+
1872
+ def _script_font_entry(script: str, family_hint: "Optional[str]" = None) -> "Optional[Tuple[str, str]]":
1873
+ key = (script, family_hint)
1874
+ if key in _SCRIPT_FONT_CACHE:
1875
+ return _SCRIPT_FONT_CACHE[key]
1876
+ _SCRIPT_FONT_CACHE[key] = result = _script_font_uncached(script, family_hint)
1877
+ return result
1878
+
1879
+
1880
+ FC_UNKNOWN = "unknown" # sentinel: fontconfig could not be asked (absent or failing), not "no font"
1881
+
1882
+
1883
+ def _script_font_uncached(script: str, family_hint: "Optional[str]" = None):
1884
+ """(file, family), None when nothing covers `script`, or FC_UNKNOWN when it cannot be asked."""
1885
+ if script not in FC_LANG:
1886
+ return None
1887
+ if platform.system() == "Windows":
1888
+ fonts = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
1889
+ for name, family in WINDOWS_FONTS.get(script, []):
1890
+ if (fonts / name).exists():
1891
+ return str(fonts / name), family
1892
+ return None
1893
+ preferred = list(PREFERRED_FAMILIES.get(script, []))
1894
+ if family_hint:
1895
+ preferred.insert(0, family_hint)
1896
+ candidates = _fc_list_fonts(FC_LANG[script])
1897
+ if candidates is None:
1898
+ return FC_UNKNOWN
1899
+ if not candidates:
1900
+ return None
1901
+ scored = []
1902
+ for path, families in candidates:
1903
+ joined = " ".join(families).lower()
1904
+ stem = Path(path).stem.lower()
1905
+ # "Unifont Sample" is fontconfig's tofu-with-hex-digits fallback: it "covers" every script
1906
+ # by drawing the code point, which is exactly the unreadable result this feature exists to
1907
+ # avoid -- it is only ever chosen when nothing else covers the script at all. A *Mono* face
1908
+ # is legible but wrong for a caption band, so it sorts after every proportional one.
1909
+ last_resort = 1 if "unifont" in joined else 0
1910
+ mono = 1 if "mono" in joined else 0
1911
+ # regular weights before Bold/Italic/Oblique cuts, so a default caption is not bold by accident
1912
+ styled = 1 if any(k in stem for k in ("bold", "italic", "oblique", "light", "thin", "black")) else 0
1913
+ scored.append((last_resort, _family_rank(families, preferred), mono, styled, path, families[0]))
1914
+ scored.sort(key=lambda row: (row[0], row[1], row[2], row[3], row[4]))
1915
+ best = scored[0]
1916
+ return best[4], best[5]
1917
+
1918
+
1919
+ def font_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
1920
+ """A font FILE path that covers `script`, or None when this machine has none.
1921
+
1922
+ Linux/macOS ask fontconfig (`fc-list :lang=xx file family`) and rank what it reports by the
1923
+ PREFERRED_FAMILIES table; Windows has no fontconfig, so the known system files are probed by
1924
+ name. Cached per process: a caption job resolves the same script for every cue.
1925
+ """
1926
+ entry = _script_font_entry(script, family_hint)
1927
+ return entry[0] if entry and entry is not FC_UNKNOWN else None
1928
+
1929
+
1930
+ def font_family_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
1931
+ """The family NAME of font_for_script()'s file -- what libass wants in an ASS Fontname."""
1932
+ entry = _script_font_entry(script, family_hint)
1933
+ return entry[1] if entry and entry is not FC_UNKNOWN else None
1934
+
1935
+
1936
+ def script_font_status(script: str) -> str:
1937
+ """"available" (a font file covers `script`), "missing" (fontconfig answered, none does) or
1938
+ "unknown" (there is no working fontconfig to ask). Only "missing" is a refusal."""
1939
+ entry = _script_font_entry(script)
1940
+ if entry is FC_UNKNOWN:
1941
+ return "unknown"
1942
+ return "available" if entry else "missing"
1943
+
1944
+
1945
+ def font_covers_script(font_name: str, script: str) -> bool:
1946
+ """Whether the installed family `font_name` actually carries glyphs for `script`.
1947
+
1948
+ `fc-match` cannot answer this: given a family that IS installed it returns that family
1949
+ whatever `:lang=` asks for (verified -- `fc-match "DejaVu Sans:lang=zh-cn"` answers
1950
+ "DejaVu Sans", which has no Han glyphs at all). `fc-list :lang=xx:family=<name>` does: it
1951
+ lists only files that satisfy BOTH, so an empty listing is the proof of no coverage. Unknown
1952
+ (no fontconfig at all) counts as covering: a warning nobody can verify is worse than none.
1953
+ """
1954
+ if script not in FC_LANG or not font_name:
1955
+ return True
1956
+ exe = shutil.which("fc-list")
1957
+ if not exe:
1958
+ return True
1959
+ try:
1960
+ proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
1961
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
1962
+ except (subprocess.TimeoutExpired, OSError):
1963
+ return True
1964
+ if proc.returncode != 0:
1965
+ return True
1966
+ return bool(proc.stdout.strip())
1967
+
1968
+
1969
+ # Named flags differ per tool: overlay.py and graphics.py take a font FILE, caption.py takes a
1970
+ # directory of faces plus the family name -- naming a flag the tool does not have is worse than
1971
+ # naming none, so the hint says both (review 10).
1972
+ FONT_FLAG_HINT = "pass a font file (--font-file on overlay.py/graphics.py, --fonts-dir with --font on caption.py)"
1973
+ FONT_INSTALL_HINT = ("install fonts-noto-cjk / fonts-noto-core (apt), "
1974
+ "brew install --cask font-noto-sans-cjk / font-noto-sans-arabic (mac), or "
1975
+ + FONT_FLAG_HINT)
1976
+
1977
+
1978
+ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
1979
+ """Does any font under `fonts_dir` cover `script`? None when it cannot be checked.
1980
+
1981
+ `--fonts-dir` says "also look here", not "this exact face", so it must not switch the
1982
+ coverage guarantee off. fontconfig's `fc-scan` reads the files directly (no cache, no
1983
+ installed-font database), which is exactly the question: `%{lang}` lists the languages each
1984
+ face claims.
1985
+ """
1986
+ if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
1987
+ return None
1988
+ exe = shutil.which("fc-scan")
1989
+ if not exe:
1990
+ return None
1991
+ try:
1992
+ proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
1993
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
1994
+ except (subprocess.TimeoutExpired, OSError):
1995
+ return None
1996
+ if proc.returncode != 0:
1997
+ return None
1998
+ want = FC_LANG[script].lower()
1999
+ for line in proc.stdout.splitlines():
2000
+ if want in [tag.strip().lower() for tag in line.split("|")]:
2001
+ return True
2002
+ return False
2003
+
2004
+
2005
+ def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Optional[str]" = None,
2006
+ font_explicit: bool = False, font_file: "Optional[str]" = None,
2007
+ fonts_dir: "Optional[str]" = None
2008
+ ) -> "Tuple[str, Optional[str], Optional[str]]":
2009
+ """(script, font file, family) to draw `text` with, resolving by script when nothing explicit
2010
+ was asked for.
2011
+
2012
+ Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
2013
+ --font-file, an explicit --font (which is kept even when fontconfig says it does not cover
2014
+ the script -- with one info line saying so, because overriding a user's stated font silently
2015
+ is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
2016
+ file is returned with ONE info line naming it.
2017
+
2018
+ A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
2019
+ with no working fontconfig at all answers "unknown", not "missing": the job continues with
2020
+ the caller's font -- libass and drawtext still have their own font backends -- and one info
2021
+ line says the coverage could not be verified.
2022
+ """
2023
+ script = detect_script(text or "", lang)
2024
+ if script == "latin":
2025
+ return script, None, None
2026
+ if font_file:
2027
+ return script, None, None
2028
+ if font_explicit and font:
2029
+ if not font_covers_script(font, script):
2030
+ info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
2031
+ f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
2032
+ return script, None, None
2033
+ if fonts_dir:
2034
+ covered = fonts_dir_covers_script(fonts_dir, script)
2035
+ if covered:
2036
+ return script, None, None
2037
+ if covered is None:
2038
+ info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
2039
+ "(no fc-scan on this machine); using it as given")
2040
+ return script, None, None
2041
+ info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
2042
+ "picking one by script instead (the directory is still searched first)")
2043
+ entry = _script_font_entry(script)
2044
+ if entry is FC_UNKNOWN:
2045
+ info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
2046
+ "(no working fontconfig); rendering with the font as given -- "
2047
+ "doctor --json .fonts.scripts reports what is known")
2048
+ return script, None, None
2049
+ if not entry:
2050
+ die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")
2051
+ info(f"font: {entry[0]} (covers {script})")
2052
+ return script, entry[0], entry[1]
2053
+
2054
+
1713
2055
  def escape_drawtext(text: str) -> str:
1714
2056
  """Escape `text` for use as a single-quoted drawtext option value (`text='<this>'`).
1715
2057
 
@@ -2044,6 +2386,11 @@ BRAND_DEFAULTS: Dict[str, Any] = {
2044
2386
  "logo_opacity": 0.9,
2045
2387
  "safe_margin": 48,
2046
2388
  "caption": {"size": 26, "position": "bottom", "animate": "pop", "karaoke": False, "bold": True, "outline": 2},
2389
+ # 1.12: one place for the caption look every project shares. `styles.caption` is the documented
2390
+ # spelling (`{font, size, colour, box, position}`, British or American "colour"); the older
2391
+ # top-level `caption` block still works and `styles.caption` wins where both name the same key.
2392
+ "styles": {},
2393
+ "lang": None,
2047
2394
  "loudness": {"lufs": -14, "tp": -1},
2048
2395
  }
2049
2396
 
@@ -2052,6 +2399,7 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
2052
2399
  """Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
2053
2400
  import copy
2054
2401
  brand = copy.deepcopy(BRAND_DEFAULTS)
2402
+ brand["_stated"] = {}
2055
2403
  if not path:
2056
2404
  return brand
2057
2405
  if not os.path.exists(path):
@@ -2070,9 +2418,39 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
2070
2418
  if brand.get(key) and not os.path.isabs(brand[key]):
2071
2419
  brand[key] = str(base / brand[key])
2072
2420
  brand["_path"] = str(path)
2421
+ # What the FILE said, separate from BRAND_DEFAULTS' filler: a brand.json that never mentions
2422
+ # a font must not read as "the caller chose a font" (which would switch font-by-script off).
2423
+ brand["_stated"] = data
2073
2424
  return brand
2074
2425
 
2075
2426
 
2427
+
2428
+ def brand_states_font(brand: Dict[str, Any]) -> bool:
2429
+ """Did the brand FILE actually name a font (top-level `font`, `caption.font` or
2430
+ `styles.caption.font`)? BRAND_DEFAULTS always supplies one, so the merged document can never
2431
+ answer this -- and treating the default filler as the caller's choice switched font-by-script
2432
+ off for every branded job (review 10)."""
2433
+ stated = brand.get("_stated") or {}
2434
+ if stated.get("font"):
2435
+ return True
2436
+ for block in (stated.get("caption"), (stated.get("styles") or {}).get("caption")):
2437
+ if isinstance(block, dict) and block.get("font"):
2438
+ return True
2439
+ return False
2440
+
2441
+
2442
+ def brand_caption_style(brand: Dict[str, Any]) -> Dict[str, Any]:
2443
+ """The effective caption style of a brand file: the top-level `caption` block updated with
2444
+ `styles.caption`, with `colour` normalised to `color`. Explicit flags still beat both."""
2445
+ style: Dict[str, Any] = dict(brand.get("caption") or {})
2446
+ extra = (brand.get("styles") or {}).get("caption") or {}
2447
+ style.update(extra)
2448
+ if "colour" in style and "color" not in style:
2449
+ style["color"] = style.pop("colour")
2450
+ style.pop("colour", None)
2451
+ return style
2452
+
2453
+
2076
2454
  def color_hex(value: str) -> str:
2077
2455
  """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
2078
2456
  v = str(value).strip().lstrip("#")
@@ -765,9 +765,53 @@ def doctor() -> Dict[str, Any]:
765
765
 
766
766
 
767
767
  def _fonts_capability() -> Dict[str, Any]:
768
+ """The default drawtext family (issue #66) plus, since 1.12, one entry per script the tools
769
+ can detect: which languages this machine can actually RENDER, not just which filters exist.
770
+
771
+ Per script: available (a font file covers it, path in `file`), missing (fontconfig knows none),
772
+ unknown (no fontconfig to ask -- the same "unknown is not missing" rule every other capability
773
+ here follows). Informational like the default font and gpu_encoders: a machine with no Thai
774
+ font is not a broken install, it is a machine that must not be asked to burn Thai captions.
775
+ """
776
+ from _common import SCRIPTS, font_for_script, script_font_status
777
+
768
778
  font = _default_font()
769
779
  result = _font_available(font)
770
- return {"default_font": font, "status": result["status"], "detail": result["detail"]}
780
+ scripts: Dict[str, Any] = {}
781
+ for script in SCRIPTS:
782
+ if script == "latin":
783
+ continue
784
+ # script_font_status() is the one place that tells "fontconfig answered, nothing covers
785
+ # this" (missing) apart from "there is no working fontconfig to ask" (unknown) -- the same
786
+ # distinction the tools refuse or continue on.
787
+ status = script_font_status(script)
788
+ scripts[script] = {"status": status, "file": font_for_script(script) if status == "available" else None}
789
+ return {"default_font": font, "status": result["status"], "detail": result["detail"], "scripts": scripts}
790
+
791
+
792
+ def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
793
+ """One line for the plain-text doctor: which scripts render here, which do not."""
794
+ scripts = fonts.get("scripts") or {}
795
+ by_state: Dict[str, List[str]] = {"available": [], "missing": [], "unknown": []}
796
+ for name, entry in scripts.items():
797
+ by_state.setdefault(entry["status"], []).append(name)
798
+ # the default font's `detail` (which family fontconfig substituted, or why it is unknown) is
799
+ # the actionable half of a non-available status, and the line has room for it
800
+ head = f"fonts: '{fonts['default_font']}' {fonts['status']}"
801
+ detail = fonts.get("detail") or ""
802
+ # The substituted family is the actionable half of a non-available status, so it goes on the
803
+ # plain line -- but only while it stays short enough to keep doctor's one-line-per-capability
804
+ # shape (the longest other line is ~85 chars). A long explanation is --json only.
805
+ if detail and fonts["status"] != "available" and len(detail) <= 60:
806
+ head += f" ({detail})"
807
+ parts = [head]
808
+ if by_state["available"]:
809
+ parts.append("renders " + " ".join(by_state["available"]))
810
+ if by_state["missing"]:
811
+ parts.append("no font for " + " ".join(by_state["missing"]))
812
+ if by_state["unknown"]:
813
+ parts.append("unknown (no fontconfig) " + " ".join(by_state["unknown"]))
814
+ return "; ".join(parts)
771
815
 
772
816
 
773
817
  def _capability_fix_hint(cap: str) -> str:
@@ -795,6 +839,11 @@ def _capability_fix_hint(cap: str) -> str:
795
839
  return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
796
840
  if cap.startswith("bsf:"):
797
841
  return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
842
+ if cap.startswith("font:"):
843
+ from _common import LANGUAGE_NAMES, FONT_INSTALL_HINT
844
+ script = cap[5:]
845
+ return (f"no installed font covers {LANGUAGE_NAMES.get(script, script)} text on this machine; "
846
+ f"{FONT_INSTALL_HINT} (doctor --json .fonts.scripts lists every script)")
798
847
  if cap == "external:whisper":
799
848
  return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
800
849
  return f"'{cap}' is not available; see docs/contract.md"
@@ -930,7 +979,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
930
979
  "semantics": "prints the ffmpeg command lines that would run; no output file is written",
931
980
  **({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
932
981
  "supports_json": "json" in schema["properties"],
933
- # additive mirror of supports_json (1.10.2): --json-brief is the same document trimmed
982
+ # additive mirror of supports_json (1.11.0): --json-brief is the same document trimmed
934
983
  "supports_json_brief": "json_brief" in schema["properties"],
935
984
  "mutates_input": False,
936
985
  "produces_artifact": meta["produces_artifact"],
@@ -1136,7 +1185,7 @@ def main() -> int:
1136
1185
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
1137
1186
  # counts, not the full capability list: the names of the ~60 available capabilities
1138
1187
  # answer no question a caller has (they are in `doctor --json .available` when one
1139
- # does), while what is MISSING is the whole reason to run doctor (1.10.2 token diet).
1188
+ # does), while what is MISSING is the whole reason to run doctor (1.11.0 token diet).
1140
1189
  head = (f"{'ok' if d['ok'] else 'NOT ok'}: {len(d['available'])} capabilities available, "
1141
1190
  f"{len(d['missing'])} required missing, {len(d['missing_optional'])} optional missing")
1142
1191
  if d["unknown"]:
@@ -1152,9 +1201,7 @@ def main() -> int:
1152
1201
  gpu = d["gpu_encoders"]
1153
1202
  if gpu["status"] == "parsed":
1154
1203
  print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
1155
- fonts = d["fonts"]
1156
- print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}"
1157
- + (f" ({fonts['detail']})" if fonts["status"] != "available" else ""))
1204
+ print(_fonts_summary_line(d["fonts"]))
1158
1205
  print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
1159
1206
  for err in d["errors"]:
1160
1207
  print(f"detection error: {err}", file=sys.stderr)