ffmpeg-skill 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/SKILL.md +5 -5
- package/docs/contract.md +28 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +264 -4
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/text.py +142 -2
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +150 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +289 -24
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
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,13 +335,17 @@ 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
|
|
345
|
+
if any(text_width_em(l) > own_em for l in lines):
|
|
346
|
+
# a run with no break point the wrapper may use (a long word, a Thai phrase
|
|
347
|
+
# without spaces) stays long rather than chopped: say so, and name the fix
|
|
348
|
+
stats["overlong"] = stats.get("overlong", 0) + 1
|
|
464
349
|
if lines != greedy:
|
|
465
350
|
stats["rebalanced"] += 1
|
|
466
351
|
if wrap != "measured" and lines != measured:
|
|
@@ -473,11 +358,13 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
473
358
|
for chunk, weight in zip(chunks, weights):
|
|
474
359
|
seg = (end - start) * weight / total_w
|
|
475
360
|
staged.append((t, min(end, t + seg), "\n".join(chunk)))
|
|
361
|
+
staged_sizes.append(own_size)
|
|
476
362
|
t += seg
|
|
477
363
|
stats["split"] += len(chunks) - 1
|
|
478
364
|
continue
|
|
479
365
|
text = "\n".join(lines)
|
|
480
366
|
staged.append((start, end, text))
|
|
367
|
+
staged_sizes.append(own_size)
|
|
481
368
|
out: List[Tuple[float, float, str]] = []
|
|
482
369
|
for i, (start, end, text) in enumerate(staged):
|
|
483
370
|
if min_duration and end - start < min_duration:
|
|
@@ -487,14 +374,20 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
487
374
|
stats["extended"] += 1
|
|
488
375
|
end = new_end
|
|
489
376
|
out.append((start, end, text))
|
|
377
|
+
if per_cue_size is not None:
|
|
378
|
+
stats["cue_sizes"] = staged_sizes
|
|
490
379
|
return out, stats
|
|
491
380
|
|
|
492
381
|
|
|
493
382
|
def report_layout(stats: dict) -> None:
|
|
494
383
|
"""One info line, only when a cue actually changed."""
|
|
495
|
-
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped") if stats.get(k)]
|
|
384
|
+
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped", "overlong") if stats.get(k)]
|
|
496
385
|
if parts:
|
|
497
386
|
info("cues: " + ", ".join(parts))
|
|
387
|
+
if stats.get("overlong"):
|
|
388
|
+
info(f"{stats['overlong']} cue line(s) wider than the safe width: a word or a run with no break "
|
|
389
|
+
"point (Thai writes none inside a phrase) was kept whole rather than chopped -- put a "
|
|
390
|
+
"space or `|` where the line may break, or use a smaller --size")
|
|
498
391
|
|
|
499
392
|
|
|
500
393
|
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|
|
@@ -503,12 +396,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
|
|
|
503
396
|
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
504
397
|
rendered pixel size is size * play_h / 288.
|
|
505
398
|
"""
|
|
506
|
-
|
|
507
|
-
return None
|
|
508
|
-
size_px = args.size * play_h / 288.0
|
|
509
|
-
if size_px <= 0:
|
|
510
|
-
return None
|
|
511
|
-
return (play_w * SAFE_WIDTH_FRACTION) / size_px
|
|
399
|
+
return line_em_for_size(args.size, play_w, play_h)
|
|
512
400
|
|
|
513
401
|
|
|
514
402
|
def parse_ass_dialogue(path: str) -> str:
|
|
@@ -553,44 +441,6 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
|
|
|
553
441
|
return n
|
|
554
442
|
|
|
555
443
|
|
|
556
|
-
def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
|
|
557
|
-
"""Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
|
|
558
|
-
|
|
559
|
-
whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
|
|
560
|
-
times; when they are there, --karaoke should follow the real speech instead of splitting the
|
|
561
|
-
cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
|
|
562
|
-
{"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
|
|
563
|
-
{"words": [...]} shape. Anything unreadable is simply "no word timings".
|
|
564
|
-
"""
|
|
565
|
-
if not srt_path:
|
|
566
|
-
return []
|
|
567
|
-
stem = os.path.splitext(srt_path)[0]
|
|
568
|
-
for cand in (stem + ".words.json", stem + ".json"):
|
|
569
|
-
if not os.path.exists(cand):
|
|
570
|
-
continue
|
|
571
|
-
try:
|
|
572
|
-
data = json.loads(Path(cand).read_text(encoding="utf-8"))
|
|
573
|
-
except (OSError, ValueError):
|
|
574
|
-
continue
|
|
575
|
-
raw = []
|
|
576
|
-
if isinstance(data, dict):
|
|
577
|
-
raw = list(data.get("words") or [])
|
|
578
|
-
for seg in data.get("segments") or []:
|
|
579
|
-
raw.extend((seg or {}).get("words") or [])
|
|
580
|
-
words = []
|
|
581
|
-
for w in raw:
|
|
582
|
-
try:
|
|
583
|
-
text = str(w.get("word") or w.get("text") or "").strip()
|
|
584
|
-
if text:
|
|
585
|
-
words.append((float(w["start"]), float(w["end"]), text))
|
|
586
|
-
except (AttributeError, KeyError, TypeError, ValueError):
|
|
587
|
-
continue
|
|
588
|
-
if words:
|
|
589
|
-
info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
|
|
590
|
-
return sorted(words)
|
|
591
|
-
return []
|
|
592
|
-
|
|
593
|
-
|
|
594
444
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
595
445
|
n_words: int) -> Optional[List[int]]:
|
|
596
446
|
"""Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
|
|
@@ -631,7 +481,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
631
481
|
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
632
482
|
]
|
|
633
483
|
lines = []
|
|
634
|
-
for start, end, text in cues:
|
|
484
|
+
for cue_index, (start, end, text) in enumerate(cues):
|
|
635
485
|
# ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
|
|
636
486
|
# commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
|
|
637
487
|
# all effectively user-controlled) that happens to contain braces would otherwise be
|
|
@@ -649,6 +499,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
649
499
|
elif args.animate == "slide":
|
|
650
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)
|
|
651
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))
|
|
652
511
|
if args.karaoke:
|
|
653
512
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
654
513
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
@@ -890,6 +749,15 @@ def main() -> int:
|
|
|
890
749
|
emo.add_argument("--emoji-max", type=int, default=60,
|
|
891
750
|
help="most emoji overlays one run may build (default 60)")
|
|
892
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)")
|
|
893
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)")
|
|
894
762
|
sty.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
|
|
895
763
|
help="how a cue too wide for the safe area is broken into lines: 'phrase' (default, 1.16) never "
|
|
@@ -922,6 +790,10 @@ def main() -> int:
|
|
|
922
790
|
if args.brand and bcap.get("box") and not args.box:
|
|
923
791
|
args.box = True
|
|
924
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)
|
|
925
797
|
args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
|
|
926
798
|
args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
|
|
927
799
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
@@ -961,6 +833,11 @@ def main() -> int:
|
|
|
961
833
|
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
962
834
|
if args.max_lines < 1:
|
|
963
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")
|
|
964
841
|
if args.min_duration < 0:
|
|
965
842
|
die("--min-duration cannot be negative")
|
|
966
843
|
|
|
@@ -983,20 +860,95 @@ def main() -> int:
|
|
|
983
860
|
play_w, play_h = meta["video"]["width"], meta["video"]["height"]
|
|
984
861
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
985
862
|
play_w, play_h = play_h, play_w
|
|
986
|
-
|
|
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}
|
|
987
884
|
caption_stats: dict = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0,
|
|
988
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))]
|
|
989
935
|
|
|
990
936
|
def lay_out(cue_list):
|
|
991
937
|
"""Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
|
|
992
938
|
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
993
939
|
transcript all come out equally readable."""
|
|
940
|
+
fit_the_size(cue_list)
|
|
994
941
|
out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
|
|
995
942
|
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
996
|
-
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)
|
|
997
948
|
report_layout(stats)
|
|
998
949
|
caption_stats.clear()
|
|
999
950
|
caption_stats.update(stats)
|
|
951
|
+
caption_stats.update(fit_stats)
|
|
1000
952
|
return out, any(v for k, v in stats.items() if k != "wrap")
|
|
1001
953
|
|
|
1002
954
|
# --srt is repeatable since 1.16 (one per language, each with an optional `:lang` suffix).
|