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.
- package/README.md +11 -9
- package/SKILL.md +27 -21
- package/docs/contract.md +43 -9
- package/package.json +1 -1
- package/references/gotchas.md +57 -7
- package/references/scripts.md +119 -10
- package/scripts/_common.py +378 -0
- package/scripts/_contract.py +60 -8
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +449 -16
- package/scripts/check.py +15 -0
- package/scripts/graphics.py +18 -3
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +9 -1
- package/scripts/render.py +69 -15
package/references/scripts.md
CHANGED
|
@@ -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, "
|
|
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.
|
|
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]
|
|
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`
|
|
640
|
-
|
|
641
|
-
|
|
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
|
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("#")
|