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:
+339
-53
@@ -1,67 +1,353 @@
|
||||
"""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
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
def format_timestamp(timestamp):
|
||||
hours, remainder = divmod(timestamp.total_seconds(), 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
milliseconds = timestamp.microseconds // 1000
|
||||
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d},{int(milliseconds):03d}"
|
||||
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,
|
||||
)
|
||||
|
||||
def lrc_to_srt(lrc_filename, srt_filename):
|
||||
with open(lrc_filename, 'r', encoding='utf-8') as lrc_file:
|
||||
lrc_content = lrc_file.readlines()
|
||||
|
||||
srt_content = []
|
||||
subtitle_counter = 1
|
||||
previous_end_time = None
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
for i, line in enumerate(lrc_content):
|
||||
match = re.match(r'\[(\d{2}):(\d{2})\.(\d{2})\](.*)', line)
|
||||
## Explanation to the regex:
|
||||
## \[ (\d{2}) : (\d{2}) \. (\d{2}) \] (.*)
|
||||
## bracket minutes (2 digits) colon seconds (2 digits) dot millisecs (2 digits) bracket lyrical line (can be blank)
|
||||
if match:
|
||||
print(f'debug: matched line {line.strip()}')
|
||||
start_minutes = int(match.group(1))
|
||||
start_seconds = int(match.group(2))
|
||||
start_milliseconds = int(match.group(3)) * 10
|
||||
print(f'matched text: {match.group(4)}')
|
||||
#: ``[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}))?\]")
|
||||
|
||||
start_time = timedelta(minutes=start_minutes, seconds=start_seconds, milliseconds=start_milliseconds)
|
||||
if len(srt_content) > 0:
|
||||
# srt_content[-1] += f"{format_timestamp(start_time - timedelta(milliseconds=1))}\n"
|
||||
srt_content[-1] += f"{format_timestamp(start_time)}\n"
|
||||
srt_content.append(f"{subtitle_text}\n\n")
|
||||
#: ``[ti:...]`` / ``[ar:...]`` / ``[offset:...]`` — an identifier, not a time.
|
||||
TAG_RE = re.compile(r"^\[(?P<key>[A-Za-z#]+):(?P<value>.*)\]\s*$")
|
||||
|
||||
srt_content.append(f"{subtitle_counter}\n")
|
||||
srt_content.append(f"{format_timestamp(start_time)} --> ")
|
||||
#: 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",
|
||||
}
|
||||
|
||||
subtitle_text = match.group(4).strip()
|
||||
subtitle_counter += 1
|
||||
previous_end_time = start_time
|
||||
|
||||
if previous_end_time is not None:
|
||||
srt_content[-1] += f"{format_timestamp(previous_end_time)}\n"
|
||||
srt_content.append(f"{subtitle_text}\n\n")
|
||||
|
||||
@dataclass
|
||||
class LyricLine:
|
||||
timestamp: float
|
||||
text: str
|
||||
|
||||
with open(srt_filename, 'w', encoding='utf-8') as srt_file:
|
||||
srt_file.writelines(srt_content)
|
||||
|
||||
print(f"Conversion from LRC to SRT completed. SRT file saved as '{srt_filename}'.")
|
||||
@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__":
|
||||
if not(len(sys.argv) == 3 or len(sys.argv) == 2):
|
||||
print("Usage: python lrc2srt.py <input_lrc_file> <output_srt_file>")
|
||||
print("or: python lrc2srt.py <input_lrc_file>")
|
||||
print("If no output file name is supplied, the input file name will be used by default.")
|
||||
else:
|
||||
lrc_filename = sys.argv[1]
|
||||
if len(sys.argv) == 3:
|
||||
srt_filename = sys.argv[2]
|
||||
else:
|
||||
raw_filename, ext = os.path.splitext(lrc_filename)
|
||||
srt_filename = raw_filename + '.srt'
|
||||
lrc_to_srt(lrc_filename, srt_filename)
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user