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.
100 lines
2.9 KiB
Python
100 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Unified entry point for the NCMR songs-videos helper tools.
|
|
|
|
Dispatches to a sibling script, forwards all remaining arguments, and keeps the
|
|
exit status. Use whichever feels natural — this dispatcher, or the script
|
|
directly.
|
|
|
|
python3 scripts/tools.py --help
|
|
python3 scripts/tools.py hwprobe
|
|
python3 scripts/tools.py tools lrc2srt song.lrc -o song.srt
|
|
|
|
The same thing, called directly:
|
|
|
|
python3 scripts/hwprobe.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import runpy
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
#: name -> (module filename, one-line summary)
|
|
TOOLS: dict[str, tuple[str, str]] = {
|
|
"hwprobe": ("hwprobe.py", "which ffmpeg encoders this machine can actually use"),
|
|
"ffmpeg": ("ffmpeg_tools.py", "mux, bake subtitles, build still-image videos, strip audio"),
|
|
"lrc2srt": ("lrc2srt.py", "convert LRC lyrics to SRT"),
|
|
"srt2ass": ("srt2ass.py", "convert SRT subtitles to ASS"),
|
|
"bom": ("bom_gen.py", "generate the untracked-asset bill of materials"),
|
|
}
|
|
|
|
ALIASES = {
|
|
"ffmpeg_tools": "ffmpeg",
|
|
"ffmpeg-tools": "ffmpeg",
|
|
"bom_gen": "bom",
|
|
"bom-gen": "bom",
|
|
"lrc": "lrc2srt",
|
|
"srt": "srt2ass",
|
|
}
|
|
|
|
|
|
def usage(stream=sys.stdout) -> None:
|
|
print("usage: tools.py <tool> [options] [-- <tool options>]", file=stream)
|
|
print(" tools.py --help | --list", file=stream)
|
|
print(file=stream)
|
|
print("tools:", file=stream)
|
|
width = max(len(name) for name in TOOLS)
|
|
for name, (_filename, summary) in TOOLS.items():
|
|
print(f" {name.ljust(width)} {summary}", file=stream)
|
|
print(file=stream)
|
|
print("Every tool also runs standalone: python3 scripts/<name>.py --help", file=stream)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
|
|
if not argv or argv[0] in ("-h", "--help", "help", "list", "--list"):
|
|
usage(stream=sys.stderr if not argv else sys.stdout)
|
|
return 0 if argv else 2
|
|
|
|
name = ALIASES.get(argv[0], argv[0])
|
|
if name not in TOOLS:
|
|
print(f"tools.py: unknown tool {argv[0]!r}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
usage(stream=sys.stderr)
|
|
return 2
|
|
|
|
filename, _summary = TOOLS[name]
|
|
path = os.path.join(HERE, filename)
|
|
|
|
if not os.path.isfile(path):
|
|
print(f"tools.py: {filename} is missing from {HERE}", file=sys.stderr)
|
|
return 1
|
|
|
|
remaining = argv[1:]
|
|
if remaining and remaining[0] == "--":
|
|
remaining = remaining[1:]
|
|
|
|
sys.argv = [path, *remaining]
|
|
|
|
# run_path gives the child the same process, so its exit code is ours.
|
|
try:
|
|
runpy.run_path(path, run_name="__main__")
|
|
except SystemExit as exc:
|
|
code = exc.code
|
|
if code is None:
|
|
return 0
|
|
if isinstance(code, int):
|
|
return code
|
|
print(code, file=sys.stderr)
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|