ffmpeg-skill 3.8.9 → 3.8.10
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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +24 -8
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/emoji.py +5 -1
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/wrap.py +60 -18
- package/scripts/caption.py +221 -69
package/README.md
CHANGED
|
@@ -400,7 +400,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
400
400
|
- **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Built-in lists since 2.19: English, Japanese, Korean, Chinese, Spanish, German, French, Portuguese, Italian, Dutch, Polish, Russian and Turkish; any other language takes `--filler-words FILE`. Whisper stays optional: `--transcribe` with no engine installed refuses and names the five installs.
|
|
401
401
|
- **Throughput (1.17).** `batch.py --jobs N` runs several files at once, capped at `min(N, cpu_count, 8)` and sharing one `--timeout` budget rather than one per item; the per-item table keeps its order. `render.py --cache DIR` reuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them.
|
|
402
402
|
- **Audiogram (1.16).** `waveform.py --image cover.png` (or `render.py --template audiogram`) puts the waveform over a still plate for an episode that has no picture, with `--platform` for the frame, `--title` and burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented.
|
|
403
|
-
- **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji naming, `1f389.png`, or Noto's `emoji_u1f389.png`) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour — `doctor --json`'s `fonts.emoji` answers that from a render probe. Without assets the result's `emoji.mode` says which of three things happened: `color` when this build's libass draws the colour glyph (also for `--emoji mono` on such a build, with a warning), `mono` when it draws a monochrome one, and `none` when
|
|
403
|
+
- **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji naming, `1f389.png`, or Noto's `emoji_u1f389.png`) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour — `doctor --json`'s `fonts.emoji` answers that from a render probe. Without assets the result's `emoji.mode` says which of three things happened: `color` when this build's libass draws the colour glyph (also for `--emoji mono` on such a build, with a warning), `mono` when it draws a monochrome one, and `none` when the emoji are left out of the text: with an `emoji_not_drawn` warning when that happened because nothing here draws the glyph, and with only an info line when `--emoji none` asked for it (and a `graphics.py` title that is nothing but emoji is refused). Nothing is ever downloaded.
|
|
404
404
|
- **Indic and Thai text shaped correctly in titles and lower-thirds (1.15).** `graphics.py` renders Devanagari, Bengali, Tamil, Thai and Lao through libass automatically (`text_renderer: "ass"`), because drawtext never reorders matras or re-clusters marks; Arabic and Hebrew were already correct on a fribidi build. `--text-render drawtext` with such a script is refused, never rendered wrongly.
|
|
405
405
|
- **Non-Latin text picks a font by script (1.12).** Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (`kind: input`) instead of rendering boxes. `doctor --json`'s `fonts.scripts` says which languages this machine can render; `--lang ja|ko` disambiguates Han-only text, and `--lang zh-TW` (or `zh-HK`, `zh-Hant`) picks a Traditional Chinese face (2.19); an explicit `--font`/`--font-file` is always kept.
|
|
406
406
|
- **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.10`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.10", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.10",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/gotchas.md
CHANGED
|
@@ -193,7 +193,8 @@ libass and drawtext draw an empty box per character they have no glyph for, and
|
|
|
193
193
|
ffmpeg still exits 0 — a video full of tofu is the classic "it worked" failure.
|
|
194
194
|
Since 1.12 `caption.py`, `graphics.py` and `overlay.py --text` detect the script
|
|
195
195
|
of the text they are about to draw (Japanese, Chinese, Korean, Arabic, Hebrew,
|
|
196
|
-
Devanagari, Bengali, Tamil, Thai, Lao, Cyrillic, Greek
|
|
196
|
+
Devanagari, Bengali, Tamil, Thai, Lao, Cyrillic, Greek; after 3.8.7 also Gurmukhi, Gujarati,
|
|
197
|
+
Telugu, Kannada, Malayalam, Sinhala, Khmer and Myanmar) and resolve a font file that covers it,
|
|
197
198
|
printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
|
|
198
199
|
**No font for the script is a failed job** (`kind: input`), not a warning.
|
|
199
200
|
|
package/references/scripts.md
CHANGED
|
@@ -1740,7 +1740,9 @@ paraphrase a caption to make it fit. Below the floor the cue is split exactly as
|
|
|
1740
1740
|
before and `fit_exhausted: true` says so, with `--min-size` named as the flag
|
|
1741
1741
|
that would go smaller and `|` as the manual break. `--min-size` above `--size`
|
|
1742
1742
|
is a `kind: input` refusal. `--mode mux` is unaffected: soft subtitles carry no
|
|
1743
|
-
size, so
|
|
1743
|
+
size, so a muxed track is neither fitted, wrapped nor split (a player wraps it at
|
|
1744
|
+
the size it draws it; through 3.8.7 it was wrapped at the burn-in size), and
|
|
1745
|
+
`--max-lines` or `--wrap measured` with `--mode mux` is refused.
|
|
1744
1746
|
|
|
1745
1747
|
Results, alongside the existing caption stats:
|
|
1746
1748
|
`fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`,
|
|
@@ -1846,10 +1848,14 @@ never changes: **R1** never inside a word, and a hyphenated token may break only
|
|
|
1846
1848
|
after its hyphen (never after a non-breaking `‑`, nor a leading/trailing
|
|
1847
1849
|
one); **R2** no line that is a lone digit, one or two punctuation characters, or
|
|
1848
1850
|
a single kana, checked at every boundary rather than only the last; **R3** for
|
|
1849
|
-
Japanese and Chinese, a break is preferred after
|
|
1851
|
+
Japanese and Chinese, a break is preferred after `。、!?」』),;:` and (Japanese
|
|
1850
1852
|
only) after a particle — a particle attaches to the word before it, so kinsoku
|
|
1851
1853
|
forbids opening a line with one — discouraged between a kanji stem and its
|
|
1852
|
-
okurigana, and forbidden before a small kana,
|
|
1854
|
+
okurigana, and forbidden before a small kana, `ー`, a closing bracket or the
|
|
1855
|
+
fullwidth `,;:.` (and after an opening bracket); in `phrase` mode the first
|
|
1856
|
+
fill does not take such a break either while a permitted one fits, so a cue of three or more lines, and each
|
|
1857
|
+
piece of a cue split past `--max-lines`, keeps the punctuation on the line it
|
|
1858
|
+
ends; **R4**
|
|
1853
1859
|
for en/es/pt/fr/de/it, an article or preposition is kept with the phrase it
|
|
1854
1860
|
governs: the break before it is preferred and the break after it penalised (a
|
|
1855
1861
|
frozen table, matched case-folded; `--lang`, else the script detector, picks the
|
|
@@ -1868,9 +1874,10 @@ toggleable streams and copies the video and audio bit for bit. The suffix splits
|
|
|
1868
1874
|
on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
|
|
1869
1875
|
token is not itself a file on disk, so `C:\subs\en.srt` and a file named
|
|
1870
1876
|
`a:b.srt` are never mangled; a single `--srt` with no suffix still takes
|
|
1871
|
-
`--language
|
|
1872
|
-
|
|
1873
|
-
|
|
1877
|
+
`--language` (in a mux only: a burn reads `--language` as the script and wrap
|
|
1878
|
+
hint, not as a track tag). `--offset` shifts every added track (each later one as a
|
|
1879
|
+
`<output>_trackN.srt` copy); `--min-duration` lengthens the first `--srt`'s cues
|
|
1880
|
+
only, and a note says so when it changed them. `--track-title` names a track (repeated in `--srt` order;
|
|
1874
1881
|
otherwise a frozen display-name table, and a code the table does not know gets
|
|
1875
1882
|
the code itself — never a guessed or translated name), `--default-track LANG`
|
|
1876
1883
|
marks one for auto-selection. Every other new track is explicitly marked *not*
|
|
@@ -1893,7 +1900,9 @@ language.
|
|
|
1893
1900
|
|
|
1894
1901
|
Fonts by script (1.12): with no `--font` and no font named in your brand file, the
|
|
1895
1902
|
script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
|
|
1896
|
-
|
|
1903
|
+
Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Thai,
|
|
1904
|
+
Lao, Khmer, Myanmar, Cyrillic, Greek) picks a font file
|
|
1905
|
+
that covers it, logged as `font: <file>
|
|
1897
1906
|
(covers ko)`. Han-only text is read as Chinese unless `--lang ja|ko` (or
|
|
1898
1907
|
brand.json `"lang"`) says otherwise; `--language` is the same flag, and still
|
|
1899
1908
|
tags the subtitle stream under `--mode mux` and sets `--transcribe`'s language.
|
|
@@ -1907,7 +1916,14 @@ does not cover the script gets one line and a font resolved by script anyway. `d
|
|
|
1907
1916
|
|
|
1908
1917
|
`--karaoke` uses real per-word timings when the transcript has them (a whisper
|
|
1909
1918
|
`<stem>.json` or `<stem>.words.json` next to the SRT, `{"segments": [{"words":
|
|
1910
|
-
[{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides.
|
|
1919
|
+
[{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides. The
|
|
1920
|
+
highlight waits for the first word (the silence before it is an empty `\k` in
|
|
1921
|
+
`sweep`, an event with no active word in `word`), and `--offset` moves the word
|
|
1922
|
+
timings with the cues. With `--karaoke-style word` each word is its own event, and
|
|
1923
|
+
`--animate` is spread over them as the cue's one envelope: it fades in at the
|
|
1924
|
+
first event and out at the last, and a slide or a pop carries on across them.
|
|
1925
|
+
`--animate slide` ends on the line's own anchor (`--position`, a cue's `{\anN}`,
|
|
1926
|
+
the side margins).
|
|
1911
1927
|
|
|
1912
1928
|
`--mode burn` (default) renders subtitles into the picture and always
|
|
1913
1929
|
re-encodes both streams. `--mode mux` copies video and audio untouched and
|
package/scripts/_ass_overlay.py
CHANGED
|
@@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
29
29
|
EMOJI_SENTINEL = "\ue000"
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def emoji_placeholder(box_px: float) -> str:
|
|
32
|
+
def emoji_placeholder(box_px: float, back_alpha: int = 0) -> str:
|
|
33
33
|
r"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
|
|
34
34
|
|
|
35
35
|
Measured, not assumed (1.15 spec, open question 2). U+2588 FULL BLOCK is NOT 1.0 em: its
|
|
@@ -40,9 +40,12 @@ def emoji_placeholder(box_px: float) -> str:
|
|
|
40
40
|
whitespace carried by `\fsp` (letter spacing, in script pixels) on a zero-width space: the
|
|
41
41
|
next glyph starts exactly `box_px` later, with no font dependence at all (verified by render,
|
|
42
42
|
including inside a karaoke run, where the placeholder is its own zero-duration \kf segment).
|
|
43
|
-
|
|
43
|
+
The text after it gets back exactly what this override changed -- the four alphas (the box
|
|
44
|
+
colour's is `back_alpha`, the Style's own) and a letter spacing of 0, which every Style here
|
|
45
|
+
has. A `\r` did the same and also reset everything before it on the line: the pop's
|
|
46
|
+
\fscx/\t, a per-cue \fs, a cue's <i>/<b> and colour (A69, #457).
|
|
44
47
|
"""
|
|
45
|
-
return "{\\alpha&HFF&\\fsp%.1f}\u200b{\\
|
|
48
|
+
return "{\\alpha&HFF&\\fsp%.1f}\u200b{\\alpha&H00&\\4a&H%02X&\\fsp0}" % (box_px, int(back_alpha) & 0xFF)
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def ass_time(sec: float) -> str:
|
|
@@ -381,7 +384,7 @@ def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h:
|
|
|
381
384
|
events.append(
|
|
382
385
|
f"Dialogue: 0,{ass_time(el['start'])},{ass_time(el['end'])},{name},,0,0,0,,"
|
|
383
386
|
"{" + "".join(tags) + "}" + ass_text(el["text"]).replace(
|
|
384
|
-
EMOJI_SENTINEL, emoji_placeholder(el.get("box_px") or el["size"])))
|
|
387
|
+
EMOJI_SENTINEL, emoji_placeholder(el.get("box_px") or el["size"], int(el.get("box_alpha", 0)))))
|
|
385
388
|
body = "\n".join(header + ["", "[Events]",
|
|
386
389
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, "
|
|
387
390
|
"MarginV, Effect, Text"] + events) + "\n"
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -383,7 +383,9 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
|
|
|
383
383
|
mode = "png"
|
|
384
384
|
elif family and libass_glyph is False:
|
|
385
385
|
mode = "none"
|
|
386
|
-
elif family:
|
|
386
|
+
elif family or libass_glyph:
|
|
387
|
+
# a face libass draws the emoji from, colour family listed or not (A64, #457: a glyph from
|
|
388
|
+
# a monochrome face was answered `none`, against this docstring and libass_glyph: true)
|
|
387
389
|
mode = "mono"
|
|
388
390
|
elif not fc_answered:
|
|
389
391
|
# No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
|
|
@@ -404,6 +406,8 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
|
|
|
404
406
|
detail = f"{family} installed; the colour render probe was not run"
|
|
405
407
|
elif assets_dir:
|
|
406
408
|
detail = "no colour emoji family installed; using the PNG assets directory"
|
|
409
|
+
elif libass_glyph:
|
|
410
|
+
detail = "no colour emoji family installed; libass draws the emoji monochrome from an installed face"
|
|
407
411
|
else:
|
|
408
412
|
detail = "no colour emoji family installed and no --emoji-assets directory"
|
|
409
413
|
result = {"mode": mode, "color_font": family, "color_font_file": file,
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -109,21 +109,32 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def font_choice(asked: "Optional[str]", *, font_file: "Optional[str]" = None, script_file: "Optional[str]" = None,
|
|
112
|
-
script_family: "Optional[str]" = None, renderer: str = "drawtext"
|
|
112
|
+
script_family: "Optional[str]" = None, renderer: str = "drawtext",
|
|
113
|
+
bold: bool = False) -> "Dict[str, object]":
|
|
113
114
|
"""The font a text-drawing tool draws with, as its result's `font_used`: {asked, file, family,
|
|
114
115
|
how, substituted}. `how`: font_file (the caller's own file), script (a font picked to cover the
|
|
115
116
|
text's script, 1.12), resolved (the asked family resolved to a file: fc-match, or the Windows
|
|
116
117
|
Fonts folder), by_name (no file resolved; ffmpeg/libass look the family up themselves, so the
|
|
117
118
|
file is not known here). For libass (`renderer="libass"`) a resolved file is the one fontconfig
|
|
118
119
|
picks for the family, which is what libass loads through fontconfig; on Windows libass is
|
|
119
|
-
given the family by name, so nothing is resolved for it.
|
|
120
|
+
given the family by name, so nothing is resolved for it.
|
|
121
|
+
|
|
122
|
+
`bold` (libass drawing a Bold style): libass asks fontconfig for the family at bold weight, so
|
|
123
|
+
the file reported is the one `fc-match FAMILY:bold` names -- the family's bold face where it
|
|
124
|
+
has one, its regular face (emboldened by libass) where it has none. The regular face was
|
|
125
|
+
reported whatever was drawn (A70, #457). Not on Windows, where nothing is resolved for libass."""
|
|
126
|
+
weight = ":bold" if bold and renderer == "libass" and platform.system() != "Windows" else ""
|
|
120
127
|
if font_file:
|
|
121
128
|
return {"asked": font_file, "file": font_file, "family": None, "how": "font_file", "substituted": False}
|
|
122
129
|
if script_file:
|
|
130
|
+
if weight and script_family:
|
|
131
|
+
face = resolve_font(script_family + weight)
|
|
132
|
+
if face["file"] and not face["substituted"]:
|
|
133
|
+
script_file = face["file"]
|
|
123
134
|
return {"asked": asked, "file": script_file, "family": script_family, "how": "script", "substituted": False}
|
|
124
135
|
if renderer == "libass" and platform.system() == "Windows":
|
|
125
136
|
return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
|
|
126
|
-
found = resolve_font(asked or "")
|
|
137
|
+
found = resolve_font((asked or "") + weight)
|
|
127
138
|
if not found["file"]:
|
|
128
139
|
return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
|
|
129
140
|
return {"asked": asked, "file": found["file"], "family": found["family"], "how": "resolved",
|
|
@@ -147,13 +158,18 @@ def record_font_choice(choice: "Dict[str, object]") -> "Dict[str, object]":
|
|
|
147
158
|
# request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
|
|
148
159
|
# to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
|
|
149
160
|
# warning (a video full of boxes is not a delivery).
|
|
150
|
-
|
|
161
|
+
# A65 (#457): gu, pa, te, kn, ml, si, km and my were in SHAPING_SCRIPTS but had no range
|
|
162
|
+
# below, so their text was detected as Latin, drawn in the Latin font and reported verified.
|
|
163
|
+
SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "pa", "gu", "ta", "te", "kn", "ml", "si", "th", "lo",
|
|
164
|
+
"km", "my", "ru", "el", "latin")
|
|
151
165
|
|
|
152
166
|
|
|
153
167
|
LANGUAGE_NAMES = {
|
|
154
168
|
"ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
|
|
155
169
|
"hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
|
|
156
170
|
"el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
|
|
171
|
+
"pa": "Gurmukhi (Punjabi)", "gu": "Gujarati", "te": "Telugu", "kn": "Kannada", "ml": "Malayalam",
|
|
172
|
+
"si": "Sinhala", "km": "Khmer", "my": "Myanmar (Burmese)",
|
|
157
173
|
"zh-hant": "Traditional Chinese",
|
|
158
174
|
}
|
|
159
175
|
|
|
@@ -161,7 +177,8 @@ LANGUAGE_NAMES = {
|
|
|
161
177
|
# fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
|
|
162
178
|
# every CJK font that claims zh carries; the rest are the plain two-letter codes).
|
|
163
179
|
FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
|
|
164
|
-
"bn": "bn", "ta": "ta", "lo": "lo", "zh-hant": "zh-tw"
|
|
180
|
+
"bn": "bn", "ta": "ta", "lo": "lo", "zh-hant": "zh-tw",
|
|
181
|
+
"pa": "pa", "gu": "gu", "te": "te", "kn": "kn", "ml": "ml", "si": "si", "km": "km", "my": "my"}
|
|
165
182
|
|
|
166
183
|
|
|
167
184
|
# Families tried in order, best first. The names are matched case-insensitively against the start
|
|
@@ -180,6 +197,14 @@ PREFERRED_FAMILIES = {
|
|
|
180
197
|
"bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
|
|
181
198
|
"ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
|
|
182
199
|
"lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
|
|
200
|
+
"pa": ["Noto Sans Gurmukhi", "Noto Serif Gurmukhi", "Lohit Gurmukhi", "Raavi", "Nirmala UI", "FreeSerif"],
|
|
201
|
+
"gu": ["Noto Sans Gujarati", "Noto Serif Gujarati", "Lohit Gujarati", "Shruti", "Nirmala UI", "FreeSerif"],
|
|
202
|
+
"te": ["Noto Sans Telugu", "Noto Serif Telugu", "Lohit Telugu", "Gautami", "Nirmala UI"],
|
|
203
|
+
"kn": ["Noto Sans Kannada", "Noto Serif Kannada", "Lohit Kannada", "Tunga", "Nirmala UI"],
|
|
204
|
+
"ml": ["Noto Sans Malayalam", "Noto Serif Malayalam", "Lohit Malayalam", "Kartika", "Nirmala UI"],
|
|
205
|
+
"si": ["Noto Sans Sinhala", "Noto Serif Sinhala", "LKLUG", "Iskoola Pota", "Nirmala UI"],
|
|
206
|
+
"km": ["Noto Sans Khmer", "Noto Serif Khmer", "Khmer OS", "Leelawadee UI", "Khmer UI"],
|
|
207
|
+
"my": ["Noto Sans Myanmar", "Noto Serif Myanmar", "Padauk", "Myanmar Text"],
|
|
183
208
|
"ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
|
|
184
209
|
"el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
|
|
185
210
|
}
|
|
@@ -201,6 +226,10 @@ WINDOWS_FONTS = {
|
|
|
201
226
|
"bn": [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
|
|
202
227
|
"ta": [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI"), ("latha.ttf", "Latha")],
|
|
203
228
|
"lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
|
|
229
|
+
# Nirmala UI carries every Indic script Windows supports, Sinhala included
|
|
230
|
+
**{k: [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI")] for k in ("pa", "gu", "te", "kn", "ml", "si")},
|
|
231
|
+
"km": [("leelawui.ttf", "Leelawadee UI"), ("KhmerUI.ttf", "Khmer UI")],
|
|
232
|
+
"my": [("mmrtext.ttf", "Myanmar Text")],
|
|
204
233
|
"ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
|
|
205
234
|
"el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
|
|
206
235
|
}
|
|
@@ -233,9 +262,17 @@ _SCRIPT_RANGES = (
|
|
|
233
262
|
("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
|
|
234
263
|
("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
|
|
235
264
|
("bn", ((0x0980, 0x09FF),)),
|
|
265
|
+
("pa", ((0x0A00, 0x0A7F),)),
|
|
266
|
+
("gu", ((0x0A80, 0x0AFF),)),
|
|
236
267
|
("ta", ((0x0B80, 0x0BFF),)),
|
|
268
|
+
("te", ((0x0C00, 0x0C7F),)),
|
|
269
|
+
("kn", ((0x0C80, 0x0CFF),)),
|
|
270
|
+
("ml", ((0x0D00, 0x0D7F),)),
|
|
271
|
+
("si", ((0x0D80, 0x0DFF), (0x111E0, 0x111FF))),
|
|
237
272
|
("th", ((0x0E00, 0x0E7F),)),
|
|
238
273
|
("lo", ((0x0E80, 0x0EFF),)),
|
|
274
|
+
("km", ((0x1780, 0x17FF), (0x19E0, 0x19FF))),
|
|
275
|
+
("my", ((0x1000, 0x109F), (0xA9E0, 0xA9FF), (0xAA60, 0xAA7F))),
|
|
239
276
|
("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
|
|
240
277
|
("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
|
|
241
278
|
)
|
package/scripts/_common/wrap.py
CHANGED
|
@@ -145,11 +145,14 @@ JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one
|
|
|
145
145
|
# text on each side of a candidate break -- putting them in the character string above turned
|
|
146
146
|
# か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
|
|
147
147
|
JA_PARTICLE_WORDS = ("から", "まで", "より")
|
|
148
|
-
JA_SENTENCE_END = "
|
|
148
|
+
JA_SENTENCE_END = "。、!?」』),;:" # a break AFTER one of these is preferred (A54: ,;: too)
|
|
149
149
|
# Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
|
|
150
150
|
# and the Japanese punctuation that hangs on the end of the line before it.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
# A54 (#457): the fullwidth comma, semicolon, colon and full stop Chinese uses, and the closing
|
|
152
|
+
# brackets of both, are on the same table: a line that opens with ',' is the same fault as one
|
|
153
|
+
# that opens with '、'.
|
|
154
|
+
JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%,;:.〉]}〗"
|
|
155
|
+
JA_NO_LINE_END = "(「『【〔《‘“〈[{〖" # ... and the ones that may never end a line
|
|
153
156
|
|
|
154
157
|
# R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
|
|
155
158
|
# noun phrase it governs, so a break before one is the good break (the word opens the next line
|
|
@@ -365,7 +368,7 @@ def _function_words(lang: "Optional[str]") -> "frozenset":
|
|
|
365
368
|
"""R4's table for this language. An unknown or absent language gets the union of the six sets:
|
|
366
369
|
a token that appears in several of them is the same kind of word in each, which is why the
|
|
367
370
|
rule is a penalty and not a refusal."""
|
|
368
|
-
key = (lang or "").strip().lower().split("-")[0]
|
|
371
|
+
key = (lang or "").strip().lower().replace("_", "-").split("-")[0]
|
|
369
372
|
if key in FUNCTION_WORDS:
|
|
370
373
|
return frozenset(FUNCTION_WORDS[key])
|
|
371
374
|
return _FUNCTION_WORDS_ANY
|
|
@@ -404,7 +407,7 @@ def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
|
|
|
404
407
|
single-character table applies."""
|
|
405
408
|
if not prev_char or not next_char:
|
|
406
409
|
return PENALTY_NEUTRAL
|
|
407
|
-
script = (lang or "").strip().lower().split("-")[0]
|
|
410
|
+
script = (lang or "").strip().lower().replace("_", "-").split("-")[0]
|
|
408
411
|
if script not in ("ja", "zh"):
|
|
409
412
|
# A kana on either side settles it: only Japanese has them, and char_script() reads a bare
|
|
410
413
|
# Han character as Chinese, which used to switch the particle rules off for exactly the
|
|
@@ -494,14 +497,15 @@ def _gaps(lines: "List[str]", gaps: "Optional[List[bool]]") -> "List[bool]":
|
|
|
494
497
|
|
|
495
498
|
|
|
496
499
|
def _fix_orphans(lines: "List[str]", max_em: float,
|
|
497
|
-
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
500
|
+
gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
|
|
498
501
|
"""No last line that is a single stranded atom.
|
|
499
502
|
|
|
500
503
|
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
501
504
|
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
502
505
|
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
503
506
|
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
504
|
-
itself, so a two-word cue is never made worse.
|
|
507
|
+
itself, so a two-word cue is never made worse. `kinsoku` (phrase mode) moves a punctuation
|
|
508
|
+
mark down only with the character it closes (_kinsoku_atoms)."""
|
|
505
509
|
lines = list(lines)
|
|
506
510
|
gaps = _gaps(lines, gaps)
|
|
507
511
|
for _ in range(len(lines)):
|
|
@@ -510,7 +514,7 @@ def _fix_orphans(lines: "List[str]", max_em: float,
|
|
|
510
514
|
tail = _atoms(lines[-1])
|
|
511
515
|
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
512
516
|
break
|
|
513
|
-
prev = _atoms(lines[-2])
|
|
517
|
+
prev = _kinsoku_atoms(_atoms(lines[-2])) if kinsoku else _atoms(lines[-2])
|
|
514
518
|
if len(prev) < 2:
|
|
515
519
|
break
|
|
516
520
|
moved, spaced = prev[-1]
|
|
@@ -532,14 +536,15 @@ def _fix_weak_lines(lines: "List[str]", max_em: float,
|
|
|
532
536
|
1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
|
|
533
537
|
three-line cue survived. Walking upward from the last line, while a line is weak the last atom
|
|
534
538
|
of the line above moves down onto it -- with 1.15's two guards intact (the result must still
|
|
535
|
-
fit, and the line above must not itself become weak), so the line count never changes.
|
|
539
|
+
fit, and the line above must not itself become weak), so the line count never changes. A
|
|
540
|
+
punctuation mark moves down only with the character it closes (_kinsoku_atoms, A54)."""
|
|
536
541
|
lines = list(lines)
|
|
537
542
|
gaps = _gaps(lines, gaps)
|
|
538
543
|
for i in range(len(lines) - 1, 0, -1):
|
|
539
544
|
for _ in range(len(lines)):
|
|
540
545
|
if not _is_weak_line(lines[i]):
|
|
541
546
|
break
|
|
542
|
-
prev = _atoms(lines[i - 1])
|
|
547
|
+
prev = _kinsoku_atoms(_atoms(lines[i - 1]))
|
|
543
548
|
if len(prev) < 2:
|
|
544
549
|
break
|
|
545
550
|
moved, spaced = prev[-1]
|
|
@@ -633,9 +638,36 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]",
|
|
|
633
638
|
return out, moved
|
|
634
639
|
|
|
635
640
|
|
|
641
|
+
def _kinsoku_atoms(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
|
|
642
|
+
"""The atom list with each no-line-start character joined to the atom before it, and each
|
|
643
|
+
no-line-end character to the atom after it, where no space stands between them.
|
|
644
|
+
|
|
645
|
+
The phrase passes only ever choose among breaks that fit, so a forbidden break the greedy fill
|
|
646
|
+
had already taken stayed whenever no pass reconsidered it: a cue of three lines or more, or
|
|
647
|
+
the pieces of a cue split past --max-lines, opened a line with '、', ',' or '。' (A54, #457).
|
|
648
|
+
Joined, the fill carries the character before the punctuation onto the next line with it."""
|
|
649
|
+
split: "List[Tuple[str, bool]]" = []
|
|
650
|
+
for atom, spaced in atoms:
|
|
651
|
+
# the atomiser reads a run of CJK punctuation as one Latin word ('。('): part it where a
|
|
652
|
+
# closing mark meets an opening one, so each half can join its own neighbour
|
|
653
|
+
start = 0
|
|
654
|
+
for k in range(1, len(atom)):
|
|
655
|
+
if atom[k - 1] in JA_NO_LINE_START and atom[k] in JA_NO_LINE_END:
|
|
656
|
+
split.append((atom[start:k], spaced if start == 0 else False))
|
|
657
|
+
start = k
|
|
658
|
+
split.append((atom[start:], spaced if start == 0 else False))
|
|
659
|
+
out: "List[Tuple[str, bool]]" = []
|
|
660
|
+
for atom, spaced in split:
|
|
661
|
+
if out and not spaced and (atom[0] in JA_NO_LINE_START or out[-1][0][-1] in JA_NO_LINE_END):
|
|
662
|
+
out[-1] = (out[-1][0] + atom, out[-1][1])
|
|
663
|
+
else:
|
|
664
|
+
out.append((atom, spaced))
|
|
665
|
+
return out
|
|
666
|
+
|
|
667
|
+
|
|
636
668
|
def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
637
669
|
sliced: "Optional[List[int]]" = None,
|
|
638
|
-
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
670
|
+
gaps: "Optional[List[bool]]" = None, kinsoku: bool = False) -> "List[str]":
|
|
639
671
|
"""The greedy fill on its own: the line count every mode must keep.
|
|
640
672
|
|
|
641
673
|
`slice_overlong` (off by default -- only caption.py's final burn-in pass turns it on, never
|
|
@@ -647,7 +679,10 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
|
647
679
|
`broken_inside_word` without re-deriving it from the output lines.
|
|
648
680
|
|
|
649
681
|
`gaps`, when given, is filled with one flag per break: did a space stand there in `raw`? The
|
|
650
|
-
passes in _balance() carry it, so a break they move keeps (or drops) exactly that space.
|
|
682
|
+
passes in _balance() carry it, so a break they move keeps (or drops) exactly that space.
|
|
683
|
+
|
|
684
|
+
`kinsoku` (phrase mode) never breaks before a no-line-start or after a no-line-end character
|
|
685
|
+
(_kinsoku_atoms); `measured` keeps 1.15's fill."""
|
|
651
686
|
current = ""
|
|
652
687
|
current_gap = False # did a space stand before `current`'s first atom
|
|
653
688
|
chunk: "List[str]" = []
|
|
@@ -671,7 +706,7 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
|
671
706
|
return pieces[-1], False
|
|
672
707
|
return atom, spaced
|
|
673
708
|
|
|
674
|
-
for atom, spaced in _atoms(raw):
|
|
709
|
+
for atom, spaced in (_kinsoku_atoms(_atoms(raw)) if kinsoku else _atoms(raw)):
|
|
675
710
|
if not current:
|
|
676
711
|
current, current_gap = start_line(atom, spaced)
|
|
677
712
|
continue
|
|
@@ -699,7 +734,7 @@ def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]"
|
|
|
699
734
|
fixed = _fix_orphans(chunk, max_em, gaps)
|
|
700
735
|
rebalanced = _rebalance(fixed, max_em, list(gaps) if gaps is not None else None)
|
|
701
736
|
else:
|
|
702
|
-
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps), max_em, gaps)
|
|
737
|
+
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps, kinsoku=True), max_em, gaps)
|
|
703
738
|
later = list(gaps) if gaps is not None else None
|
|
704
739
|
rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang, later)
|
|
705
740
|
rebalanced = _fix_weak_lines(rebalanced, max_em, later)
|
|
@@ -729,7 +764,8 @@ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "ph
|
|
|
729
764
|
if not raw.strip():
|
|
730
765
|
continue
|
|
731
766
|
gaps: "List[bool]" = []
|
|
732
|
-
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps
|
|
767
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
|
|
768
|
+
kinsoku=mode == "phrase")
|
|
733
769
|
lines.extend(_balance(chunk, max_em, mode, lang, gaps) if balance else chunk)
|
|
734
770
|
return lines or [text]
|
|
735
771
|
def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
@@ -750,11 +786,17 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
|
750
786
|
if not raw.strip():
|
|
751
787
|
continue
|
|
752
788
|
gaps: "List[bool]" = []
|
|
753
|
-
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps
|
|
789
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps,
|
|
790
|
+
kinsoku=mode == "phrase")
|
|
754
791
|
greedy.extend(chunk)
|
|
755
792
|
wrapped.extend(_balance(list(chunk), max_em, mode, lang, gaps))
|
|
756
|
-
|
|
757
|
-
|
|
793
|
+
if mode == "measured":
|
|
794
|
+
measured.extend(chunk)
|
|
795
|
+
else:
|
|
796
|
+
# 1.15's wrap fills without the kinsoku joins (A54), so it is filled on its own
|
|
797
|
+
plain_gaps: "List[bool]" = []
|
|
798
|
+
plain = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, gaps=plain_gaps)
|
|
799
|
+
measured.extend(_balance(plain, max_em, "measured", None, plain_gaps))
|
|
758
800
|
if not greedy:
|
|
759
801
|
greedy = [text]
|
|
760
802
|
return (wrapped or [text], greedy, measured or [text])
|
package/scripts/caption.py
CHANGED
|
@@ -321,12 +321,15 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
321
321
|
scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
|
|
322
322
|
# --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
|
|
323
323
|
# pops in against a line that is still fading up. These match the \fad values below.
|
|
324
|
-
fade_in, fade_out =
|
|
325
|
-
"slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
|
|
324
|
+
fade_in, fade_out = (ms / 1000.0 for ms in ANIMATE_FADE_MS.get(getattr(args, "animate", None) or "none", (0, 0)))
|
|
326
325
|
size_px = args.size * play_h / 288.0
|
|
327
326
|
margin_px = args.margin * play_h / 288.0
|
|
328
327
|
line_h = size_px * 1.2
|
|
329
328
|
box_px = size_px * scale
|
|
329
|
+
# A62 (#457): libass lays a line out between the Style's MarginL and MarginR (margins_x, the
|
|
330
|
+
# horizontal safe zone), not --margin, which is the vertical distance: a left-aligned PNG was
|
|
331
|
+
# drawn over the first words and a right-aligned one left of its gap
|
|
332
|
+
margin_l, margin_r = margins_x(args, play_w)
|
|
330
333
|
out_cues = []
|
|
331
334
|
for start, end, text in cues:
|
|
332
335
|
lines = _cue_lines(text)
|
|
@@ -342,11 +345,11 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
342
345
|
y_top = play_h - margin_px - (n - i) * line_h
|
|
343
346
|
line_w = text_width_em(strip_srt_markup(line), scale) * size_px
|
|
344
347
|
if align in (1, 4, 7):
|
|
345
|
-
x0 =
|
|
348
|
+
x0 = float(margin_l)
|
|
346
349
|
elif align in (3, 6, 9):
|
|
347
|
-
x0 = play_w -
|
|
350
|
+
x0 = play_w - margin_r - line_w
|
|
348
351
|
else:
|
|
349
|
-
x0 = (play_w - line_w) / 2.0
|
|
352
|
+
x0 = margin_l + (play_w - margin_l - margin_r - line_w) / 2.0
|
|
350
353
|
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
351
354
|
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
352
355
|
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
@@ -561,23 +564,31 @@ def safe_width_fraction(args, play_w: Optional[int]) -> float:
|
|
|
561
564
|
return max(0.05, (play_w - left - right) / float(play_w))
|
|
562
565
|
|
|
563
566
|
|
|
567
|
+
def line_fraction(args, play_w: Optional[int]) -> float:
|
|
568
|
+
"""The width fraction one line is wrapped to: safe_width_fraction, narrowed under
|
|
569
|
+
--karaoke-style word by --karaoke-scale.
|
|
570
|
+
|
|
571
|
+
--karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
|
|
572
|
+
(WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget is
|
|
573
|
+
narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
|
|
574
|
+
still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
|
|
575
|
+
exactly filled the safe width would overflow it the moment its emphasised word appears. The
|
|
576
|
+
size fit measures against the same budget, or a cue it reported as fitting was split by the
|
|
577
|
+
layout after it (A58, #457).
|
|
578
|
+
"""
|
|
579
|
+
frac = safe_width_fraction(args, play_w)
|
|
580
|
+
if getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
|
|
581
|
+
frac = frac / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
|
|
582
|
+
return frac
|
|
583
|
+
|
|
584
|
+
|
|
564
585
|
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|
|
565
586
|
"""How many em fit on one caption line at the chosen size, or None without video geometry.
|
|
566
587
|
|
|
567
588
|
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
568
|
-
rendered pixel size is size * play_h / 288.
|
|
569
|
-
|
|
570
|
-
--karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
|
|
571
|
-
(WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget here
|
|
572
|
-
is narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
|
|
573
|
-
still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
|
|
574
|
-
exactly filled the safe width would overflow it the moment its emphasised word appears.
|
|
589
|
+
rendered pixel size is size * play_h / 288; the width is line_fraction's.
|
|
575
590
|
"""
|
|
576
|
-
|
|
577
|
-
safe_fraction=safe_width_fraction(args, play_w))
|
|
578
|
-
if em is not None and getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
|
|
579
|
-
em = em / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
|
|
580
|
-
return em
|
|
591
|
+
return line_em_for_size(args.size, play_w, play_h, safe_fraction=line_fraction(args, play_w))
|
|
581
592
|
|
|
582
593
|
|
|
583
594
|
def parse_ass_dialogue(path: str) -> str:
|
|
@@ -692,35 +703,139 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
|
|
|
692
703
|
|
|
693
704
|
|
|
694
705
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
695
|
-
n_words: int) -> Optional[List[int]]:
|
|
696
|
-
"""
|
|
706
|
+
n_words: int) -> Optional[Tuple[int, List[int]]]:
|
|
707
|
+
"""(lead, centiseconds per word) for one cue from real word timings; None when they don't
|
|
708
|
+
cover it. `lead` is the silence between the cue's start and its first word: the highlight
|
|
709
|
+
waits for it rather than starting at the cue and running ahead of the speech by that much
|
|
710
|
+
(A59, #457)."""
|
|
697
711
|
inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
|
|
698
712
|
if len(inside) != n_words or n_words <= 0:
|
|
699
713
|
return None
|
|
700
714
|
total_cs = max(1, int(round((end - start) * 100)))
|
|
701
|
-
|
|
715
|
+
first = min(end, max(start, inside[0][0]))
|
|
716
|
+
lead = int(round((first - start) * 100))
|
|
717
|
+
bounds = [first] + [max(first, min(end, w[1])) for w in inside]
|
|
702
718
|
out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
|
|
703
|
-
out[-1] += total_cs - sum(out)
|
|
719
|
+
out[-1] += total_cs - lead - sum(out)
|
|
704
720
|
if out[-1] < 1:
|
|
705
721
|
return None
|
|
706
|
-
return out
|
|
722
|
+
return lead, out
|
|
707
723
|
|
|
708
724
|
|
|
709
|
-
def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
|
|
710
|
-
|
|
725
|
+
def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
|
|
726
|
+
n_words: int) -> Tuple[int, List[int]]:
|
|
727
|
+
"""(lead, centiseconds per word) for one cue, shared by --karaoke-style sweep and word.
|
|
711
728
|
|
|
712
729
|
Real word timings from the transcript beat both the energy estimate and the even split --
|
|
713
|
-
they are what the speaker actually did, not a proxy for it.
|
|
730
|
+
they are what the speaker actually did, not a proxy for it. Only they carry a lead (the
|
|
731
|
+
silence before the first word); the other two share the whole cue out from its start.
|
|
714
732
|
"""
|
|
715
733
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
716
|
-
|
|
717
|
-
if
|
|
718
|
-
|
|
719
|
-
|
|
734
|
+
timed = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
|
|
735
|
+
if timed is not None:
|
|
736
|
+
return timed
|
|
737
|
+
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
738
|
+
return 0, word_durations_from_audio(video, start, end, n_words, getattr(args, "audio_stream", 0))
|
|
739
|
+
per = max(1, dur_cs // max(1, n_words))
|
|
740
|
+
return 0, [per] * n_words
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def shift_words(words: List[Tuple[float, float, str]], offset: float) -> List[Tuple[float, float, str]]:
|
|
744
|
+
"""Word timings moved by --offset, as the cues they belong to are (#505): the offset corrects a
|
|
745
|
+
transcript that runs early or late against the sound, and its word timings come from the same
|
|
746
|
+
transcript."""
|
|
747
|
+
if not offset:
|
|
748
|
+
return list(words or [])
|
|
749
|
+
return [(s + offset, e + offset, w) for s, e, w in (words or [])]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# A `{...}` that ffmpeg's SRT reader passes on to libass as text, where it is an override block and
|
|
753
|
+
# draws nothing: not `{\...}` (ffmpeg removes those itself), not a MicroDVD `{y:i}` style code, not
|
|
754
|
+
# the `\{` escape
|
|
755
|
+
SRT_HIDDEN_BRACES_RE = re.compile(r"(?<!\\)\{(?!\\)(?![CcFfoPSsYy]:)[^{}]*\}")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# --animate's envelope in ms (fade in, fade out), and the slide's rise: one table for the ASS
|
|
759
|
+
# below and the emoji PNGs that ride it (plan_emoji)
|
|
760
|
+
ANIMATE_FADE_MS = {"fade": (200, 200), "pop": (80, 120), "slide": (150, 150)}
|
|
761
|
+
SLIDE_MS = 250
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def line_anchor(align: int, play_w: int, play_h: int, margin: int, margin_l: int, margin_r: int) -> Tuple[int, int]:
|
|
765
|
+
"""The point libass anchors a line of alignment `align` (numpad) at, from the Style's margins:
|
|
766
|
+
what a `\\move` has to end on so that --animate slide lands where the line is drawn without it
|
|
767
|
+
(A56, #457: the slide always ended at the bottom centre)."""
|
|
768
|
+
if align in (1, 4, 7):
|
|
769
|
+
x = margin_l
|
|
770
|
+
elif align in (3, 6, 9):
|
|
771
|
+
x = play_w - margin_r
|
|
772
|
+
else:
|
|
773
|
+
x = margin_l + (play_w - margin_l - margin_r) // 2
|
|
774
|
+
if align in (7, 8, 9):
|
|
775
|
+
y = margin
|
|
776
|
+
elif align in (4, 5, 6):
|
|
777
|
+
y = play_h // 2
|
|
778
|
+
else:
|
|
779
|
+
y = play_h - margin
|
|
780
|
+
return x, y
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def animate_fx(kind: str, o: int, e: int, dur: int, anchor: Tuple[int, int], rise: int) -> str:
|
|
784
|
+
"""The --animate override for an event shown from `o` to `e` ms into a cue `dur` ms long.
|
|
785
|
+
|
|
786
|
+
A whole cue (o == 0, e == dur) gets the envelope it always had. --karaoke-style word draws a
|
|
787
|
+
cue as one event per word: each gets the part of the cue's one envelope that falls inside it,
|
|
788
|
+
started where the event before left it, so the fade-out comes at the cue's end and not at the
|
|
789
|
+
end of the first word, and a slide or a pop does not restart or jump at a word (A57, #457)."""
|
|
790
|
+
if kind not in ANIMATE_FADE_MS:
|
|
791
|
+
return ""
|
|
792
|
+
fin, fout = ANIMATE_FADE_MS[kind]
|
|
793
|
+
x, y2 = anchor
|
|
794
|
+
y1 = y2 + rise
|
|
795
|
+
whole = o == 0 and e == dur
|
|
796
|
+
if whole:
|
|
797
|
+
fade = "\\fad(%d,%d)" % (fin, fout)
|
|
798
|
+
else:
|
|
799
|
+
out_at = dur - fout
|
|
800
|
+
|
|
801
|
+
def alpha(t: float) -> int:
|
|
802
|
+
a = 0.0
|
|
803
|
+
if t < fin:
|
|
804
|
+
a = 255.0 * (1.0 - t / float(fin))
|
|
805
|
+
if t > out_at and fout:
|
|
806
|
+
a = max(a, 255.0 * (t - out_at) / float(fout))
|
|
807
|
+
return int(round(min(255.0, max(0.0, a))))
|
|
808
|
+
if o >= fin and e <= out_at:
|
|
809
|
+
fade = ""
|
|
810
|
+
else:
|
|
811
|
+
a1 = alpha(o)
|
|
812
|
+
t2 = max(0, fin - o)
|
|
813
|
+
t3 = max(t2, out_at - o)
|
|
814
|
+
fade = "\\fade(%d,%d,255,0,%d,%d,%d)" % (a1, a1 if o > out_at else 0, t2, t3, max(t3, dur - o))
|
|
815
|
+
if kind == "fade":
|
|
816
|
+
tags = fade
|
|
817
|
+
elif kind == "pop":
|
|
818
|
+
if o == 0:
|
|
819
|
+
scale = "\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)"
|
|
820
|
+
elif o < 120:
|
|
821
|
+
v = int(round(60 + 50 * o / 120.0))
|
|
822
|
+
scale = ("\\fscx%d\\fscy%d\\t(0,%d,\\fscx110\\fscy110)\\t(%d,%d,\\fscx100\\fscy100)"
|
|
823
|
+
% (v, v, 120 - o, 120 - o, 200 - o))
|
|
824
|
+
elif o < 200:
|
|
825
|
+
v = int(round(110 - 10 * (o - 120) / 80.0))
|
|
826
|
+
scale = "\\fscx%d\\fscy%d\\t(0,%d,\\fscx100\\fscy100)" % (v, v, 200 - o)
|
|
827
|
+
else:
|
|
828
|
+
scale = ""
|
|
829
|
+
tags = fade + scale
|
|
830
|
+
else:
|
|
831
|
+
if o == 0:
|
|
832
|
+
move = "\\move(%d,%d,%d,%d,0,%d)" % (x, y1, x, y2, SLIDE_MS)
|
|
833
|
+
elif o < SLIDE_MS:
|
|
834
|
+
move = "\\move(%d,%d,%d,%d,0,%d)" % (x, int(round(y1 + (y2 - y1) * o / float(SLIDE_MS))), x, y2, SLIDE_MS - o)
|
|
720
835
|
else:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return
|
|
836
|
+
move = "\\pos(%d,%d)" % (x, y2)
|
|
837
|
+
tags = fade + move
|
|
838
|
+
return "{" + tags + "}" if tags else ""
|
|
724
839
|
|
|
725
840
|
|
|
726
841
|
# an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
|
|
@@ -751,7 +866,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
751
866
|
primary = ass_color(args.highlight_color if args.karaoke else args.color)
|
|
752
867
|
secondary = ass_color(args.color)
|
|
753
868
|
outline = ass_color(args.outline_color)
|
|
754
|
-
|
|
869
|
+
back_alpha = 0x80 # the --box colour is half transparent; an emoji placeholder restores it
|
|
870
|
+
back = ass_color(args.outline_color, back_alpha)
|
|
755
871
|
word_style = args.karaoke and getattr(args, "karaoke_style", "sweep") == "word"
|
|
756
872
|
word_past = ass_color(args.color)
|
|
757
873
|
word_active = ass_color(args.highlight_color)
|
|
@@ -786,14 +902,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
786
902
|
# an override block. Newlines become \N in the same pass.
|
|
787
903
|
# SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
|
|
788
904
|
# overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
|
|
905
|
+
# the line's anchor for --animate slide: the cue's own {\anN} first, as libass reads it
|
|
906
|
+
anchor = line_anchor(srt_alignment(text) or ALIGN[args.position], play_w, play_h, margin, margin_l, margin_r)
|
|
789
907
|
text = ass_escape_markup(text, colour=not args.karaoke)
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
elif args.animate == "pop":
|
|
794
|
-
fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
|
|
795
|
-
elif args.animate == "slide":
|
|
796
|
-
fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
|
|
908
|
+
cue_ms = max(1, int(round((end - start) * 100))) * 10
|
|
909
|
+
fx = animate_fx(args.animate, 0, cue_ms, cue_ms, anchor, int(30 * scale))
|
|
910
|
+
size_fx = ""
|
|
797
911
|
body = text
|
|
798
912
|
# --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
|
|
799
913
|
# see fit_size()'s docstring for why a size that changes cue to cue is not the default.
|
|
@@ -803,7 +917,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
803
917
|
own_sizes = getattr(args, "_fit_sizes_out", None) or []
|
|
804
918
|
own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
|
|
805
919
|
if own and own != args.size:
|
|
806
|
-
|
|
920
|
+
size_fx = "{\\fs%d}" % int(round(own * scale))
|
|
921
|
+
fx += size_fx
|
|
807
922
|
if args.karaoke and word_style:
|
|
808
923
|
# One Dialogue event per WORD, each carrying the whole cue -- \k/\kf can only
|
|
809
924
|
# interpolate colour, so scaling/emboldening the active word (and a third "upcoming"
|
|
@@ -819,18 +934,21 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
819
934
|
# today's karaoke-off rendering, rather than zero Dialogue lines for this cue
|
|
820
935
|
out_body = body
|
|
821
936
|
if EMOJI_SENTINEL in out_body:
|
|
822
|
-
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
937
|
+
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
823
938
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{out_body}")
|
|
824
939
|
continue
|
|
825
940
|
n_words = len(real_idx)
|
|
826
|
-
durs = _karaoke_word_durations(args, video, start, end, n_words)
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
941
|
+
lead, durs = _karaoke_word_durations(args, video, start, end, n_words)
|
|
942
|
+
# (first cs, cs, active word): the silence before the first word is an event of its
|
|
943
|
+
# own in which no word is active yet (A59)
|
|
944
|
+
events, acc = ([(0, lead, -1)] if lead > 0 else []), lead
|
|
945
|
+
for j, d in enumerate(durs):
|
|
946
|
+
events.append((acc, d, j))
|
|
830
947
|
acc += d
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
948
|
+
span_ms = acc * 10 # the --animate envelope spans the events, first to last
|
|
949
|
+
for ev_cs, ev_len, j in events:
|
|
950
|
+
w_start = start + ev_cs / 100.0
|
|
951
|
+
w_end = start + (ev_cs + ev_len) / 100.0
|
|
834
952
|
# colour/scale each real word by its rank against the active one (j); an
|
|
835
953
|
# emoji-placeholder token is never active and just keeps its plain text
|
|
836
954
|
styled, flat_pos = [], 0
|
|
@@ -855,18 +973,19 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
855
973
|
styled.append(" ".join(styled_line))
|
|
856
974
|
out_body = "\\N".join(styled)
|
|
857
975
|
if EMOJI_SENTINEL in out_body:
|
|
858
|
-
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
859
|
-
# the size/fit override (\fsN) belongs on every event; the
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
event_fx =
|
|
976
|
+
out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
977
|
+
# the size/fit override (\fsN) belongs on every event; --animate is the cue's one
|
|
978
|
+
# envelope, and each event takes the part of it that falls inside the event: the
|
|
979
|
+
# fade-out at the cue's end, the slide and the pop carried on, not restarted (A57)
|
|
980
|
+
event_fx = animate_fx(args.animate, ev_cs * 10, (ev_cs + ev_len) * 10, span_ms, anchor,
|
|
981
|
+
int(30 * scale)) + size_fx
|
|
863
982
|
lines.append(f"Dialogue: 0,{t(w_start)},{t(w_end)},Default,,0,0,0,,{event_fx}{out_body}")
|
|
864
983
|
continue
|
|
865
984
|
if args.karaoke:
|
|
866
985
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
867
986
|
segments = body.split("\\N")
|
|
868
987
|
words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
|
|
869
|
-
durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
988
|
+
lead, durs = _karaoke_word_durations(args, video, start, end, len(words))
|
|
870
989
|
it = iter(durs)
|
|
871
990
|
out_segments = []
|
|
872
991
|
for seg in segments:
|
|
@@ -877,8 +996,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
877
996
|
out_segments.append(" ".join(
|
|
878
997
|
("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
879
998
|
body = "\\N".join(out_segments)
|
|
999
|
+
if lead > 0:
|
|
1000
|
+
# the silence before the first word: an empty \k syllable, so the fill starts
|
|
1001
|
+
# with the speech and not at the cue's start (A59)
|
|
1002
|
+
body = "{\\k%d}" % lead + body
|
|
880
1003
|
if EMOJI_SENTINEL in body:
|
|
881
|
-
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
1004
|
+
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
|
|
882
1005
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
883
1006
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
884
1007
|
fh.write("\n".join(header + lines) + "\n")
|
|
@@ -1214,6 +1337,13 @@ def main() -> int:
|
|
|
1214
1337
|
"ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
|
|
1215
1338
|
if args.animate != "none" or args.karaoke:
|
|
1216
1339
|
die("--animate/--karaoke render pixels into the picture and require --mode burn")
|
|
1340
|
+
if args.max_lines != 2 or args.wrap != "phrase":
|
|
1341
|
+
# A55: a soft track is wrapped by the player at the size it draws; this run does not wrap
|
|
1342
|
+
# or split it, so a layout flag would be accepted and do nothing
|
|
1343
|
+
asked = f"--max-lines {args.max_lines}" if args.max_lines != 2 else f"--wrap {args.wrap}"
|
|
1344
|
+
die(f"{asked} lays out burned captions; --mode mux adds the cues as they are and the player wraps "
|
|
1345
|
+
"a soft subtitle at the size it draws it", kind="input",
|
|
1346
|
+
hint=f"drop {asked.split()[0]}, or use --mode burn")
|
|
1217
1347
|
|
|
1218
1348
|
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
1219
1349
|
if args.max_lines < 1:
|
|
@@ -1289,7 +1419,7 @@ def main() -> int:
|
|
|
1289
1419
|
return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
|
|
1290
1420
|
max_lines=args.max_lines, play_w=play_w, play_h=play_h,
|
|
1291
1421
|
mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
|
|
1292
|
-
safe_fraction=
|
|
1422
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1293
1423
|
|
|
1294
1424
|
def fit_the_size(cue_list):
|
|
1295
1425
|
"""Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
|
|
@@ -1331,7 +1461,7 @@ def main() -> int:
|
|
|
1331
1461
|
# budget and then drawing some cues large put lines off the side of the frame.
|
|
1332
1462
|
args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
|
|
1333
1463
|
play_w, play_h,
|
|
1334
|
-
safe_fraction=
|
|
1464
|
+
safe_fraction=line_fraction(args, play_w))
|
|
1335
1465
|
for i in range(len(cue_list))]
|
|
1336
1466
|
args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
|
|
1337
1467
|
|
|
@@ -1340,7 +1470,9 @@ def main() -> int:
|
|
|
1340
1470
|
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
1341
1471
|
transcript all come out equally readable."""
|
|
1342
1472
|
fit_the_size(cue_list)
|
|
1343
|
-
out
|
|
1473
|
+
# A55 (#457): a soft subtitle is laid out by the player, at a size this run does not
|
|
1474
|
+
# know; wrapping it at the burn-in size split a cue on a portrait video into three
|
|
1475
|
+
out, stats = layout_cues(cue_list, max_em=None if args.mode == "mux" else max_line_em(args, play_w, play_h),
|
|
1344
1476
|
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
1345
1477
|
offset=args.offset, wrap=args.wrap, lang=args.language,
|
|
1346
1478
|
per_cue_em=getattr(args, "_fit_cue_em", None),
|
|
@@ -1360,15 +1492,24 @@ def main() -> int:
|
|
|
1360
1492
|
for token in (args.srt or []):
|
|
1361
1493
|
path_part, lang_part = split_srt_lang(token)
|
|
1362
1494
|
srt_tracks.append((path_part, lang_part))
|
|
1363
|
-
|
|
1495
|
+
# --language tags the track only where there is a track to tag (A71, #457): a burn reads it as
|
|
1496
|
+
# the script and wrap hint, and refusing it as an --srt suffix named a flag nobody had given
|
|
1497
|
+
tagged_by_language = False
|
|
1498
|
+
if args.mode == "mux" and len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
|
|
1364
1499
|
srt_tracks[0] = (srt_tracks[0][0], args.language)
|
|
1500
|
+
tagged_by_language = True
|
|
1501
|
+
if args.mode == "mux" and args.language and not srt_tracks and not LANG_TOKEN_RE.match(args.language):
|
|
1502
|
+
# the --text/--transcribe track is tagged with it too, and was tagged unchecked
|
|
1503
|
+
die(f"--language: '{args.language}' is not a language code (two or three letters, optionally with a "
|
|
1504
|
+
"region, e.g. en, ja, pt-BR)", kind="input")
|
|
1365
1505
|
if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
|
|
1366
1506
|
die("burning renders pixels; only one language can be in the picture -- burn one and mux "
|
|
1367
1507
|
"the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
|
|
1368
1508
|
seen_langs = [lang for _p, lang in srt_tracks if lang]
|
|
1369
1509
|
for lang in seen_langs:
|
|
1370
1510
|
if not LANG_TOKEN_RE.match(lang):
|
|
1371
|
-
die(f"--srt: '{lang}' is not a language code (two or
|
|
1511
|
+
die(f"{'--language' if tagged_by_language else '--srt'}: '{lang}' is not a language code (two or "
|
|
1512
|
+
"three letters, optionally with a "
|
|
1372
1513
|
"region, e.g. en, ja, pt-BR)", kind="input")
|
|
1373
1514
|
if len(set(seen_langs)) != len(seen_langs):
|
|
1374
1515
|
dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
|
|
@@ -1464,7 +1605,8 @@ def main() -> int:
|
|
|
1464
1605
|
cues = asr_run.cues
|
|
1465
1606
|
args._asr = dict(asr_run.facts)
|
|
1466
1607
|
args._asr_notes = list(asr_run.notes)
|
|
1467
|
-
args._word_timings = whisper_word_timings(srt_path) or sorted(
|
|
1608
|
+
args._word_timings = shift_words(whisper_word_timings(srt_path) or sorted(
|
|
1609
|
+
(w["start"], w["end"], w["word"]) for w in asr_run.words), args.offset)
|
|
1468
1610
|
cues, changed = lay_out(cues)
|
|
1469
1611
|
if changed:
|
|
1470
1612
|
write_srt(cues, srt_path)
|
|
@@ -1629,11 +1771,11 @@ def main() -> int:
|
|
|
1629
1771
|
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1630
1772
|
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1631
1773
|
added[n] = (copy, lang)
|
|
1632
|
-
if len(added) > 1 and
|
|
1633
|
-
# --
|
|
1634
|
-
#
|
|
1635
|
-
side_notes.append(f"--
|
|
1636
|
-
"added track(s) keep the
|
|
1774
|
+
if len(added) > 1 and caption_stats.get("extended"):
|
|
1775
|
+
# --min-duration lengthens the first --srt's cues; the others keep their own durations,
|
|
1776
|
+
# and the result says so (no track is wrapped or split in a mux, A55)
|
|
1777
|
+
side_notes.append(f"--min-duration lengthened cues of the first --srt only: the other {len(added) - 1} "
|
|
1778
|
+
"added track(s) keep the cue lengths of their own files")
|
|
1637
1779
|
titles = list(args.track_title or [])
|
|
1638
1780
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1639
1781
|
dropped_titles: List[str] = []
|
|
@@ -1773,7 +1915,7 @@ def main() -> int:
|
|
|
1773
1915
|
elif font_file or not args.fonts_dir:
|
|
1774
1916
|
# the family libass is given is the sanitised one (ass_font_name): that is what it resolves
|
|
1775
1917
|
font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
|
|
1776
|
-
renderer="libass"))
|
|
1918
|
+
renderer="libass", bold=bool(args.bold)))
|
|
1777
1919
|
else:
|
|
1778
1920
|
font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
|
|
1779
1921
|
"fonts_dir": args.fonts_dir, "substituted": False}
|
|
@@ -1801,6 +1943,15 @@ def main() -> int:
|
|
|
1801
1943
|
brand if args.brand else None)
|
|
1802
1944
|
# the PNG route and --emoji none both change the drawn text, so they need the generated ASS
|
|
1803
1945
|
force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
|
|
1946
|
+
if (not args.ass and not force_ass and args.animate == "none" and not args.karaoke
|
|
1947
|
+
and any(SRT_HIDDEN_BRACES_RE.search(t) for _s, _e, t in src_cues)):
|
|
1948
|
+
# A68 (#457): ffmpeg's SRT reader hands `{curly}` to libass as an override block, which
|
|
1949
|
+
# draws nothing of it, while the generated ASS draws the braces and their text; the cues
|
|
1950
|
+
# go the generated-ASS way, so the burned words are the words given
|
|
1951
|
+
force_ass = True
|
|
1952
|
+
info("cue text has braces that ffmpeg's SRT reader would hide; drawing the cues through the generated ASS")
|
|
1953
|
+
side_notes.append("the cues hold text in braces, which a plain SRT burn does not draw ({...} is an override "
|
|
1954
|
+
"block to libass), so they were drawn through the generated ASS, braces included")
|
|
1804
1955
|
if emoji_plan and emoji_plan.get("box_px"):
|
|
1805
1956
|
args._emoji_box_px = emoji_plan["box_px"]
|
|
1806
1957
|
|
|
@@ -1812,7 +1963,8 @@ def main() -> int:
|
|
|
1812
1963
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1813
1964
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1814
1965
|
# Use the cue source's sidecar, even when layout rewrites the caller's SRT.
|
|
1815
|
-
args._word_timings = whisper_word_timings(
|
|
1966
|
+
args._word_timings = shift_words(whisper_word_timings(
|
|
1967
|
+
args.srt if args.srt and not (args.text or args.transcribe) else srt_path), args.offset)
|
|
1816
1968
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1817
1969
|
claim([(ass_path, "ASS")])
|
|
1818
1970
|
w, h = display_size(meta["video"])
|