ffmpeg-skill 1.11.1 → 1.13.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.
@@ -404,8 +404,25 @@ so the rendered file meets the platform's loudness without a separate pass. Sinc
404
404
  1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
405
405
  and the project has no `loudness` stage; `"normalize": false` opts out.
406
406
 
407
+ `"audio": {"stems": {"dialogue": -2, "music": -18, "effects": -24}}` names one
408
+ level per element of the mix: `dialogue` is the main track's gain, `music` the
409
+ bed's level, `effects` the level of the third file `"audio": {"effects":
410
+ "sfx.wav"}` adds (never ducked). Each maps to the flag of the same meaning
411
+ (`--gain`, `--music-volume`, `--effects-volume`); an explicit flag next to a
412
+ stem wins, and a stems level with no file to apply it to (`effects` without
413
+ `"effects"`, `music` without `"music"`) is refused, `kind: input`.
414
+ `"audio": {"voice": "light"|"medium"|"strong"}` picks the voice strength
415
+ (`true` is `medium`).
416
+
417
+ `"chapters"` is a chapters file path, or an inline list of `{"at": TIME,
418
+ "title": STR}`; it runs `metadata.py` on the delivered file as the last stage
419
+ before `check`, so the markers are in the file that ships (streams copied). The
420
+ entries and the file path are validated before the first stage runs, and the
421
+ stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
422
+ other stage, so the plan lists `chapters` and the run does the same work.
423
+
407
424
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
408
- captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
425
+ captions → graphics → overlays → audio → loudness → export → chapters → check. Keys mirror the
409
426
  CLI flags of each script (see the docstring); a key `render.py` does not read -- at
410
427
  the top level or in any stage/clip object -- is refused (`kind: input`) naming the
411
428
  key and the nearest valid one, never silently ignored. Use it whenever an edit has
@@ -437,6 +454,11 @@ step before reporting a deliverable; fix FAILs, mention WARNs. Without
437
454
  `--platform` the youtube spec is assumed and the judgement rows (duration,
438
455
  aspect, fps, resolution, loudness, true peak) come back as WARN with a `notes`
439
456
  line, not FAIL: name the platform when the file is a delivery for it.
457
+ `--platform podcast` adds two informational rows: `channels` (PASS for mono or
458
+ stereo, WARN above — podcast players downmix 5.1 unpredictably) and `chapters`
459
+ (PASS when the container carries at least one marker, WARN `none` otherwise —
460
+ write them with `metadata.py --chapters`). Neither can FAIL a delivery, and
461
+ neither appears for another platform.
440
462
 
441
463
  ### batch.py — same recipe over a folder, cached
442
464
  ```
@@ -465,21 +487,35 @@ Inside this skill, call the scripts directly; the server is for other hosts.
465
487
  ### graphics.py — motion-graphics templates
466
488
  ```
467
489
  graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
468
- [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
490
+ [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [--lang XX] [-o OUT]
469
491
  ```
470
492
  Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
471
493
  the frame's short side; colours, font and safe margin come from `--brand`.
472
494
  Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
495
+ Non-Latin `--name`/`--title`/`--subtitle` text picks a font file by script the
496
+ same way `caption.py` does (`--lang XX` disambiguates Han-only text; no font for
497
+ the script fails the job). RTL shaping in drawtext depends on the ffmpeg build
498
+ (`--enable-libfribidi`/`--enable-libharfbuzz` shape it correctly, a build without
499
+ them does not); `caption.py` always shapes, because it renders through libass:
500
+ `references/gotchas.md#fonts-by-script`.
473
501
 
474
502
  ### brand.json — one file for fonts, colours, logo, margins
475
503
  ```json
476
504
  {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
477
505
  "colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
478
506
  "logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
479
- "safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
507
+ "safe_margin": 48, "lang": "ja",
508
+ "styles": {"caption": {"font": "Noto Sans CJK JP", "size": 28, "colour": "FFFFFF", "box": false, "position": "bottom"}},
509
+ "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
480
510
  ```
481
511
  `caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
482
- `"brand": "brand.json"` in a render project. Explicit flags still win. When a
512
+ `"brand": "brand.json"` in a render project. Explicit flags still win.
513
+ `styles.caption` (1.12) is the one caption look every project shares —
514
+ `{font, size, colour, box, position}`, British or American spelling of colour —
515
+ read by `caption.py` and, for `font` and `colour`, by `graphics.py`; it wins
516
+ over the older top-level `caption` block where both set the same key, and that
517
+ block still carries the burn-in-only defaults (`animate`, `karaoke`, `bold`,
518
+ `outline`). `"lang"` is the script hint `--lang` would give. When a
483
519
  user mentions brand guidelines, colours, "our font" or a logo, ask for or
484
520
  write a brand.json once and reuse it across every output.
485
521
 
@@ -526,7 +562,8 @@ frame like an editor would. Use `--compare` to show before/after to the user.
526
562
  ### caption.py — subtitles (static, animated, karaoke)
527
563
  ```
528
564
  caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
529
- [--mode burn|mux] [--audio-stream N] [--fps N]
565
+ [--mode burn|mux] [--audio-stream N] [--fps N] [--lang XX] [--offset TIME]
566
+ [--max-lines N] [--min-duration S]
530
567
  [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
531
568
  [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
532
569
  [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
@@ -543,6 +580,43 @@ word from `--color` to `--highlight-color` across the cue; `--karaoke-timing
543
580
  energy` (default) follows the speech loudness in the audio, `even` splits the
544
581
  cue equally (word timing is derived, not transcribed). The ASS is kept next to the
545
582
  user can hand-tune timings and re-run with `--ass`.
583
+ Readable by default (1.12): every cue is wrapped to the safe area (90 % of the
584
+ frame width) at the chosen `--size`, measured per script — CJK and Thai count a
585
+ full em per character, Latin per character from a table read off DejaVu Sans (so
586
+ an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
587
+ Arabic/Hebrew 0.6, Devanagari 0.7, and a combining mark nothing at all —
588
+ breaking between characters for CJK/Thai and at spaces otherwise, but never
589
+ between a character and the combining marks that belong to it (Thai tone marks
590
+ and vowel signs, Devanagari matras, Arabic and Hebrew points). A cue that would need more than `--max-lines` (default 2) is split
591
+ into consecutive cues sharing its time; a cue shorter than `--min-duration`
592
+ (default 1.0 s) is held longer, never past the next cue's start; `--offset
593
+ TIME` shifts every cue (seconds, `mm:ss`, `hh:mm:ss.ms` or `hh:mm:ss:ff`, a
594
+ leading `-` for earlier) for `--text`, `--srt` and `--ass`. One `cues:` info line reports what changed. A file you passed in is
595
+ never edited: the adjusted copy is written next to the output
596
+ (`<out>_adjusted.srt`, `<out>_offset.ass`) and burned instead — under `--dry-run`
597
+ /`--plan` the planned command names that same copy and the plan says where it
598
+ comes from, but nothing is written until the real run. `--min-duration` and
599
+ `--offset` also work with `--write-srt` alone; `--max-lines` does not, because
600
+ wrapping needs the input video's real frame size.
601
+
602
+ Fonts by script (1.12): with no `--font` and no font named in your brand file, the
603
+ script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
604
+ Thai, Cyrillic, Greek) picks a font file that covers it, logged as `font: <file>
605
+ (covers ko)`. Han-only text is read as Chinese unless `--lang ja|ko` (or
606
+ brand.json `"lang"`) says otherwise; `--language` is the same flag, and still
607
+ tags the subtitle stream under `--mode mux` and sets `--transcribe`'s language.
608
+ No font for the script fails the job (`kind: input`) instead of rendering boxes
609
+ — but only when fontconfig answered: with no working `fc-list` the coverage is
610
+ `unknown`, and the job runs with the font as given behind one info line. An
611
+ explicit font is always kept, with an info line when it does not cover the text;
612
+ `--fonts-dir` is searched first and checked with `fc-scan`, and a directory that
613
+ does not cover the script gets one line and a font resolved by script anyway. `doctor --json` `fonts.scripts` lists what this machine can render. See
614
+ `references/gotchas.md#fonts-by-script` (RTL: use captions, not drawtext).
615
+
616
+ `--karaoke` uses real per-word timings when the transcript has them (a whisper
617
+ `<stem>.json` or `<stem>.words.json` next to the SRT, `{"segments": [{"words":
618
+ [{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides.
619
+
546
620
  `--mode burn` (default) renders subtitles into the picture and always
547
621
  re-encodes both streams. `--mode mux` copies video and audio untouched and
548
622
  adds the SRT as a separate, player-toggleable subtitle stream instead —
@@ -632,13 +706,42 @@ first on HDR or Log sources.
632
706
 
633
707
  ### audio.py — clean-up, music, ducking, layout
634
708
  ```
635
- audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
636
- [--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
709
+ audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25]] [--gain dB]
710
+ [--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
711
+ [--duck-attack 20] [--duck-release 400]] [--music-loop]]
712
+ [--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
637
713
  [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
638
714
  ```
639
- `--voice` = highpass 80 Hz de-esser FFT denoise gentle compressor, the
640
- standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
641
- speech so music dips under dialogue and swells in pauses. `--downmix` uses the
715
+ `--voice` takes a strength; a bare `--voice` is `medium`, the chain it has always
716
+ produced. The exact filter chains:
717
+
718
+ | level | chain |
719
+ | --- | --- |
720
+ | `light` | `highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1` |
721
+ | `medium` | `highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2` |
722
+ | `strong` | the `medium` chain, then `deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled` |
723
+
724
+ `light` for a good room (rumble and level only, noise floor and sibilance left
725
+ alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
726
+ `strong` is the only level with a limiter, so it is the only one whose peaks
727
+ stop at −1 dBFS: `medium` (the default, and the chain a bare `--voice` gets) can
728
+ clip a hot source, since its make-up gain has nothing above it — normalise
729
+ afterwards with `loudness.py`, or use `strong`, which measures quieter and safer.
730
+ `--duck` uses a sidechain compressor keyed by the speech so music dips under
731
+ dialogue and swells in pauses:
732
+ `sidechaincompress=threshold=0.05:ratio=<amount/3, min 2>:attack=20:release=400:makeup=1`
733
+ by default. `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
734
+ `--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
735
+ threshold ducks on quieter speech, a shorter release brings the bed back faster.
736
+ `--json`'s `audio` block reports the settings the run actually used.
737
+ `--effects FILE` mixes a third track (sound effects, atmos) at
738
+ `--effects-volume` and is never ducked — effects are cut to the picture.
739
+ `--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
740
+ needs a real stereo source: it scales the side signal (L−R), so a mono track
741
+ duplicated to two channels has nothing to scale. A 1-channel input is refused
742
+ (`kind: input`) — `--stereo` duplicates it but does not widen it — and more than
743
+ two channels are refused unless `--downmix` is given too, in which case the
744
+ widening runs on the stereo fold-down. `--downmix` uses the
642
745
  ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
643
746
  leaves a 1-channel input untouched and downmixes >2 channels through
644
747
  swresample. Video is always stream-copied, and so is a subtitle/data track
@@ -649,6 +752,12 @@ Run `loudness.py` after this for final levels.
649
752
  ```
650
753
  loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
651
754
  ```
755
+ `--lra N` is the loudness-range target in LU (default 11): lower it to squeeze a
756
+ wide-dynamic mix into a phone speaker, raise it to leave a film mix alone.
757
+ `--json` reports the measured range on both sides — `measured.input_lra` for the
758
+ input, `result.input_lra` for the written file, with `targets` echoing the
759
+ requested lufs / tp / lra.
760
+
652
761
  Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
653
762
  the true-peak ceiling allows). Video and any subtitle/data track are
654
763
  stream-copied (`dropped_non_av_streams` reports a track the container refused); audio becomes AAC in
@@ -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("#")