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.
354 lines
11 KiB
Python
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())
|