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 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 nothing here draws the glyph, in which case the emoji are left out of the text with an `emoji_not_drawn` warning (and a `graphics.py` title that is nothing but emoji is refused). Nothing is ever downloaded.
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.9`) | any release |
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.9", "execution_mode": "local", "kind": "execution",
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.9",
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": [
@@ -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) and resolve a font file that covers it,
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
 
@@ -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 the SRT is the one 1.16 wrote.
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 `。、!?」』)` and (Japanese
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, `ー` or a closing bracket; **R4**
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`. `--offset` shifts every added track (each later one as a
1872
- `<output>_trackN.srt` copy); `--max-lines` and `--min-duration` lay out the first
1873
- `--srt` only, and a note says so when they changed it. `--track-title` names a track (repeated in `--srt` order;
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
- Thai, Cyrillic, Greek) picks a font file that covers it, logged as `font: <file>
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
@@ -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
- `\r` restores the style for the rest of the line.
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{\\r}" % box_px
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"
@@ -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,
@@ -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") -> "Dict[str, object]":
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
- SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
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
  )
@@ -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 = "。、!?」』)" # a break AFTER one of these is preferred
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
- JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
152
- JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
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
- measured.extend(chunk if mode == "measured"
757
- else _balance(list(chunk), max_em, "measured", None, gaps))
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])
@@ -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 = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
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 = margin_px
348
+ x0 = float(margin_l)
346
349
  elif align in (3, 6, 9):
347
- x0 = play_w - margin_px - line_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
- em = line_em_for_size(args.size, play_w, play_h,
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
- """Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
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
- bounds = [max(start, inside[0][0])] + [max(start, min(end, w[1])) for w in inside]
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, n_words: int) -> List[int]:
710
- """Centiseconds per word for one cue, shared by --karaoke-style sweep and word.
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
- durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
717
- if durs is None:
718
- if getattr(args, "karaoke_timing", "even") == "energy" and video:
719
- durs = word_durations_from_audio(video, start, end, n_words, getattr(args, "audio_stream", 0))
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
- per = max(1, dur_cs // max(1, n_words))
722
- durs = [per] * n_words
723
- return durs
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
- back = ass_color(args.outline_color, 0x80)
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
- fx = ""
791
- if args.animate == "fade":
792
- fx = "{\\fad(200,200)}"
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
- fx += "{\\fs%d}" % int(round(own * scale))
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
- starts_cs, acc = [], 0
828
- for d in durs:
829
- starts_cs.append(acc)
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
- for j in range(n_words):
832
- w_start = start + starts_cs[j] / 100.0
833
- w_end = start + (starts_cs[j] + durs[j]) / 100.0
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 entrance animation
860
- # (\fad/\move/pop) only on the cue's FIRST word event -- replaying it on every
861
- # later word would re-fade/re-pop the whole cue each time the highlight advances
862
- event_fx = fx if j == 0 else (fx[fx.index("{\\fs"):] if "{\\fs" in fx else "")
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=safe_width_fraction(args, play_w))
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=safe_width_fraction(args, play_w))
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, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
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
- if len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
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 three letters, optionally with a "
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((w["start"], w["end"], w["word"]) for w in asr_run.words)
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 any(caption_stats.get(k) for k in ("wrapped", "split", "extended")):
1633
- # --max-lines and --min-duration lay out the first --srt, the track the size fit is
1634
- # made for; the others keep their own line breaks and durations, and the result says so
1635
- side_notes.append(f"--max-lines/--min-duration laid out the first --srt only: the other {len(added) - 1} "
1636
- "added track(s) keep the line breaks and cue lengths of their own files")
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(args.srt if args.srt and not (args.text or args.transcribe) else srt_path)
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"])