ffmpeg-skill 1.11.1 → 1.13.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 -9
- package/SKILL.md +27 -21
- package/docs/contract.md +43 -9
- package/package.json +1 -1
- package/references/gotchas.md +57 -7
- package/references/scripts.md +119 -10
- package/scripts/_common.py +378 -0
- package/scripts/_contract.py +60 -8
- package/scripts/audio.py +99 -5
- package/scripts/caption.py +449 -16
- package/scripts/check.py +15 -0
- package/scripts/graphics.py +18 -3
- package/scripts/loudness.py +3 -0
- package/scripts/overlay.py +9 -1
- package/scripts/render.py +69 -15
package/scripts/caption.py
CHANGED
|
@@ -28,15 +28,19 @@ Examples:
|
|
|
28
28
|
python3 caption.py input.mp4 --srt subs.srt --font "Noto Sans CJK JP" --size 28 --position top
|
|
29
29
|
python3 caption.py --text cues.txt --write-srt cues.srt # only produce the SRT
|
|
30
30
|
python3 caption.py input.mp4 --text cues.txt # generate + burn in one go
|
|
31
|
+
python3 caption.py input.mp4 --text cues_ko.txt --lang ko # a font that covers the script is picked automatically
|
|
32
|
+
python3 caption.py input.mp4 --srt subs.srt --offset -0.4 --max-lines 2 --min-duration 1.2
|
|
31
33
|
"""
|
|
32
34
|
import argparse
|
|
35
|
+
import json
|
|
33
36
|
import os
|
|
34
37
|
import re
|
|
35
38
|
import sys
|
|
39
|
+
import unicodedata
|
|
36
40
|
from pathlib import Path
|
|
37
41
|
from typing import List, Optional, Tuple
|
|
38
42
|
|
|
39
|
-
from _common import STATE, 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
|
|
43
|
+
from _common import STATE, brand_states_font, char_script, 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
|
|
40
44
|
|
|
41
45
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
42
46
|
|
|
@@ -293,6 +297,310 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
293
297
|
return out
|
|
294
298
|
|
|
295
299
|
|
|
300
|
+
# --------------------------------------------------------------------------- readable cues (1.12)
|
|
301
|
+
# Average advance width per character, in em (a fraction of the font size). Proportional Latin text
|
|
302
|
+
# averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
|
|
303
|
+
# Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
|
|
304
|
+
# real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
|
|
305
|
+
# shaping, while a cue wrapped from an average is right to within a character on every line.
|
|
306
|
+
# (Latin is measured per character from LATIN_EM below, not from this average.)
|
|
307
|
+
ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
|
|
308
|
+
"ru": 0.55, "el": 0.55, "latin": 0.55}
|
|
309
|
+
# How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
|
|
310
|
+
# 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
|
|
311
|
+
SAFE_WIDTH_FRACTION = 0.9
|
|
312
|
+
# Scripts written without spaces: a line breaks between any two characters.
|
|
313
|
+
NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
|
|
314
|
+
# Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
|
|
315
|
+
# enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
|
|
316
|
+
# against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
|
|
317
|
+
# the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
|
|
318
|
+
# past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
|
|
319
|
+
# un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
|
|
320
|
+
# 0.57 lowercase and anything else Latin-ish).
|
|
321
|
+
LATIN_EM = {
|
|
322
|
+
' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
|
|
323
|
+
'(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
|
|
324
|
+
'1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
|
|
325
|
+
'9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
|
|
326
|
+
'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
|
|
327
|
+
'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
|
|
328
|
+
'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
|
|
329
|
+
'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
|
|
330
|
+
'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
|
|
331
|
+
'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
|
|
332
|
+
'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
|
|
333
|
+
'{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
|
|
334
|
+
}
|
|
335
|
+
# Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
|
|
336
|
+
# between them and the base that follows.
|
|
337
|
+
LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _is_mark(ch: str) -> bool:
|
|
341
|
+
"""A character that hangs off the one before it: a combining mark (any script) or one of the
|
|
342
|
+
Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
|
|
343
|
+
return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _char_em(ch: str) -> float:
|
|
347
|
+
# CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
|
|
348
|
+
# full-width grid as the ideographs they sit between, even though they are not "Han" to a
|
|
349
|
+
# script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
|
|
350
|
+
cp = ord(ch)
|
|
351
|
+
# A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
|
|
352
|
+
# charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
|
|
353
|
+
if unicodedata.combining(ch) != 0 or unicodedata.category(ch) == "Mn":
|
|
354
|
+
return 0.0
|
|
355
|
+
if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
|
|
356
|
+
return 1.0
|
|
357
|
+
script = char_script(ch)
|
|
358
|
+
if script == "latin":
|
|
359
|
+
if ch in LATIN_EM:
|
|
360
|
+
return LATIN_EM[ch]
|
|
361
|
+
if ch.isupper() or ch.isdigit():
|
|
362
|
+
return 0.7
|
|
363
|
+
return 0.57
|
|
364
|
+
return ADVANCE_EM.get(script, 0.55)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def text_width_em(text: str) -> float:
|
|
368
|
+
"""Width of `text` in em, from the per-script average advance table."""
|
|
369
|
+
return sum(_char_em(ch) for ch in text)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _atoms(line: str) -> List[Tuple[str, bool]]:
|
|
373
|
+
"""Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
|
|
374
|
+
character, one per whitespace-delimited word otherwise -- each with whether a space stood
|
|
375
|
+
before it in the original. The flag is what puts the text back together exactly as written:
|
|
376
|
+
"Hello 世界" keeps its space, "世界です" gains none."""
|
|
377
|
+
out: List[Tuple[str, bool]] = []
|
|
378
|
+
word = ""
|
|
379
|
+
spaced = False # a space stands before the atom being built
|
|
380
|
+
pending = False # a space stands before the NEXT atom
|
|
381
|
+
attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
|
|
382
|
+
for ch in line:
|
|
383
|
+
if char_script(ch) in NO_SPACE_SCRIPTS:
|
|
384
|
+
if word:
|
|
385
|
+
out.append((word, spaced))
|
|
386
|
+
word = ""
|
|
387
|
+
if out and (attach_next or _is_mark(ch)):
|
|
388
|
+
# never break between a base and the mark (or the leading vowel) that belongs to
|
|
389
|
+
# it: the line would start with an orphaned tone mark or vowel sign
|
|
390
|
+
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
391
|
+
else:
|
|
392
|
+
out.append((ch, pending))
|
|
393
|
+
pending = False
|
|
394
|
+
attach_next = ord(ch) in LEADING_VOWELS
|
|
395
|
+
elif ch.isspace():
|
|
396
|
+
if word:
|
|
397
|
+
out.append((word, spaced))
|
|
398
|
+
word = ""
|
|
399
|
+
pending = True
|
|
400
|
+
else:
|
|
401
|
+
if not word:
|
|
402
|
+
spaced, pending = pending, False
|
|
403
|
+
word += ch
|
|
404
|
+
if word:
|
|
405
|
+
out.append((word, spaced))
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _join(left: str, atom: str, spaced: bool) -> str:
|
|
410
|
+
"""Put an atom back on a line, restoring the space that stood before it."""
|
|
411
|
+
if not left:
|
|
412
|
+
return atom
|
|
413
|
+
return left + (" " if spaced else "") + atom
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def wrap_text(text: str, max_em: float) -> List[str]:
|
|
417
|
+
"""Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
|
|
418
|
+
|
|
419
|
+
An atom wider than the whole line (one very long word) is left alone on its line rather than
|
|
420
|
+
cut mid-word: an over-long line is readable, a chopped word is not.
|
|
421
|
+
"""
|
|
422
|
+
lines: List[str] = []
|
|
423
|
+
for raw in text.split("\n"):
|
|
424
|
+
if not raw.strip():
|
|
425
|
+
continue
|
|
426
|
+
current = ""
|
|
427
|
+
for atom, spaced in _atoms(raw):
|
|
428
|
+
candidate = _join(current, atom, spaced)
|
|
429
|
+
if current and text_width_em(candidate) > max_em:
|
|
430
|
+
lines.append(current)
|
|
431
|
+
current = atom
|
|
432
|
+
else:
|
|
433
|
+
current = candidate
|
|
434
|
+
if current:
|
|
435
|
+
lines.append(current)
|
|
436
|
+
return lines or [text]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
|
|
440
|
+
min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
|
|
441
|
+
"""Shift, wrap, split and lengthen cues so they can actually be read.
|
|
442
|
+
|
|
443
|
+
`offset` moves every cue (a transcript that runs early/late); `max_em` wraps each cue to the
|
|
444
|
+
safe area at the chosen size (None when no video geometry is known, e.g. --write-srt alone);
|
|
445
|
+
a cue needing more than `max_lines` lines is split into consecutive cues sharing its time in
|
|
446
|
+
proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
|
|
447
|
+
cue's start. Returns the new cues and a count of what changed.
|
|
448
|
+
"""
|
|
449
|
+
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0}
|
|
450
|
+
staged: List[Tuple[float, float, str]] = []
|
|
451
|
+
for start, end, text in cues:
|
|
452
|
+
if offset:
|
|
453
|
+
start, end = start + offset, end + offset
|
|
454
|
+
if end <= 0:
|
|
455
|
+
stats["dropped"] += 1
|
|
456
|
+
continue
|
|
457
|
+
start = max(0.0, start)
|
|
458
|
+
stats["shifted"] += 1
|
|
459
|
+
if max_em and max_em > 0:
|
|
460
|
+
lines = wrap_text(text, max_em)
|
|
461
|
+
if lines != [l for l in text.split("\n") if l.strip()]:
|
|
462
|
+
stats["wrapped"] += 1
|
|
463
|
+
if len(lines) > max_lines:
|
|
464
|
+
chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
|
|
465
|
+
weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
|
|
466
|
+
total_w = sum(weights)
|
|
467
|
+
t = start
|
|
468
|
+
for chunk, weight in zip(chunks, weights):
|
|
469
|
+
seg = (end - start) * weight / total_w
|
|
470
|
+
staged.append((t, min(end, t + seg), "\n".join(chunk)))
|
|
471
|
+
t += seg
|
|
472
|
+
stats["split"] += len(chunks) - 1
|
|
473
|
+
continue
|
|
474
|
+
text = "\n".join(lines)
|
|
475
|
+
staged.append((start, end, text))
|
|
476
|
+
out: List[Tuple[float, float, str]] = []
|
|
477
|
+
for i, (start, end, text) in enumerate(staged):
|
|
478
|
+
if min_duration and end - start < min_duration:
|
|
479
|
+
limit = staged[i + 1][0] if i + 1 < len(staged) else None
|
|
480
|
+
new_end = start + min_duration if limit is None else min(start + min_duration, limit)
|
|
481
|
+
if new_end > end:
|
|
482
|
+
stats["extended"] += 1
|
|
483
|
+
end = new_end
|
|
484
|
+
out.append((start, end, text))
|
|
485
|
+
return out, stats
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def report_layout(stats: dict) -> None:
|
|
489
|
+
"""One info line, only when a cue actually changed."""
|
|
490
|
+
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "split", "extended", "dropped") if stats.get(k)]
|
|
491
|
+
if parts:
|
|
492
|
+
info("cues: " + ", ".join(parts))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|
|
496
|
+
"""How many em fit on one caption line at the chosen size, or None without video geometry.
|
|
497
|
+
|
|
498
|
+
--size is in ASS points against a 288-line script (what libass's force_style uses), so the
|
|
499
|
+
rendered pixel size is size * play_h / 288.
|
|
500
|
+
"""
|
|
501
|
+
if not play_w or not play_h or not args.size:
|
|
502
|
+
return None
|
|
503
|
+
size_px = args.size * play_h / 288.0
|
|
504
|
+
if size_px <= 0:
|
|
505
|
+
return None
|
|
506
|
+
return (play_w * SAFE_WIDTH_FRACTION) / size_px
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def parse_ass_dialogue(path: str) -> str:
|
|
510
|
+
"""The spoken text of an ASS file, for script detection -- style/override blocks stripped."""
|
|
511
|
+
text = []
|
|
512
|
+
for line in read_text_or_die(path, "--ass").lstrip("\ufeff").splitlines():
|
|
513
|
+
if not line.startswith("Dialogue:"):
|
|
514
|
+
continue
|
|
515
|
+
fields = line.split(",", 9)
|
|
516
|
+
if len(fields) == 10:
|
|
517
|
+
text.append(re.sub(r"\{[^}]*\}", "", fields[9]))
|
|
518
|
+
return "\n".join(text)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def shift_ass_file(src: str, dst: str, offset: float) -> int:
|
|
522
|
+
"""Copy an ASS file with every Dialogue start/end moved by `offset` seconds."""
|
|
523
|
+
def shift(stamp: str) -> str:
|
|
524
|
+
h, m, rest = stamp.split(":")
|
|
525
|
+
secs = int(h) * 3600 + int(m) * 60 + float(rest) + offset
|
|
526
|
+
secs = max(0.0, secs)
|
|
527
|
+
cs = int(round(secs * 100))
|
|
528
|
+
hh, rem = divmod(cs, 360000)
|
|
529
|
+
mm, rem = divmod(rem, 6000)
|
|
530
|
+
ss, cc = divmod(rem, 100)
|
|
531
|
+
return f"{hh}:{mm:02d}:{ss:02d}.{cc:02d}"
|
|
532
|
+
|
|
533
|
+
n = 0
|
|
534
|
+
out = []
|
|
535
|
+
for line in Path(src).read_text(encoding="utf-8-sig").splitlines():
|
|
536
|
+
if line.startswith("Dialogue:"):
|
|
537
|
+
head, sep, rest = line.partition(":")
|
|
538
|
+
fields = rest.split(",")
|
|
539
|
+
if len(fields) >= 3:
|
|
540
|
+
try:
|
|
541
|
+
fields[1], fields[2] = shift(fields[1].strip()), shift(fields[2].strip())
|
|
542
|
+
line = head + sep + ",".join(fields)
|
|
543
|
+
n += 1
|
|
544
|
+
except (ValueError, IndexError):
|
|
545
|
+
pass
|
|
546
|
+
out.append(line)
|
|
547
|
+
Path(dst).write_text("\n".join(out) + "\n", encoding="utf-8-sig")
|
|
548
|
+
return n
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
|
|
552
|
+
"""Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
|
|
553
|
+
|
|
554
|
+
whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
|
|
555
|
+
times; when they are there, --karaoke should follow the real speech instead of splitting the
|
|
556
|
+
cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
|
|
557
|
+
{"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
|
|
558
|
+
{"words": [...]} shape. Anything unreadable is simply "no word timings".
|
|
559
|
+
"""
|
|
560
|
+
if not srt_path:
|
|
561
|
+
return []
|
|
562
|
+
stem = os.path.splitext(srt_path)[0]
|
|
563
|
+
for cand in (stem + ".words.json", stem + ".json"):
|
|
564
|
+
if not os.path.exists(cand):
|
|
565
|
+
continue
|
|
566
|
+
try:
|
|
567
|
+
data = json.loads(Path(cand).read_text(encoding="utf-8"))
|
|
568
|
+
except (OSError, ValueError):
|
|
569
|
+
continue
|
|
570
|
+
raw = []
|
|
571
|
+
if isinstance(data, dict):
|
|
572
|
+
raw = list(data.get("words") or [])
|
|
573
|
+
for seg in data.get("segments") or []:
|
|
574
|
+
raw.extend((seg or {}).get("words") or [])
|
|
575
|
+
words = []
|
|
576
|
+
for w in raw:
|
|
577
|
+
try:
|
|
578
|
+
text = str(w.get("word") or w.get("text") or "").strip()
|
|
579
|
+
if text:
|
|
580
|
+
words.append((float(w["start"]), float(w["end"]), text))
|
|
581
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
582
|
+
continue
|
|
583
|
+
if words:
|
|
584
|
+
info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
|
|
585
|
+
return sorted(words)
|
|
586
|
+
return []
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
590
|
+
n_words: int) -> Optional[List[int]]:
|
|
591
|
+
"""Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
|
|
592
|
+
inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
|
|
593
|
+
if len(inside) != n_words or n_words <= 0:
|
|
594
|
+
return None
|
|
595
|
+
total_cs = max(1, int(round((end - start) * 100)))
|
|
596
|
+
bounds = [max(start, inside[0][0])] + [max(start, min(end, w[1])) for w in inside]
|
|
597
|
+
out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
|
|
598
|
+
out[-1] += total_cs - sum(out)
|
|
599
|
+
if out[-1] < 1:
|
|
600
|
+
return None
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
|
|
296
604
|
def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
|
|
297
605
|
"""Write a styled ASS file with optional animation and word-by-word highlight."""
|
|
298
606
|
def t(sec: float) -> str:
|
|
@@ -341,11 +649,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
341
649
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
342
650
|
segments = body.split("\\N")
|
|
343
651
|
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
652
|
+
# real word timings from the transcript beat both the energy estimate and the even
|
|
653
|
+
# split -- they are what the speaker actually did, not a proxy for it
|
|
654
|
+
durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
|
|
655
|
+
if durs is None:
|
|
656
|
+
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
657
|
+
durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
|
|
658
|
+
else:
|
|
659
|
+
per = max(1, dur_cs // max(1, len(words)))
|
|
660
|
+
durs = [per] * len(words)
|
|
349
661
|
it = iter(durs)
|
|
350
662
|
out_segments = []
|
|
351
663
|
for seg in segments:
|
|
@@ -391,6 +703,23 @@ def ass_font_name(name: str) -> str:
|
|
|
391
703
|
return name
|
|
392
704
|
|
|
393
705
|
|
|
706
|
+
def _glue_negative_offset(argv: List[str]) -> List[str]:
|
|
707
|
+
"""`--offset -0:00:02` reads as a flag to argparse, not a value: only bare negative NUMBERS
|
|
708
|
+
are exempt from the "starts with -" rule, and a negative timecode is not one. Join the pair
|
|
709
|
+
into `--offset=-0:00:02` so the documented grammar works in the shape people type it."""
|
|
710
|
+
out: List[str] = []
|
|
711
|
+
i = 0
|
|
712
|
+
while i < len(argv):
|
|
713
|
+
nxt = argv[i + 1] if i + 1 < len(argv) else ""
|
|
714
|
+
if argv[i] == "--offset" and nxt.startswith("-") and not nxt.startswith("--"):
|
|
715
|
+
out.append(f"--offset={nxt}")
|
|
716
|
+
i += 2
|
|
717
|
+
continue
|
|
718
|
+
out.append(argv[i])
|
|
719
|
+
i += 1
|
|
720
|
+
return out
|
|
721
|
+
|
|
722
|
+
|
|
394
723
|
def main() -> int:
|
|
395
724
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
396
725
|
ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
|
|
@@ -407,7 +736,11 @@ def main() -> int:
|
|
|
407
736
|
src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
|
|
408
737
|
src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
|
|
409
738
|
src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
|
|
410
|
-
src.add_argument("--language", help="language code
|
|
739
|
+
src.add_argument("--language", "--lang", help="language code (e.g. en, ja, zh, ko): the language for --transcribe (default auto), "
|
|
740
|
+
"the tag on the subtitle stream with --mode mux, and the hint that says whether Han-only "
|
|
741
|
+
"text is Chinese, Japanese or Korean when a font is picked by script")
|
|
742
|
+
src.add_argument("--offset", default="0", help="shift every cue by TIME (seconds, mm:ss, hh:mm:ss.ms or "
|
|
743
|
+
"hh:mm:ss:ff; a leading - shifts earlier); works for --text, --srt and --ass")
|
|
411
744
|
src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
|
|
412
745
|
src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
|
|
413
746
|
src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
|
|
@@ -428,6 +761,8 @@ def main() -> int:
|
|
|
428
761
|
sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
|
|
429
762
|
sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
|
|
430
763
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
764
|
+
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)")
|
|
765
|
+
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)")
|
|
431
766
|
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
432
767
|
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=None, help="per-cue entrance animation (default none, or brand caption.animate)")
|
|
433
768
|
anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
|
|
@@ -439,14 +774,21 @@ def main() -> int:
|
|
|
439
774
|
enc.add_argument("--crf", type=int, default=18)
|
|
440
775
|
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
441
776
|
add_common(ap)
|
|
442
|
-
args = ap.parse_args()
|
|
777
|
+
args = ap.parse_args(_glue_negative_offset(sys.argv[1:]))
|
|
443
778
|
apply_common(args)
|
|
444
779
|
|
|
445
780
|
brand = load_brand(args.brand)
|
|
446
|
-
bc, bcap = brand["colors"], brand
|
|
447
|
-
|
|
781
|
+
bc, bcap = brand["colors"], brand_caption_style(brand)
|
|
782
|
+
# A brand file that never names a font is not an explicit font: BRAND_DEFAULTS always
|
|
783
|
+
# supplies one, so asking the merged document would turn font-by-script off for every job
|
|
784
|
+
# that passes --brand at all.
|
|
785
|
+
font_explicit = bool(args.font) or bool(args.brand and brand_states_font(brand))
|
|
786
|
+
args.language = args.language or (brand.get("lang") if args.brand else None)
|
|
787
|
+
if args.brand and bcap.get("box") and not args.box:
|
|
788
|
+
args.box = True
|
|
789
|
+
args.font = args.font or (bcap.get("font") if args.brand else None) or brand.get("font") or "DejaVu Sans"
|
|
448
790
|
args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
|
|
449
|
-
args.color = color_hex(args.color or bc.get("text", "FFFFFF"))
|
|
791
|
+
args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
|
|
450
792
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
451
793
|
args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
|
|
452
794
|
args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
|
|
@@ -469,6 +811,12 @@ def main() -> int:
|
|
|
469
811
|
if args.animate != "none" or args.karaoke:
|
|
470
812
|
die("--animate/--karaoke render pixels into the picture and require --mode burn")
|
|
471
813
|
|
|
814
|
+
args.offset = signed_time_arg(str(args.offset), "--offset")
|
|
815
|
+
if args.max_lines < 1:
|
|
816
|
+
die("--max-lines must be at least 1")
|
|
817
|
+
if args.min_duration < 0:
|
|
818
|
+
die("--min-duration cannot be negative")
|
|
819
|
+
|
|
472
820
|
meta = None
|
|
473
821
|
if args.input:
|
|
474
822
|
meta = probe(args.input)
|
|
@@ -483,6 +831,22 @@ def main() -> int:
|
|
|
483
831
|
if fps_for_tc is None and meta is not None:
|
|
484
832
|
fps_for_tc = meta.get("video", {}).get("fps")
|
|
485
833
|
|
|
834
|
+
play_w = play_h = None
|
|
835
|
+
if meta and meta.get("video"):
|
|
836
|
+
play_w, play_h = meta["video"]["width"], meta["video"]["height"]
|
|
837
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
838
|
+
play_w, play_h = play_h, play_w
|
|
839
|
+
|
|
840
|
+
def lay_out(cue_list):
|
|
841
|
+
"""Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
|
|
842
|
+
--offset -- the one place every cue source goes through, so an SRT, a cue file and a
|
|
843
|
+
transcript all come out equally readable."""
|
|
844
|
+
out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
|
|
845
|
+
max_lines=args.max_lines, min_duration=args.min_duration,
|
|
846
|
+
offset=args.offset)
|
|
847
|
+
report_layout(stats)
|
|
848
|
+
return out, any(stats.values())
|
|
849
|
+
|
|
486
850
|
srt_path = args.srt
|
|
487
851
|
if args.transcribe:
|
|
488
852
|
if not args.input:
|
|
@@ -497,10 +861,15 @@ def main() -> int:
|
|
|
497
861
|
if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
|
|
498
862
|
info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
|
|
499
863
|
cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
|
|
864
|
+
args._word_timings = whisper_word_timings(srt_path)
|
|
865
|
+
cues, changed = lay_out(cues)
|
|
866
|
+
if changed:
|
|
867
|
+
write_srt(cues, srt_path)
|
|
500
868
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
501
869
|
args.text = None
|
|
502
870
|
if args.text:
|
|
503
871
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
|
|
872
|
+
cues, _ = lay_out(cues)
|
|
504
873
|
if args.write_srt:
|
|
505
874
|
srt_path = args.write_srt
|
|
506
875
|
elif args.input:
|
|
@@ -522,8 +891,48 @@ def main() -> int:
|
|
|
522
891
|
|
|
523
892
|
output = args.output or default_output(args.input, "captioned")
|
|
524
893
|
|
|
894
|
+
# An SRT or ASS the caller wrote is never edited in place: when --offset/--max-lines/
|
|
895
|
+
# --min-duration change it, the adjusted copy is written next to the output and burned instead.
|
|
896
|
+
# The path is repointed in BOTH modes: --dry-run/--plan must describe the job the real run
|
|
897
|
+
# executes, so the planned command names the adjusted copy the real run burns. Only the
|
|
898
|
+
# WRITING waits for a real run (the rule every side file in this tool follows), which is why
|
|
899
|
+
# the cues are kept in hand below for the font sample and the ASS generator.
|
|
900
|
+
planned_cues: Optional[List[Tuple[float, float, str]]] = None
|
|
901
|
+
side_notes: List[str] = []
|
|
902
|
+
ass_sample_path = args.ass
|
|
903
|
+
if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
|
|
904
|
+
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
905
|
+
if changed:
|
|
906
|
+
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
907
|
+
if STATE.dry_run:
|
|
908
|
+
info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
909
|
+
else:
|
|
910
|
+
write_srt(adjusted, new_srt)
|
|
911
|
+
info(f"wrote {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
912
|
+
srt_path = new_srt
|
|
913
|
+
planned_cues = adjusted
|
|
914
|
+
side_notes.append(f"the burned subtitles are {new_srt}, the adjusted copy of "
|
|
915
|
+
f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration); "
|
|
916
|
+
"re-run this command without --dry-run to produce it")
|
|
917
|
+
if args.ass and args.offset and os.path.exists(args.ass):
|
|
918
|
+
shifted = os.path.splitext(output)[0] + "_offset.ass"
|
|
919
|
+
if STATE.dry_run:
|
|
920
|
+
info(f"[dry-run] would write {shifted} (cues shifted by {args.offset:+g} s)")
|
|
921
|
+
else:
|
|
922
|
+
n = shift_ass_file(args.ass, shifted, args.offset)
|
|
923
|
+
info(f"wrote {shifted} ({n} cues shifted by {args.offset:+g} s)")
|
|
924
|
+
args.ass = shifted
|
|
925
|
+
side_notes.append(f"the burned subtitles are {shifted}, the offset copy of "
|
|
926
|
+
f"{os.path.basename(ass_sample_path)} this run writes; re-run this command "
|
|
927
|
+
"without --dry-run to produce it")
|
|
928
|
+
# a side file this run has planned but (under --dry-run) not written is still the file the
|
|
929
|
+
# command names, so its absence must not be reported as a missing input
|
|
930
|
+
planned_only = STATE.dry_run and planned_cues is not None
|
|
931
|
+
planned_ass = STATE.dry_run and bool(args.ass) and args.ass != ass_sample_path
|
|
932
|
+
|
|
525
933
|
if args.mode == "mux":
|
|
526
|
-
if not srt_path or (not os.path.exists(srt_path) and not
|
|
934
|
+
if not srt_path or (not os.path.exists(srt_path) and not planned_only
|
|
935
|
+
and not (STATE.dry_run and (args.text or args.transcribe))):
|
|
527
936
|
die(f"SRT file not found: {srt_path}")
|
|
528
937
|
codec = mux_subtitle_codec(output)
|
|
529
938
|
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
@@ -550,8 +959,31 @@ def main() -> int:
|
|
|
550
959
|
emit(output)
|
|
551
960
|
return 0
|
|
552
961
|
|
|
962
|
+
# A font that covers the text, before anything is rendered: non-Latin cues in a Latin-only
|
|
963
|
+
# family come out as empty boxes, and ffmpeg exits 0 all the same (see references/gotchas.md).
|
|
964
|
+
if args.ass:
|
|
965
|
+
sample = parse_ass_dialogue(ass_sample_path) if ass_sample_path and os.path.exists(ass_sample_path) else ""
|
|
966
|
+
elif args.text or args.transcribe:
|
|
967
|
+
sample = "\n".join(t for _, _, t in cues)
|
|
968
|
+
elif planned_cues is not None:
|
|
969
|
+
sample = "\n".join(t for _, _, t in planned_cues)
|
|
970
|
+
else:
|
|
971
|
+
sample = "\n".join(t for _, _, t in parse_srt(srt_path)) if os.path.exists(srt_path or "") else ""
|
|
972
|
+
_script, font_file, font_family = script_font_for_text(
|
|
973
|
+
sample, lang=args.language, font=args.font, font_explicit=font_explicit,
|
|
974
|
+
fonts_dir=args.fonts_dir)
|
|
975
|
+
if font_file:
|
|
976
|
+
args.font = font_family or args.font
|
|
977
|
+
if not args.fonts_dir:
|
|
978
|
+
args.fonts_dir = os.path.dirname(font_file)
|
|
979
|
+
|
|
553
980
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
554
|
-
|
|
981
|
+
# both sources are already laid out: `cues` above, and srt_path was rewritten in place of
|
|
982
|
+
# the caller's file when --offset/--max-lines/--min-duration changed anything
|
|
983
|
+
cues_for_ass = cues if (args.text or args.transcribe) else (
|
|
984
|
+
planned_cues if planned_cues is not None else parse_srt(srt_path))
|
|
985
|
+
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
986
|
+
args._word_timings = whisper_word_timings(srt_path)
|
|
555
987
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
556
988
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
557
989
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
@@ -565,13 +997,14 @@ def main() -> int:
|
|
|
565
997
|
generated_ass = False
|
|
566
998
|
|
|
567
999
|
if args.ass:
|
|
568
|
-
if not generated_ass and not os.path.exists(args.ass):
|
|
1000
|
+
if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
|
|
569
1001
|
die(f"ASS file not found: {args.ass}")
|
|
570
1002
|
vf = f"ass={escape_filter_path(args.ass)}"
|
|
571
1003
|
if args.fonts_dir:
|
|
572
1004
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
573
1005
|
else:
|
|
574
|
-
if not srt_path or (not os.path.exists(srt_path) and not
|
|
1006
|
+
if not srt_path or (not os.path.exists(srt_path) and not planned_only
|
|
1007
|
+
and not (STATE.dry_run and (args.text or args.transcribe))):
|
|
575
1008
|
die(f"SRT file not found: {srt_path}")
|
|
576
1009
|
style = [
|
|
577
1010
|
f"FontName={ass_font_name(args.font)}",
|
|
@@ -599,7 +1032,7 @@ def main() -> int:
|
|
|
599
1032
|
run(cmd)
|
|
600
1033
|
result = probe(output, role="output")
|
|
601
1034
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
602
|
-
emit(output)
|
|
1035
|
+
emit(output, **({"notes": side_notes} if side_notes else {}))
|
|
603
1036
|
return 0
|
|
604
1037
|
|
|
605
1038
|
|
package/scripts/check.py
CHANGED
|
@@ -162,6 +162,21 @@ def main() -> int:
|
|
|
162
162
|
|
|
163
163
|
if a:
|
|
164
164
|
row("audio", "PASS", f"{a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz", "present")
|
|
165
|
+
if args.platform == "podcast":
|
|
166
|
+
# Podcast rows, informational: neither can fail a delivery, both are things a
|
|
167
|
+
# publisher notices after the fact. A 5.1 podcast master is the common one -- every
|
|
168
|
+
# player downmixes it, none of them the same way, and the centre-heavy dialogue
|
|
169
|
+
# comes back at a level nobody checked.
|
|
170
|
+
ch = a.get("channels") or 0
|
|
171
|
+
row("channels", "PASS" if ch in (1, 2) else "WARN", f"{ch}ch", "1 (mono) or 2 (stereo)",
|
|
172
|
+
"audio.py --downmix (5.1/7.1 to stereo with the standard weights) or audio.py --mono",
|
|
173
|
+
reason="podcast players downmix 5.1 unpredictably")
|
|
174
|
+
if args.platform == "podcast":
|
|
175
|
+
chapters = meta.get("chapters") or []
|
|
176
|
+
row("chapters", "PASS" if chapters else "WARN", f"{len(chapters)}" if chapters else "none", ">= 1 chapter marker",
|
|
177
|
+
"metadata.py episode.m4a --chapters chapters.txt (`TIME TITLE` per line; streams copied)",
|
|
178
|
+
reason="chapter markers are optional, but a podcast app shows them as the episode's seekable table of contents")
|
|
179
|
+
if a:
|
|
165
180
|
if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
|
|
166
181
|
row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
|
|
167
182
|
if not args.no_loudness and spec["lufs"] is not None:
|