ffmpeg-skill 1.16.1 → 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 +11 -7
- 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 +257 -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/text.py +124 -0
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +142 -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,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).
|
package/scripts/cut.py
CHANGED
|
@@ -25,11 +25,13 @@ Examples:
|
|
|
25
25
|
python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
|
|
26
26
|
"""
|
|
27
27
|
import argparse
|
|
28
|
+
import json
|
|
28
29
|
import os
|
|
29
30
|
import sys
|
|
30
31
|
import tempfile
|
|
31
32
|
from typing import List, Tuple
|
|
32
33
|
|
|
34
|
+
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
33
35
|
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
36
|
|
|
35
37
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -148,6 +150,123 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
148
150
|
return reencode
|
|
149
151
|
|
|
150
152
|
|
|
153
|
+
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
157
|
+
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
158
|
+
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
159
|
+
try:
|
|
160
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
161
|
+
doc = json.load(fh)
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
doc = None
|
|
164
|
+
if isinstance(doc, dict) and doc.get("beat_grid"):
|
|
165
|
+
grid = dict(doc["beat_grid"])
|
|
166
|
+
grid["beats"] = doc.get("beats") or []
|
|
167
|
+
# A scenes.py document carries the supported subset since 1.17; one written by an older
|
|
168
|
+
# build does not, and a grid whose supported points are unknown is not one this tool may
|
|
169
|
+
# move a cut onto -- an unknown subset is not an empty one, but it is not a measurement
|
|
170
|
+
# either, so it is refused rather than silently treated as "all of them".
|
|
171
|
+
grid["supported_beats"] = doc.get("beat_grid", {}).get("supported_beats")
|
|
172
|
+
if grid["supported_beats"] is None:
|
|
173
|
+
grid["supported_beats"] = doc.get("supported_beats")
|
|
174
|
+
try:
|
|
175
|
+
tempo = grid.get("tempo_bpm")
|
|
176
|
+
grid["tempo_bpm"] = float(tempo) if tempo is not None else None
|
|
177
|
+
grid["confidence"] = float(grid.get("confidence") or 0.0)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
die(f"--snap-source {path}: beat_grid.tempo_bpm and .confidence must be numbers "
|
|
180
|
+
"(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
|
|
181
|
+
if grid["beats"] and grid["tempo_bpm"] is None:
|
|
182
|
+
die(f"--snap-source {path}: this document lists beats but no tempo_bpm, so no grid "
|
|
183
|
+
"was actually measured in it. Regenerate it with "
|
|
184
|
+
"`scenes.py MUSIC --beats --json`.", kind="input")
|
|
185
|
+
grid["usable"] = grid["confidence"] >= min_confidence
|
|
186
|
+
return grid
|
|
187
|
+
if isinstance(doc, dict):
|
|
188
|
+
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
189
|
+
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
190
|
+
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
191
|
+
env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
|
|
192
|
+
return beat_grid(env, 0.01, min_confidence=min_confidence)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def snap_segments(args, segments, meta, total):
|
|
196
|
+
"""Move every in/out point to the nearest measured beat. Returns (result dict, segments).
|
|
197
|
+
|
|
198
|
+
A cut point may move to a measured, onset-supported grid point and may not appear from one:
|
|
199
|
+
the number of segments is unchanged, and nothing is ever proposed. The keyframe/tolerance
|
|
200
|
+
decision downstream then runs on the snapped values, which is the right order -- whether a cut
|
|
201
|
+
can be lossless depends on where it actually lands.
|
|
202
|
+
"""
|
|
203
|
+
source = args.snap_source or args.input
|
|
204
|
+
if not args.snap_source and not meta.get("audio"):
|
|
205
|
+
die("--snap beats needs audio to measure a beat in; this file has none. Cut without it "
|
|
206
|
+
"(--snap none), or pass --snap-source with the music bed.", kind="input")
|
|
207
|
+
# Compare the PARSED segments against the whole file, not the raw --start string: "0:00",
|
|
208
|
+
# "0.0" and "00:00:00" are all a zero start that a string comparison lets through, and the
|
|
209
|
+
# run would then snap the implicit end point and silently shorten a whole-file copy.
|
|
210
|
+
whole_file = (len(segments) == 1 and abs(segments[0][0]) < 1e-6
|
|
211
|
+
and (not total or abs(segments[0][1] - total) < 1e-6))
|
|
212
|
+
if whole_file:
|
|
213
|
+
die("--snap beats has no in or out point to move: this run copies the whole file. Give "
|
|
214
|
+
"--start/--end (or --segments), or drop --snap.", kind="input")
|
|
215
|
+
# A floor of zero would make the confidence check vacuous -- a grid measured from noise scores
|
|
216
|
+
# above 0.0 and would pass -- and the whole point of the flag is that a cut only moves onto a
|
|
217
|
+
# pulse somebody can hear. The number is a floor on belief, so it must be a positive one.
|
|
218
|
+
if args.min_confidence <= 0:
|
|
219
|
+
die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
|
|
220
|
+
"one measured from noise, which is exactly what --snap beats must not cut to. Use "
|
|
221
|
+
"--snap none if you do not want the points moved at all.", kind="input")
|
|
222
|
+
grid = _grid_from_source(source, args.min_confidence)
|
|
223
|
+
confidence = float(grid.get("confidence") or 0.0)
|
|
224
|
+
tempo = grid.get("tempo_bpm")
|
|
225
|
+
if confidence < args.min_confidence or not grid.get("beats"):
|
|
226
|
+
die(f"no reliable beat grid in this audio (confidence {confidence:.2f}, needs "
|
|
227
|
+
f"{args.min_confidence:.2f}): cutting to invented beats would move your in/out points "
|
|
228
|
+
"to times nothing in the audio supports. Re-run with --snap none, or pass "
|
|
229
|
+
"--snap-source from a music bed.", kind="input")
|
|
230
|
+
# THE grid a cut may move onto is the onset-supported subset, never the full regular grid.
|
|
231
|
+
# beat_grid() reports a regular grid over the whole duration by design -- a grid has to be
|
|
232
|
+
# regular -- so it runs on through a passage with no music in it. Snapping to one of those
|
|
233
|
+
# points moves a cut to a time nothing in the audio marks, which is the fabrication this
|
|
234
|
+
# release forbids and which this tool's own refusal text promises it does not do.
|
|
235
|
+
supported = grid.get("supported_beats")
|
|
236
|
+
if supported is None:
|
|
237
|
+
die(f"--snap-source {source}: this document does not say which grid points a measured "
|
|
238
|
+
"onset supports, so there is no way to tell a beat from a gap in it. Regenerate it "
|
|
239
|
+
"with `scenes.py MUSIC --beats --json`.", kind="input")
|
|
240
|
+
if not supported:
|
|
241
|
+
die(f"no measured onset supports any point of this beat grid (confidence "
|
|
242
|
+
f"{confidence:.2f}): the grid is regular but nothing in the audio marks it, so every "
|
|
243
|
+
"move would be to an invented time. Re-run with --snap none, or pass --snap-source "
|
|
244
|
+
"from a music bed.", kind="input")
|
|
245
|
+
points = [t for seg in segments for t in seg]
|
|
246
|
+
moved = snap_points(points, supported, args.snap_tolerance)
|
|
247
|
+
out_segments = []
|
|
248
|
+
for i in range(0, len(moved), 2):
|
|
249
|
+
s, e = moved[i]["to"], moved[i + 1]["to"]
|
|
250
|
+
if e <= s: # a snap that would collapse the segment is not applied to it
|
|
251
|
+
s, e = moved[i]["from"], moved[i + 1]["from"]
|
|
252
|
+
moved[i].update({"to": s, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
253
|
+
moved[i + 1].update({"to": e, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
254
|
+
out_segments.append((s, e))
|
|
255
|
+
snapped = sum(1 for m in moved if m["snapped"])
|
|
256
|
+
for m in moved:
|
|
257
|
+
if m["snapped"]:
|
|
258
|
+
info(f"--snap beats: {m['from']:.3f}s -> {m['to']:.3f}s ({m['delta'] * 1000:+.0f} ms)")
|
|
259
|
+
info(f"--snap beats: {tempo:.1f} BPM, confidence {confidence:.2f}; {snapped} of {len(moved)} "
|
|
260
|
+
f"point(s) moved, within {args.snap_tolerance:.3f}s, onto {len(supported)} of "
|
|
261
|
+
f"{len(grid['beats'])} grid point(s) a measured onset supports")
|
|
262
|
+
return ({"mode": "beats", "tolerance": args.snap_tolerance, "confidence": confidence,
|
|
263
|
+
"tempo_bpm": tempo, "grid": "supported", "grid_points": len(supported),
|
|
264
|
+
"moved": [dict(m) for m in moved], "snapped": snapped,
|
|
265
|
+
"unchanged": len(moved) - snapped,
|
|
266
|
+
"source": "measured" if not args.snap_source else args.snap_source},
|
|
267
|
+
out_segments)
|
|
268
|
+
|
|
269
|
+
|
|
151
270
|
def main() -> int:
|
|
152
271
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
153
272
|
ap.add_argument("input")
|
|
@@ -159,6 +278,17 @@ def main() -> int:
|
|
|
159
278
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
160
279
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
161
280
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
|
|
281
|
+
snap = ap.add_argument_group("beat snapping")
|
|
282
|
+
snap.add_argument("--snap", choices=["none", "beats"], default="none",
|
|
283
|
+
help="move each in/out point to the nearest measured beat (default none)")
|
|
284
|
+
snap.add_argument("--snap-tolerance", type=float, default=0.12,
|
|
285
|
+
help="most seconds a point may move with --snap beats (default 0.12, about a "
|
|
286
|
+
"quarter of a beat at 120 BPM)")
|
|
287
|
+
snap.add_argument("--snap-source", metavar="FILE",
|
|
288
|
+
help="take the beat grid from this scenes.py --beats --json document (or from "
|
|
289
|
+
"this media file) instead of measuring the input again")
|
|
290
|
+
snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
|
|
291
|
+
help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
|
|
162
292
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
163
293
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
|
|
164
294
|
add_common(ap)
|
|
@@ -196,6 +326,10 @@ def main() -> int:
|
|
|
196
326
|
die("end must be after start")
|
|
197
327
|
segments = [(start, end)]
|
|
198
328
|
|
|
329
|
+
snap_result = None
|
|
330
|
+
if args.snap == "beats":
|
|
331
|
+
snap_result, segments = snap_segments(args, segments, meta, total)
|
|
332
|
+
|
|
199
333
|
for s, e in segments:
|
|
200
334
|
if total and s >= total:
|
|
201
335
|
die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
|
|
@@ -249,7 +383,8 @@ def main() -> int:
|
|
|
249
383
|
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|
|
250
384
|
lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
|
|
251
385
|
f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
|
|
252
|
-
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None
|
|
386
|
+
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
|
|
387
|
+
snap=snap_result)
|
|
253
388
|
return 0
|
|
254
389
|
|
|
255
390
|
|