ffmpeg-skill 1.16.1 → 1.17.1
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 +19 -9
- package/SKILL.md +27 -22
- package/docs/contract.md +35 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +281 -1
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/probe.py +14 -0
- package/scripts/_common/runner.py +10 -6
- package/scripts/_common/text.py +131 -7
- package/scripts/_contract.py +8 -6
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +157 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +326 -26
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
- package/scripts/verify.py +1 -1
- package/scripts/waveform.py +1 -2
package/scripts/caption.py
CHANGED
|
@@ -40,17 +40,27 @@ import unicodedata
|
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
from typing import Any, Dict, List, Optional, Tuple
|
|
42
42
|
|
|
43
|
-
from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units,
|
|
43
|
+
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
44
|
+
resolve as resolve_platform)
|
|
44
45
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
45
46
|
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
|
|
46
47
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
48
|
+
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
49
|
+
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
50
|
+
# caption.parse_srt / caption.transcribe / caption.whisper_word_timings before 1.17 still does.
|
|
51
|
+
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
52
|
+
write_srt)
|
|
47
53
|
from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
|
|
54
|
+
fit_size, line_em_for_size, MIN_CAPTION_FRACTION,
|
|
48
55
|
break_penalty, _is_weak_line, _atoms, _join, _break_spaced, _bare_word, _function_words,
|
|
49
56
|
_split_hyphens, FUNCTION_WORDS, JA_PARTICLES, JA_SENTENCE_END, _fix_orphans, _rebalance)
|
|
50
57
|
|
|
51
58
|
# The breaker's names are caption.py's public surface as much as _common's: every caller and test
|
|
52
59
|
# that reached for `caption.wrap_text` before 1.16 still does.
|
|
53
|
-
__all__ = ["
|
|
60
|
+
__all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_no_engine",
|
|
61
|
+
"ASR_INSTALL_HINT",
|
|
62
|
+
"SAFE_WIDTH_FRACTION", "ORPHAN_MIN_EM", "WRAP_MODES", "wrap_text", "wrap_variants",
|
|
63
|
+
"fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION",
|
|
54
64
|
"best_break", "break_penalty", "_is_weak_line", "_atoms", "_join", "_break_spaced",
|
|
55
65
|
"_bare_word", "_function_words", "_split_hyphens", "FUNCTION_WORDS", "JA_PARTICLES",
|
|
56
66
|
"JA_SENTENCE_END", "_fix_orphans", "_rebalance", "char_script", "NO_SPACE_SCRIPTS",
|
|
@@ -97,150 +107,6 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
97
107
|
return cues
|
|
98
108
|
|
|
99
109
|
|
|
100
|
-
def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
|
|
101
|
-
"""Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
|
|
102
|
-
whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
|
|
103
|
-
No engine installed -> clear error with install hints; the skill never depends on one."""
|
|
104
|
-
import shutil
|
|
105
|
-
import subprocess
|
|
106
|
-
import tempfile
|
|
107
|
-
from _common import require_tool, run_analysis, STATE
|
|
108
|
-
ffmpeg = require_tool("ffmpeg")
|
|
109
|
-
tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
|
|
110
|
-
try:
|
|
111
|
-
return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
|
|
112
|
-
finally:
|
|
113
|
-
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
|
|
117
|
-
"""Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
|
|
118
|
-
from _common import STATE, die
|
|
119
|
-
limit = STATE.timeout or None
|
|
120
|
-
try:
|
|
121
|
-
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
|
|
122
|
-
except subprocess.TimeoutExpired:
|
|
123
|
-
die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
|
|
124
|
-
code=124, kind="timeout")
|
|
125
|
-
return None # unreachable
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
|
|
129
|
-
ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
|
|
130
|
-
from _common import run_analysis, STATE, die
|
|
131
|
-
wav = os.path.join(tmpdir, "audio.wav")
|
|
132
|
-
# A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
|
|
133
|
-
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
134
|
-
# reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
|
|
135
|
-
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
136
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
137
|
-
# 1. whisper.cpp
|
|
138
|
-
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
|
|
139
|
-
if not cli:
|
|
140
|
-
# older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
|
|
141
|
-
# in a directory that names whisper, so an unrelated /usr/bin/main is never run
|
|
142
|
-
main_bin = shutil.which("main")
|
|
143
|
-
if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
|
|
144
|
-
cli = main_bin
|
|
145
|
-
if cli:
|
|
146
|
-
model_path = model
|
|
147
|
-
if not os.path.exists(model_path):
|
|
148
|
-
for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin", f"/usr/local/share/whisper/ggml-{model}.bin"):
|
|
149
|
-
if os.path.exists(cand):
|
|
150
|
-
model_path = cand
|
|
151
|
-
break
|
|
152
|
-
base = os.path.join(tmpdir, "out")
|
|
153
|
-
cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
|
|
154
|
-
if language:
|
|
155
|
-
cmd += ["-l", language]
|
|
156
|
-
proc = _asr_run(cmd, subprocess, "whisper.cpp")
|
|
157
|
-
if proc.returncode == 0 and os.path.exists(base + ".srt"):
|
|
158
|
-
info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
|
|
159
|
-
cues = parse_srt(base + ".srt")
|
|
160
|
-
write_srt(cues, out_srt)
|
|
161
|
-
return cues
|
|
162
|
-
info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
|
|
163
|
-
# 2. faster-whisper (python package)
|
|
164
|
-
try:
|
|
165
|
-
from faster_whisper import WhisperModel # type: ignore
|
|
166
|
-
import threading
|
|
167
|
-
result: list = []
|
|
168
|
-
|
|
169
|
-
def work() -> None:
|
|
170
|
-
m = WhisperModel(model, device="cpu", compute_type="int8")
|
|
171
|
-
segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
|
|
172
|
-
result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
|
|
173
|
-
|
|
174
|
-
# An in-process engine gets the same wall-clock limit as the CLI engines and ffmpeg.
|
|
175
|
-
t = threading.Thread(target=work, daemon=True)
|
|
176
|
-
t.start()
|
|
177
|
-
t.join(STATE.timeout or None)
|
|
178
|
-
if t.is_alive():
|
|
179
|
-
die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
|
|
180
|
-
cues = list(result)
|
|
181
|
-
if cues:
|
|
182
|
-
info("transcribed with faster-whisper")
|
|
183
|
-
write_srt(cues, out_srt)
|
|
184
|
-
return cues
|
|
185
|
-
except ImportError:
|
|
186
|
-
pass
|
|
187
|
-
# 3. openai-whisper CLI
|
|
188
|
-
if shutil.which("whisper"):
|
|
189
|
-
cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
|
|
190
|
-
if language:
|
|
191
|
-
cmd += ["--language", language]
|
|
192
|
-
proc = _asr_run(cmd, subprocess, "openai-whisper")
|
|
193
|
-
srt = os.path.join(tmpdir, "audio.srt")
|
|
194
|
-
if proc.returncode == 0 and os.path.exists(srt):
|
|
195
|
-
info("transcribed with openai-whisper")
|
|
196
|
-
cues = parse_srt(srt)
|
|
197
|
-
write_srt(cues, out_srt)
|
|
198
|
-
return cues
|
|
199
|
-
die("no local speech-to-text engine found for --transcribe.\n"
|
|
200
|
-
"Install one (all run offline):\n"
|
|
201
|
-
" whisper.cpp: brew install whisper-cpp (then download a model: ggml-base.bin)\n"
|
|
202
|
-
" faster-whisper: pip install faster-whisper\n"
|
|
203
|
-
" openai-whisper: pip install openai-whisper\n"
|
|
204
|
-
"Or write the cues by hand with --text cues.txt (see format above).")
|
|
205
|
-
return []
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
209
|
-
cues: List[Tuple[float, float, str]] = []
|
|
210
|
-
block: List[str] = []
|
|
211
|
-
content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
|
|
212
|
-
for line in content.split("\n"):
|
|
213
|
-
if line.strip():
|
|
214
|
-
block.append(line)
|
|
215
|
-
continue
|
|
216
|
-
if block:
|
|
217
|
-
times = next((b for b in block if "-->" in b), None)
|
|
218
|
-
if times:
|
|
219
|
-
a, b = times.split("-->")
|
|
220
|
-
text = "\n".join(block[block.index(times) + 1:]).strip()
|
|
221
|
-
try:
|
|
222
|
-
cues.append((parse_time(a), parse_time(b), text))
|
|
223
|
-
except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
|
|
224
|
-
die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
|
|
225
|
-
block = []
|
|
226
|
-
if not cues:
|
|
227
|
-
die(f"no cues found in {path}")
|
|
228
|
-
return cues
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
232
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
233
|
-
for i, (s, e, t) in enumerate(cues, 1):
|
|
234
|
-
# A blank line is SRT's own block separator (index/timecode/text, blank, next block).
|
|
235
|
-
# Cue text can contain one -- parse_text_cues() turns a bare "|" into "\n", so a source
|
|
236
|
-
# line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
|
|
237
|
-
# raw would split one cue into two malformed half-blocks (the second missing its own
|
|
238
|
-
# index/timecode). Collapse any run of blank lines within the cue text to a single
|
|
239
|
-
# newline so the cue's own text can never fake the format's block boundary.
|
|
240
|
-
t = re.sub(r"\n{2,}", "\n", t).strip("\n")
|
|
241
|
-
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
242
|
-
|
|
243
|
-
|
|
244
110
|
def word_durations_from_audio(video: str, start: float, end: float, n_words: int, audio_stream: int = 0) -> List[int]:
|
|
245
111
|
"""Split a cue's time across n_words in proportion to speech energy (centiseconds each).
|
|
246
112
|
|
|
@@ -434,7 +300,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
434
300
|
|
|
435
301
|
def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
|
|
436
302
|
min_duration: float, offset: float, wrap: str = "phrase",
|
|
437
|
-
lang: Optional[str] = None
|
|
303
|
+
lang: Optional[str] = None, per_cue_em: Optional[List[Optional[float]]] = None,
|
|
304
|
+
per_cue_size: Optional[List[int]] = None) -> Tuple[List[Tuple[float, float, str]], dict]:
|
|
438
305
|
"""Shift, wrap, split and lengthen cues so they can actually be read.
|
|
439
306
|
|
|
440
307
|
`offset` moves every cue (a transcript that runs early/late); `max_em` wraps each cue to the
|
|
@@ -442,11 +309,25 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
442
309
|
a cue needing more than `max_lines` lines is split into consecutive cues sharing its time in
|
|
443
310
|
proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
|
|
444
311
|
cue's start. Returns the new cues and a count of what changed.
|
|
312
|
+
|
|
313
|
+
`per_cue_em` (--fit-size-scope cue) gives cue i its OWN line budget instead of the file's:
|
|
314
|
+
a cue drawn at a larger size has a narrower line in em, and wrapping it to the file-wide
|
|
315
|
+
budget -- which is the budget of the SMALLEST size -- produced lines that overflowed the
|
|
316
|
+
frame when they were then drawn large. `per_cue_size` rides along so the caller knows which
|
|
317
|
+
size each OUTPUT cue belongs to after splits have renumbered them; it comes back as
|
|
318
|
+
`stats["cue_sizes"]`, one entry per returned cue.
|
|
445
319
|
"""
|
|
446
320
|
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0,
|
|
447
321
|
"wrap": wrap, "phrase_breaks": 0}
|
|
448
322
|
staged: List[Tuple[float, float, str]] = []
|
|
449
|
-
|
|
323
|
+
staged_sizes: List[Optional[int]] = []
|
|
324
|
+
for cue_index, (start, end, text) in enumerate(cues):
|
|
325
|
+
own_em = max_em
|
|
326
|
+
own_size = None
|
|
327
|
+
if per_cue_em is not None and cue_index < len(per_cue_em):
|
|
328
|
+
own_em = per_cue_em[cue_index]
|
|
329
|
+
if per_cue_size is not None and cue_index < len(per_cue_size):
|
|
330
|
+
own_size = per_cue_size[cue_index]
|
|
450
331
|
if offset:
|
|
451
332
|
start, end = start + offset, end + offset
|
|
452
333
|
if end <= 0:
|
|
@@ -454,14 +335,14 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
454
335
|
continue
|
|
455
336
|
start = max(0.0, start)
|
|
456
337
|
stats["shifted"] += 1
|
|
457
|
-
if
|
|
338
|
+
if own_em and own_em > 0:
|
|
458
339
|
# one greedy fill per cue, three answers off it: what gets burnt in, what the
|
|
459
340
|
# greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
|
|
460
341
|
# given (`phrase_breaks`). Three wrap_text() calls re-ran the atomiser each time.
|
|
461
|
-
lines, greedy, measured = wrap_variants(text,
|
|
342
|
+
lines, greedy, measured = wrap_variants(text, own_em, mode=wrap, lang=lang)
|
|
462
343
|
if lines != [l for l in text.split("\n") if l.strip()]:
|
|
463
344
|
stats["wrapped"] += 1
|
|
464
|
-
if any(text_width_em(l) >
|
|
345
|
+
if any(text_width_em(l) > own_em for l in lines):
|
|
465
346
|
# a run with no break point the wrapper may use (a long word, a Thai phrase
|
|
466
347
|
# without spaces) stays long rather than chopped: say so, and name the fix
|
|
467
348
|
stats["overlong"] = stats.get("overlong", 0) + 1
|
|
@@ -477,11 +358,13 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
477
358
|
for chunk, weight in zip(chunks, weights):
|
|
478
359
|
seg = (end - start) * weight / total_w
|
|
479
360
|
staged.append((t, min(end, t + seg), "\n".join(chunk)))
|
|
361
|
+
staged_sizes.append(own_size)
|
|
480
362
|
t += seg
|
|
481
363
|
stats["split"] += len(chunks) - 1
|
|
482
364
|
continue
|
|
483
365
|
text = "\n".join(lines)
|
|
484
366
|
staged.append((start, end, text))
|
|
367
|
+
staged_sizes.append(own_size)
|
|
485
368
|
out: List[Tuple[float, float, str]] = []
|
|
486
369
|
for i, (start, end, text) in enumerate(staged):
|
|
487
370
|
if min_duration and end - start < min_duration:
|
|
@@ -491,6 +374,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
491
374
|
stats["extended"] += 1
|
|
492
375
|
end = new_end
|
|
493
376
|
out.append((start, end, text))
|
|
377
|
+
if per_cue_size is not None:
|
|
378
|
+
stats["cue_sizes"] = staged_sizes
|
|
494
379
|
return out, stats
|
|
495
380
|
|
|
496
381
|
|
|
@@ -511,12 +396,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
|
|
|
511
396
|
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
512
397
|
rendered pixel size is size * play_h / 288.
|
|
513
398
|
"""
|
|
514
|
-
|
|
515
|
-
return None
|
|
516
|
-
size_px = args.size * play_h / 288.0
|
|
517
|
-
if size_px <= 0:
|
|
518
|
-
return None
|
|
519
|
-
return (play_w * SAFE_WIDTH_FRACTION) / size_px
|
|
399
|
+
return line_em_for_size(args.size, play_w, play_h)
|
|
520
400
|
|
|
521
401
|
|
|
522
402
|
def parse_ass_dialogue(path: str) -> str:
|
|
@@ -561,44 +441,6 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
|
|
|
561
441
|
return n
|
|
562
442
|
|
|
563
443
|
|
|
564
|
-
def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
|
|
565
|
-
"""Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
|
|
566
|
-
|
|
567
|
-
whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
|
|
568
|
-
times; when they are there, --karaoke should follow the real speech instead of splitting the
|
|
569
|
-
cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
|
|
570
|
-
{"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
|
|
571
|
-
{"words": [...]} shape. Anything unreadable is simply "no word timings".
|
|
572
|
-
"""
|
|
573
|
-
if not srt_path:
|
|
574
|
-
return []
|
|
575
|
-
stem = os.path.splitext(srt_path)[0]
|
|
576
|
-
for cand in (stem + ".words.json", stem + ".json"):
|
|
577
|
-
if not os.path.exists(cand):
|
|
578
|
-
continue
|
|
579
|
-
try:
|
|
580
|
-
data = json.loads(Path(cand).read_text(encoding="utf-8"))
|
|
581
|
-
except (OSError, ValueError):
|
|
582
|
-
continue
|
|
583
|
-
raw = []
|
|
584
|
-
if isinstance(data, dict):
|
|
585
|
-
raw = list(data.get("words") or [])
|
|
586
|
-
for seg in data.get("segments") or []:
|
|
587
|
-
raw.extend((seg or {}).get("words") or [])
|
|
588
|
-
words = []
|
|
589
|
-
for w in raw:
|
|
590
|
-
try:
|
|
591
|
-
text = str(w.get("word") or w.get("text") or "").strip()
|
|
592
|
-
if text:
|
|
593
|
-
words.append((float(w["start"]), float(w["end"]), text))
|
|
594
|
-
except (AttributeError, KeyError, TypeError, ValueError):
|
|
595
|
-
continue
|
|
596
|
-
if words:
|
|
597
|
-
info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
|
|
598
|
-
return sorted(words)
|
|
599
|
-
return []
|
|
600
|
-
|
|
601
|
-
|
|
602
444
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
603
445
|
n_words: int) -> Optional[List[int]]:
|
|
604
446
|
"""Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
|
|
@@ -639,7 +481,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
639
481
|
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
640
482
|
]
|
|
641
483
|
lines = []
|
|
642
|
-
for start, end, text in cues:
|
|
484
|
+
for cue_index, (start, end, text) in enumerate(cues):
|
|
643
485
|
# ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
|
|
644
486
|
# commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
|
|
645
487
|
# all effectively user-controlled) that happens to contain braces would otherwise be
|
|
@@ -657,6 +499,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
657
499
|
elif args.animate == "slide":
|
|
658
500
|
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)
|
|
659
501
|
body = text
|
|
502
|
+
# --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
|
|
503
|
+
# see fit_size()'s docstring for why a size that changes cue to cue is not the default.
|
|
504
|
+
# The size is the one THIS cue was laid out at, carried through layout_cues; re-measuring
|
|
505
|
+
# here would measure the post-layout text (already carrying the wrap's newlines), which
|
|
506
|
+
# is a different string from the one the fit was computed on.
|
|
507
|
+
own_sizes = getattr(args, "_fit_sizes_out", None) or []
|
|
508
|
+
own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
|
|
509
|
+
if own and own != args.size:
|
|
510
|
+
fx += "{\\fs%d}" % int(round(own * scale))
|
|
660
511
|
if args.karaoke:
|
|
661
512
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
662
513
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
@@ -898,6 +749,15 @@ def main() -> int:
|
|
|
898
749
|
emo.add_argument("--emoji-max", type=int, default=60,
|
|
899
750
|
help="most emoji overlays one run may build (default 60)")
|
|
900
751
|
sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
|
|
752
|
+
sty.add_argument("--fit-size", choices=["auto", "on", "off"], default="auto",
|
|
753
|
+
help="shrink the caption size until the cue fits --max-lines, BEFORE splitting it: "
|
|
754
|
+
"'auto' (default) only when no --size was given, 'on' always, 'off' for 1.16 behaviour")
|
|
755
|
+
sty.add_argument("--min-size", type=int, default=None,
|
|
756
|
+
help="smallest size --fit-size may use, in ASS points (default 13 = 4.5%% of the frame height, "
|
|
757
|
+
"the legibility floor)")
|
|
758
|
+
sty.add_argument("--fit-size-scope", choices=["file", "cue"], default="file",
|
|
759
|
+
help="one fitted size for the whole file (default) or one per cue (a size that changes "
|
|
760
|
+
"cue to cue reads as a mistake, so it is opt-in)")
|
|
901
761
|
sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
|
|
902
762
|
sty.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
|
|
903
763
|
help="how a cue too wide for the safe area is broken into lines: 'phrase' (default, 1.16) never "
|
|
@@ -930,6 +790,10 @@ def main() -> int:
|
|
|
930
790
|
if args.brand and bcap.get("box") and not args.box:
|
|
931
791
|
args.box = True
|
|
932
792
|
args.font = args.font or (bcap.get("font") if args.brand else None) or brand.get("font") or "DejaVu Sans"
|
|
793
|
+
# --fit-size auto shrinks only a size the skill itself chose. An explicit --size is a
|
|
794
|
+
# statement about the look and is never quietly overridden; a brand's caption size is the
|
|
795
|
+
# same kind of statement, so it counts as explicit too.
|
|
796
|
+
args._size_explicit = args.size is not None or bool(args.brand and bcap.get("size") is not None)
|
|
933
797
|
args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
|
|
934
798
|
args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
|
|
935
799
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
@@ -969,6 +833,11 @@ def main() -> int:
|
|
|
969
833
|
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
970
834
|
if args.max_lines < 1:
|
|
971
835
|
die("--max-lines must be at least 1")
|
|
836
|
+
if args.min_size is not None and args.min_size < 1:
|
|
837
|
+
die("--min-size must be at least 1", kind="input")
|
|
838
|
+
if args.min_size is not None and args.min_size > args.size:
|
|
839
|
+
die(f"--min-size {args.min_size} is larger than --size {args.size}: the floor cannot be "
|
|
840
|
+
"above the size it is a floor for", kind="input")
|
|
972
841
|
if args.min_duration < 0:
|
|
973
842
|
die("--min-duration cannot be negative")
|
|
974
843
|
|
|
@@ -991,20 +860,95 @@ def main() -> int:
|
|
|
991
860
|
play_w, play_h = meta["video"]["width"], meta["video"]["height"]
|
|
992
861
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
993
862
|
play_w, play_h = play_h, play_w
|
|
994
|
-
|
|
863
|
+
# A --plan or --dry-run written before the input exists has no geometry to fit against, and a
|
|
864
|
+
# plan that describes a different FontSize from the run that executes it is not a plan. When
|
|
865
|
+
# --platform names a destination, that destination's frame IS the geometry the real run will
|
|
866
|
+
# have, so the fit is computed against it; with no platform there is nothing to stand in for
|
|
867
|
+
# the frame, and size_used is reported as null rather than presenting the requested size as
|
|
868
|
+
# the size that was used.
|
|
869
|
+
planned_frame = False
|
|
870
|
+
if not (play_w and play_h) and args.platform and PLATFORMS[args.platform].get("frame"):
|
|
871
|
+
frame = PLATFORMS[args.platform]["frame"]
|
|
872
|
+
play_w, play_h = frame["w"], frame["h"]
|
|
873
|
+
planned_frame = True
|
|
874
|
+
info(f"[plan] no geometry to measure yet; fitting the caption size against the "
|
|
875
|
+
f"--platform {args.platform} frame ({play_w}x{play_h})")
|
|
876
|
+
|
|
877
|
+
args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
|
|
878
|
+
fit_unmeasurable = not (play_w and play_h)
|
|
879
|
+
fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
|
|
880
|
+
"size_used": None if fit_unmeasurable else args.size,
|
|
881
|
+
"size_floor": args._fit_floor,
|
|
882
|
+
"size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
|
|
883
|
+
"shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
|
|
995
884
|
caption_stats: dict = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0,
|
|
996
885
|
"rebalanced": 0, "wrap": args.wrap, "phrase_breaks": 0}
|
|
886
|
+
caption_stats.update(fit_stats)
|
|
887
|
+
|
|
888
|
+
def fit_params():
|
|
889
|
+
return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
|
|
890
|
+
max_lines=args.max_lines, play_w=play_w, play_h=play_h,
|
|
891
|
+
mode=args.wrap, lang=args.language, scope=args.fit_size_scope)
|
|
892
|
+
|
|
893
|
+
def fit_the_size(cue_list):
|
|
894
|
+
"""Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
|
|
895
|
+
|
|
896
|
+
This is the whole point of the ordering: at a size the cue cannot fit, layout_cues splits
|
|
897
|
+
the sentence into consecutive cues and half of it arrives late (eval 17). The text is never
|
|
898
|
+
touched -- only the type size, and never below the legibility floor.
|
|
899
|
+
"""
|
|
900
|
+
if args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit):
|
|
901
|
+
fit_stats["size_used"] = args.size # a stated size IS the size used
|
|
902
|
+
return
|
|
903
|
+
if fit_unmeasurable:
|
|
904
|
+
# Nothing to measure against: size_used stays null rather than presenting the
|
|
905
|
+
# requested size as one that was fitted.
|
|
906
|
+
return
|
|
907
|
+
if args.mode == "mux":
|
|
908
|
+
# Soft subtitles carry no size: the player picks it. Shrinking would change nothing a
|
|
909
|
+
# viewer sees and would silently change the SRT this run writes, so the mux path is
|
|
910
|
+
# left exactly as 1.16 wrote it.
|
|
911
|
+
return
|
|
912
|
+
fit = fit_size(cue_list, **fit_params())
|
|
913
|
+
fit_stats["size_used"] = fit["size"]
|
|
914
|
+
fit_stats["size_source"] = "platform-frame" if planned_frame else "input"
|
|
915
|
+
fit_stats["shrunk"] = fit["shrunk"]
|
|
916
|
+
fit_stats["fit_exhausted"] = bool(fit["shrunk"]) and not fit["fits"]
|
|
917
|
+
fit_stats["size_pct_height"] = round(fit["size"] * 100.0 / ASS_SCRIPT_HEIGHT, 2)
|
|
918
|
+
if fit["size"] != args.size:
|
|
919
|
+
info(f"caption size {args.size} -> {fit['size']} ASS units "
|
|
920
|
+
f"({fit_stats['size_pct_height']:.1f} % of frame height) so {fit['shrunk']} cue(s) "
|
|
921
|
+
f"fit --max-lines {args.max_lines}; floor {args._fit_floor}")
|
|
922
|
+
args.size = fit["size"]
|
|
923
|
+
elif fit_stats["fit_exhausted"]:
|
|
924
|
+
info(f"caption size stays {args.size} ASS units: {fit['shrunk']} cue(s) still need more "
|
|
925
|
+
f"than {args.max_lines} line(s) at the floor {args._fit_floor} and are split "
|
|
926
|
+
"(--min-size goes smaller; `|` sets the break yourself)")
|
|
927
|
+
if args.fit_size_scope == "cue":
|
|
928
|
+
# Each cue is laid out at ITS OWN size, not at the file minimum. A cue drawn larger
|
|
929
|
+
# has a NARROWER line in em, so wrapping everything to the minimum size's (widest)
|
|
930
|
+
# budget and then drawing some cues large put lines off the side of the frame.
|
|
931
|
+
args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
|
|
932
|
+
play_w, play_h)
|
|
933
|
+
for i in range(len(cue_list))]
|
|
934
|
+
args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
|
|
997
935
|
|
|
998
936
|
def lay_out(cue_list):
|
|
999
937
|
"""Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
|
|
1000
938
|
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
1001
939
|
transcript all come out equally readable."""
|
|
940
|
+
fit_the_size(cue_list)
|
|
1002
941
|
out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
|
|
1003
942
|
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
1004
|
-
offset=args.offset, wrap=args.wrap, lang=args.language
|
|
943
|
+
offset=args.offset, wrap=args.wrap, lang=args.language,
|
|
944
|
+
per_cue_em=getattr(args, "_fit_cue_em", None),
|
|
945
|
+
per_cue_size=getattr(args, "_fit_cue_size", None))
|
|
946
|
+
# which size each OUTPUT cue belongs to, after splits have renumbered them
|
|
947
|
+
args._fit_sizes_out = stats.pop("cue_sizes", None)
|
|
1005
948
|
report_layout(stats)
|
|
1006
949
|
caption_stats.clear()
|
|
1007
950
|
caption_stats.update(stats)
|
|
951
|
+
caption_stats.update(fit_stats)
|
|
1008
952
|
return out, any(v for k, v in stats.items() if k != "wrap")
|
|
1009
953
|
|
|
1010
954
|
# --srt is repeatable since 1.16 (one per language, each with an optional `:lang` suffix).
|
|
@@ -1328,6 +1272,21 @@ def main() -> int:
|
|
|
1328
1272
|
result = probe(output, role="output")
|
|
1329
1273
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
1330
1274
|
extra = {"notes": side_notes} if side_notes else {}
|
|
1275
|
+
# 1.17.1: say so when the words on screen are the words that were handed in. This tool never
|
|
1276
|
+
# rewrites, shortens or translates a cue -- only line breaks, timing and type size move -- so
|
|
1277
|
+
# the honest sentence in a report ("the text is yours, unchanged") needs no extra judgement.
|
|
1278
|
+
# Review 17 finding 5: a cue SPLIT across two consecutive cues, a dropped cue, transcription
|
|
1279
|
+
# and `--emoji none` (which str.replace()s glyphs out of the drawn text) all change what the
|
|
1280
|
+
# viewer reads, so none of them may be reported as unchanged. Wrapping, line breaks and
|
|
1281
|
+
# timing do not count -- the words are the same.
|
|
1282
|
+
caption_stats["text_unchanged"] = bool(
|
|
1283
|
+
not args.transcribe
|
|
1284
|
+
and not caption_stats.get("dropped")
|
|
1285
|
+
and not caption_stats.get("split")
|
|
1286
|
+
and (emoji_plan or {}).get("mode") != "none")
|
|
1287
|
+
if caption_stats["text_unchanged"]:
|
|
1288
|
+
info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
|
|
1289
|
+
"and type size only)")
|
|
1331
1290
|
extra["caption"] = dict(caption_stats)
|
|
1332
1291
|
if emoji_plan:
|
|
1333
1292
|
notes = list(extra.get("notes") or [])
|