feat(scripts): unified Python toolchain, stdlib only

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.
This commit is contained in:
2026-09-10 17:47:02 +09:00
parent 51b878486f
commit 68c12caef9
9 changed files with 2941 additions and 53 deletions
+382
View File
@@ -0,0 +1,382 @@
"""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())