68c12caef9
Replaces the scattered TypeScript/shell helpers with one Python toolchain that runs as plain files (`python3 scripts/tools.py <tool> ...`) with no third-party dependencies. New tools - `_common.py` shared plumbing (Report, run, ffmpeg discovery, argparse ergonomics) - `hwprobe.py` what this machine can actually encode (software + hardware) - `srt2ass.py` SRT -> ASS, the missing step between lrc2srt and baking - `ffmpeg_tools.py` mux / bake / still / strip / probe, with hardware encode - `bom_gen.py` BOM generator, ported from scripts/bom-gen.ts - `tools.py` dispatcher so any tool is one command away - `README.md` usage for all of the above Rewritten - `lrc2srt.py` now handles multiple timestamps per line, timestamp-less cue lines and blank/comment lines, and survives malformed input. The legacy version emitted an empty cue plus a stray cue for every line. Hardware encoding This host is AMD (Strix Halo, VCN 4.0.5), so nvenc in the legacy notes is reference only. `hwprobe.py` probes and reports what genuinely works here: VA-API via /dev/dri/renderD128 gives h264_vaapi, hevc_vaapi and av1_vaapi. `ffmpeg_tools.py --video auto` picks the hardware encoder when the render node is usable and falls back to libx264/libx265 when it is not. Two bugs found and fixed while wiring this up: - the VA-API capability probe fed frames straight to the encoder without `format=nv12,hwupload`, so ffmpeg failed with "Function not implemented" and the toolchain silently fell back to software on a machine where hardware works. VA-API encoders only accept GPU-resident frames; the probe now uploads. - `bake --video copy` only failed when the copied stream happened to be VA-API. Burning subtitles always changes pixels, so it now refuses `copy` outright with a clear message. Also makes -q/-n usable on either side of the mode word, and ignores __pycache__ in .gitignore.
383 lines
11 KiB
Python
383 lines
11 KiB
Python
"""Convert an SRT subtitle file to ASS (Advanced SubStation Alpha).
|
|
|
|
Stdlib only. Handles the constructs that actually appear in song subtitles:
|
|
multi-line cues, ``{\\an8}``-style override blocks passed through untouched,
|
|
and the SRT quirks (CRLF, BOM, cue numbers, blank-line separators).
|
|
|
|
Usage::
|
|
|
|
python3 scripts/srt2ass.py song.srt song.ass
|
|
python3 scripts/srt2ass.py lyrics/*.srt --out-dir build/ # batch
|
|
python3 scripts/srt2ass.py song.srt --style-font "Noto Sans" --style-size 64
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
|
|
if __package__ in (None, ""):
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _common import ( # type: ignore[import-not-found]
|
|
Die,
|
|
Report,
|
|
add_common_arguments,
|
|
glob_files,
|
|
main_wrapper,
|
|
)
|
|
else: # pragma: no cover
|
|
from ._common import (
|
|
Die,
|
|
Report,
|
|
add_common_arguments,
|
|
glob_files,
|
|
main_wrapper,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Timestamps
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_SRT_TIME_RE = re.compile(
|
|
r"(?P<h>\d{1,2}):(?P<m>\d{2}):(?P<s>\d{2})[,.](?P<ms>\d{1,3})"
|
|
)
|
|
|
|
|
|
def parse_srt_time(stamp: str) -> float:
|
|
"""``00:01:02,500`` (or ``00:01:02.5``) to seconds."""
|
|
match = _SRT_TIME_RE.search(stamp.strip())
|
|
if not match:
|
|
raise Die(f"unparseable SRT timestamp: {stamp!r}")
|
|
|
|
ms = match.group("ms").ljust(3, "0")[:3]
|
|
|
|
return (
|
|
int(match.group("h")) * 3600
|
|
+ int(match.group("m")) * 60
|
|
+ int(match.group("s"))
|
|
+ int(ms) / 1000
|
|
)
|
|
|
|
|
|
def format_ass_time(seconds: float) -> str:
|
|
"""Seconds to ASS ``H:MM:SS.cc`` (centiseconds, single-digit hour)."""
|
|
if seconds < 0:
|
|
seconds = 0.0
|
|
|
|
total_cs = int(round(seconds * 100))
|
|
hours, remainder = divmod(total_cs, 360_000)
|
|
minutes, remainder = divmod(remainder, 6_000)
|
|
secs, centis = divmod(remainder, 100)
|
|
|
|
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Parsing
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@dataclass
|
|
class Cue:
|
|
index: int
|
|
start: float
|
|
end: float
|
|
lines: list[str]
|
|
|
|
def text(self, line_sep: str = r"\N") -> str:
|
|
return line_sep.join(self.lines)
|
|
|
|
|
|
def strip_bom(text: str) -> str:
|
|
return text[1:] if text.startswith("\ufeff") else text
|
|
|
|
|
|
def parse_srt(content: str) -> list[Cue]:
|
|
"""Parse SRT text into cues. Tolerates CRLF, BOM, and missing cue numbers."""
|
|
content = strip_bom(content).replace("\r\n", "\n").replace("\r", "\n")
|
|
blocks = re.split(r"\n\s*\n", content)
|
|
|
|
cues: list[Cue] = []
|
|
auto_index = 0
|
|
|
|
for block in blocks:
|
|
lines = block.strip("\n").split("\n")
|
|
if not lines or not any(line.strip() for line in lines):
|
|
continue
|
|
|
|
index: int | None = None
|
|
if lines[0].strip().isdigit():
|
|
index = int(lines[0].strip())
|
|
lines = lines[1:]
|
|
|
|
if not lines:
|
|
continue
|
|
|
|
if "-->" not in lines[0]:
|
|
# Not a cue (stray header, WEBVTT marker, ...). Skip it quietly.
|
|
continue
|
|
|
|
start_raw, _, end_raw = lines[0].partition("-->")
|
|
start = parse_srt_time(start_raw)
|
|
end = parse_srt_time(end_raw)
|
|
|
|
body = [line for line in lines[1:] if line.strip()]
|
|
if not body:
|
|
# A cue with a timestamp but no text: pure gap in the lyrics. Emitting
|
|
# it would produce an empty subtitle block, so drop it — the previous
|
|
# cue simply holds until the next one.
|
|
continue
|
|
|
|
auto_index += 1
|
|
cues.append(Cue(index=index or auto_index, start=start, end=end, lines=body))
|
|
|
|
return cues
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ASS output
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
#: ASS uses US-ASCII-with-code-page-1252 in practice; karaoke/Japanese needs 1.
|
|
ASS_CODEPAGES = {
|
|
"ascii": 0,
|
|
"latin1": 0,
|
|
"cp1252": 0,
|
|
"shift_jis": 1,
|
|
"sjis": 1,
|
|
"gbk": 134,
|
|
"utf-8": 1,
|
|
"utf8": 1,
|
|
}
|
|
|
|
ASS_HEADER = """[Script Info]
|
|
; Generated by scripts/srt2ass.py — edit the source SRT, not this file.
|
|
Title: {title}
|
|
ScriptType: v4.00+
|
|
WrapStyle: {wrap_style}
|
|
ScaledBorderAndShadow: yes
|
|
YCbCr Matrix: TV.709
|
|
PlayResX: {width}
|
|
PlayResY: {height}
|
|
|
|
[V4+ Styles]
|
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
Style: Default,{font},{size},{primary},{secondary},{outline_colour},{back},{bold},{italic},0,0,100,100,{spacing},0,1,{border},{shadow},{alignment},{margin_l},{margin_r},{margin_v},{encoding}
|
|
|
|
[Events]
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class AssStyle:
|
|
"""The subset of the Default style we expose on the CLI."""
|
|
|
|
font: str = "Arial"
|
|
size: int = 48
|
|
primary: str = "&H00FFFFFF" # white, fully opaque
|
|
secondary: str = "&H000000FF"
|
|
outline_colour: str = "&H00000000"
|
|
back: str = "&H00000000"
|
|
bold: int = -1
|
|
italic: int = 0
|
|
spacing: float = 0.0
|
|
border: float = 2.0
|
|
shadow: float = 1.0
|
|
alignment: int = 2 # 2 = bottom centre
|
|
margin_l: int = 40
|
|
margin_r: int = 40
|
|
margin_v: int = 60
|
|
encoding: int = 1
|
|
|
|
|
|
def render_ass(
|
|
cues: list[Cue],
|
|
*,
|
|
style: AssStyle,
|
|
width: int,
|
|
height: int,
|
|
title: str,
|
|
wrap_style: int = 0,
|
|
) -> str:
|
|
header = ASS_HEADER.format(
|
|
title=title.replace("\n", " ") or "Untitled",
|
|
wrap_style=wrap_style,
|
|
width=width,
|
|
height=height,
|
|
font=style.font,
|
|
size=style.size,
|
|
primary=style.primary,
|
|
secondary=style.secondary,
|
|
outline_colour=style.outline_colour,
|
|
back=style.back,
|
|
bold=style.bold,
|
|
italic=style.italic,
|
|
spacing=style.spacing,
|
|
border=style.border,
|
|
shadow=style.shadow,
|
|
alignment=style.alignment,
|
|
margin_l=style.margin_l,
|
|
margin_r=style.margin_r,
|
|
margin_v=style.margin_v,
|
|
encoding=style.encoding,
|
|
)
|
|
|
|
events = []
|
|
for cue in cues:
|
|
text = cue.text()
|
|
# ASS treats commas as field separators in the Text column, but only the
|
|
# first 9 commas matter — ffmpeg/libass handles commas inside Text fine as
|
|
# long as the Format line lists Text last, which it does above.
|
|
events.append(
|
|
"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}".format(
|
|
start=format_ass_time(cue.start),
|
|
end=format_ass_time(cue.end),
|
|
text=text,
|
|
)
|
|
)
|
|
|
|
return header + "\n".join(events) + "\n"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# CLI
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="srt2ass",
|
|
description="Convert SRT subtitles to ASS, ready for ffmpeg's ass= filter.",
|
|
epilog=(
|
|
"Then bake with: ffmpeg -vf \"ass=out.ass\" -i in.mp4 ... "
|
|
"(or scripts/ffmpeg_tools.py bake)"
|
|
),
|
|
)
|
|
add_common_arguments(parser)
|
|
|
|
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="SRT file(s) to convert")
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
metavar="FILE",
|
|
help="output .ass path (single input only; default: alongside the input)",
|
|
)
|
|
parser.add_argument(
|
|
"--out-dir",
|
|
metavar="DIR",
|
|
help="write every output into this directory (created if missing)",
|
|
)
|
|
|
|
style = parser.add_argument_group("style")
|
|
style.add_argument("--style-font", default="Arial", help="font name (default: Arial)")
|
|
style.add_argument("--style-size", type=int, default=48, help="font size (default: 48)")
|
|
style.add_argument(
|
|
"--style-alignment",
|
|
type=int,
|
|
default=2,
|
|
choices=range(1, 10),
|
|
metavar="N",
|
|
help="ASS numpad alignment 1-9 (default: 2, bottom centre)",
|
|
)
|
|
style.add_argument("--style-margin-v", type=int, default=60, help="vertical margin (default: 60)")
|
|
style.add_argument("--style-outline", type=float, default=2.0, help="outline width (default: 2)")
|
|
style.add_argument("--style-shadow", type=float, default=1.0, help="shadow depth (default: 1)")
|
|
style.add_argument("--style-bold", action=argparse.BooleanOptionalAction, default=True)
|
|
style.add_argument("--style-italic", action=argparse.BooleanOptionalAction, default=False)
|
|
|
|
canvas = parser.add_argument_group("canvas")
|
|
canvas.add_argument("--width", type=int, default=1920, help="PlayResX (default: 1920)")
|
|
canvas.add_argument("--height", type=int, default=1080, help="PlayResY (default: 1080)")
|
|
canvas.add_argument(
|
|
"--title",
|
|
default=None,
|
|
help="ASS Title field (default: the output filename)",
|
|
)
|
|
|
|
return parser
|
|
|
|
|
|
def convert_one(src: str, dest: str, args: argparse.Namespace, report: Report) -> tuple[int, int]:
|
|
try:
|
|
with open(src, "r", encoding="utf-8-sig", errors="replace") as handle:
|
|
content = handle.read()
|
|
except OSError as exc:
|
|
raise Die(f"cannot read {src}: {exc.strerror}") from exc
|
|
|
|
cues = parse_srt(content)
|
|
if not cues:
|
|
report.warn(f"{src}: no cues parsed — is this really an SRT file?")
|
|
|
|
style = AssStyle(
|
|
font=args.style_font,
|
|
size=args.style_size,
|
|
alignment=args.style_alignment,
|
|
margin_v=args.style_margin_v,
|
|
border=args.style_outline,
|
|
shadow=args.style_shadow,
|
|
bold=-1 if args.style_bold else 0,
|
|
italic=-1 if args.style_italic else 0,
|
|
)
|
|
|
|
title = args.title or os.path.splitext(os.path.basename(dest))[0]
|
|
document = render_ass(
|
|
cues,
|
|
style=style,
|
|
width=args.width,
|
|
height=args.height,
|
|
title=title,
|
|
)
|
|
|
|
if args.dry_run:
|
|
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
|
|
return len(cues), 0
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(dest)), exist_ok=True)
|
|
with open(dest, "w", encoding="utf-8", newline="\n") as handle:
|
|
handle.write(document)
|
|
|
|
report.ok(f"{src} -> {dest} ({len(cues)} cues)")
|
|
|
|
return len(cues), 0
|
|
|
|
|
|
@main_wrapper
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
report = Report(quiet=args.quiet)
|
|
|
|
if args.output and len(args.inputs) > 1:
|
|
raise Die("--output takes a single input; use --out-dir for batches")
|
|
|
|
total = 0
|
|
for src in args.inputs:
|
|
if not os.path.isfile(src):
|
|
raise Die(f"no such file: {src}")
|
|
|
|
stem = os.path.splitext(os.path.basename(src))[0]
|
|
|
|
if args.output:
|
|
dest = args.output
|
|
elif args.out_dir:
|
|
dest = os.path.join(args.out_dir, stem + ".ass")
|
|
else:
|
|
dest = os.path.splitext(src)[0] + ".ass"
|
|
|
|
cues, _ = convert_one(src, dest, args, report)
|
|
total += cues
|
|
|
|
if len(args.inputs) > 1:
|
|
report.info(f"{total} cues across {len(args.inputs)} file(s)")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|