Files
ncmr-songs-videos/scripts/tools.py
T
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

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())