ffmpeg-skill 1.11.1 → 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.
- package/README.md +5 -4
- package/SKILL.md +13 -10
- package/docs/contract.md +28 -9
- package/package.json +1 -1
- package/references/gotchas.md +49 -7
- package/references/scripts.md +56 -4
- package/scripts/_common.py +378 -0
- package/scripts/_contract.py +51 -4
- package/scripts/caption.py +449 -16
- package/scripts/graphics.py +18 -3
- package/scripts/overlay.py +9 -1
- package/scripts/render.py +5 -4
package/scripts/_common.py
CHANGED
|
@@ -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("#")
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
@@ -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
|
-
|
|
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)
|