Files
irene 68c12caef9 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.
2026-09-10 17:47:02 +09:00

354 lines
11 KiB
Python

"""Convert LRC lyrics to SRT subtitles.
LRC is the timestamped-lyrics format most lyric sites and players emit. This
converter handles the shapes that actually show up in the wild:
* ``[mm:ss.xx]`` and ``[mm:ss.xxx]`` timestamps, and bare ``[mm:ss]``
* several timestamps on one line (``[00:05.00][00:30.00]chorus``), which is how
repeated sections are written
* ID tags (``[ti:Title]``, ``[ar:Artist]``, ``[offset:+250]``), which are
metadata, not lyrics
* multiple timestamped lines sharing one timestamp, which SRT wants as one cue
with two subtitle lines
Cue end times are inferred from the next cue's start, so the timing is driven by
the LRC rather than invented. The final cue gets ``--last-duration`` seconds.
Usage::
python3 scripts/lrc2srt.py song.lrc # -> song.srt
python3 scripts/lrc2srt.py song.lrc -o subs/song.srt
python3 scripts/lrc2srt.py lyrics/*.lrc --out-dir build/
python3 scripts/lrc2srt.py song.lrc --stdout # write to stdout
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from dataclasses import dataclass, field
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,
main_wrapper,
)
else: # pragma: no cover
from ._common import (
Die,
Report,
add_common_arguments,
main_wrapper,
)
# --------------------------------------------------------------------------- #
# Parsing
# --------------------------------------------------------------------------- #
#: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` / ``[mm:ss]``, anywhere in a line.
TIMESTAMP_RE = re.compile(r"\[(?P<m>\d{1,3}):(?P<s>\d{1,2})(?:[.:](?P<frac>\d{1,3}))?\]")
#: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time.
TAG_RE = re.compile(r"^\[(?P<key>[A-Za-z#]+):(?P<value>.*)\]\s*$")
#: Keys whose value is a signed millisecond count, not a timestamp.
META_KEYS = {
"ti", "al", "ar", "au", "by", "re", "ve", "length", "offset", "encoding", "tool", "id",
}
@dataclass
class LyricLine:
timestamp: float
text: str
@dataclass
class LrcDocument:
lines: list[LyricLine] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
offset_ms: int = 0
@property
def title(self) -> str | None:
return self.metadata.get("ti")
@property
def artist(self) -> str | None:
return self.metadata.get("ar")
def parse_timestamp(minutes: str, seconds: str, fraction: str | None) -> float:
"""Turn LRC timestamp parts into seconds.
The fractional part is centiseconds in the common case (``.30`` = 300 ms) but
milliseconds when three digits are given (``.300`` = 300 ms). The length of
the field disambiguates.
"""
total = int(minutes) * 60 + int(seconds)
if fraction:
if len(fraction) == 3:
total += int(fraction) / 1000
else:
total += int(fraction) / 100
return total
def parse_lrc(content: str) -> LrcDocument:
"""Parse LRC text into a document, sorted by timestamp."""
if content.startswith("\ufeff"):
content = content[1:]
content = content.replace("\r\n", "\n").replace("\r", "\n")
document = LrcDocument()
for raw in content.split("\n"):
line = raw.strip()
if not line:
continue
# Metadata tags first: [ti:...] looks a lot like [00:12] to a loose regex.
tag = TAG_RE.match(line)
if tag and tag.group("key").lower() in META_KEYS:
key = tag.group("key").lower()
value = tag.group("value").strip()
if key == "offset":
try:
document.offset_ms = int(value)
except ValueError:
pass
else:
document.metadata[key] = value
continue
stamps = list(TIMESTAMP_RE.finditer(line))
if not stamps:
# An untimed line. Attach it to the previous cue as a continuation
# line rather than discarding the lyric entirely.
if document.lines:
previous = document.lines[-1]
previous.text = f"{previous.text}\n{line}"
continue
text = TIMESTAMP_RE.sub("", line).strip()
for stamp in stamps:
document.lines.append(
LyricLine(
timestamp=parse_timestamp(
stamp.group("m"), stamp.group("s"), stamp.group("frac")
),
text=text,
)
)
if document.offset_ms:
shift = document.offset_ms / 1000
for line in document.lines:
line.timestamp = max(0.0, line.timestamp + shift)
document.lines.sort(key=lambda l: l.timestamp)
return document
# --------------------------------------------------------------------------- #
# SRT output
# --------------------------------------------------------------------------- #
@dataclass
class Cue:
index: int
start: float
end: float
lines: list[str]
def format_srt_time(seconds: float) -> str:
"""Seconds to SRT ``HH:MM:SS,mmm``."""
if seconds < 0:
seconds = 0.0
total_ms = int(round(seconds * 1000))
hours, remainder = divmod(total_ms, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
secs, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def build_cues(
lines: list[LyricLine],
*,
last_duration: float = 5.0,
min_duration: float = 0.5,
) -> list[Cue]:
"""Group simultaneous lines into cues and infer each cue's end time.
Lines sharing one timestamp become a single cue with several subtitle lines —
the format singers actually expect for a two-line phrase. A cue ends when the
*next distinct* timestamp arrives.
"""
if not lines:
return []
# Collapse lines with identical timestamps into one cue.
groups: list[tuple[float, list[str]]] = []
for line in lines:
if groups and abs(groups[-1][0] - line.timestamp) < 1e-6:
groups[-1][1].append(line.text)
else:
groups.append((line.timestamp, [line.text]))
cues: list[Cue] = []
for i, (start, text_lines) in enumerate(groups):
if i + 1 < len(groups):
end = groups[i + 1][0]
if end - start < min_duration:
end = start + min_duration
else:
end = start + last_duration
cues.append(Cue(index=i + 1, start=start, end=end, lines=text_lines))
return cues
def render_srt(cues: list[Cue]) -> str:
blocks = []
for cue in cues:
body = "\n".join(cue.lines)
blocks.append(
f"{cue.index}\n{format_srt_time(cue.start)} --> {format_srt_time(cue.end)}\n{body}"
)
return "\n\n".join(blocks) + "\n"
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="lrc2srt",
description="Convert LRC lyrics to SRT subtitles.",
epilog="Multiple timestamps on one line are expanded; ID tags are ignored.",
)
add_common_arguments(parser)
parser.add_argument("inputs", nargs="+", metavar="INPUT", help="LRC file(s) to convert")
parser.add_argument("-o", "--output", metavar="FILE", help="output path (single input only)")
parser.add_argument("--out-dir", metavar="DIR", help="write every output into this directory")
parser.add_argument("--stdout", action="store_true", help="write the SRT to stdout, no file")
parser.add_argument(
"--last-duration",
type=float,
default=5.0,
metavar="SECONDS",
help="how long to hold the final line (default: 5)",
)
parser.add_argument(
"--min-duration",
type=float,
default=0.5,
metavar="SECONDS",
help="shortest cue allowed (default: 0.5)",
)
parser.add_argument(
"--title",
help="override the SRT's implicit title (used for reporting only)",
)
return parser
def convert(src: str, args: argparse.Namespace, report: Report) -> tuple[int, str | None]:
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
document = parse_lrc(content)
if not document.lines:
raise Die(f"{src}: no timestamped lyrics found — is this an LRC file?")
cues = build_cues(
document.lines,
last_duration=args.last_duration,
min_duration=args.min_duration,
)
output = render_srt(cues)
if args.stdout:
sys.stdout.write(output)
return len(cues), None
if args.output:
dest = args.output
elif args.out_dir:
dest = os.path.join(args.out_dir, os.path.splitext(os.path.basename(src))[0] + ".srt")
else:
dest = os.path.splitext(src)[0] + ".srt"
if args.dry_run:
report.info(f"[dry-run] would write {dest} ({len(cues)} cues)")
return len(cues), dest
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(output)
label = document.title or os.path.basename(src)
report.ok(f"{label} -> {dest} ({len(cues)} cues)")
if document.artist:
report.verbose(f"artist: {document.artist}")
if document.offset_ms:
report.verbose(f"applied LRC offset of {document.offset_ms:+d} ms")
return len(cues), dest
@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")
if args.stdout and len(args.inputs) > 1:
raise Die("--stdout takes a single input")
total = 0
converted = 0
for src in args.inputs:
if not os.path.isfile(src):
raise Die(f"no such file: {src}")
cues, _dest = convert(src, args, report)
total += cues
converted += 1
if converted > 1:
report.info(f"{total} cues across {converted} file(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())