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:
2026-09-10 17:47:02 +09:00
parent 51b878486f
commit 68c12caef9
9 changed files with 2941 additions and 53 deletions
+340
View File
@@ -0,0 +1,340 @@
"""NCMR songs-videos helper toolchain — Python, stdlib only.
Shared plumbing for the repo's helper scripts. Every script in this package is
runnable as a plain file (``python3 scripts/bom_gen.py``) and as a module
(``python3 -m scripts.bom_gen``); both work because this package is imported
either way without needing its parent directory on ``sys.path``.
Nothing outside the standard library is imported anywhere in this package.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Callable, Iterable, Sequence
__all__ = [
"Die",
"VERSION",
"Report",
"find_ffmpeg",
"glob_files",
"human_size",
"report",
"run",
"subparsers_metavar",
"text_table",
]
VERSION = "0.2.0"
class Die(Exception):
"""Fatal, user-facing error. Caught in ``main()`` and printed to stderr."""
# --------------------------------------------------------------------------- #
# Console output
# --------------------------------------------------------------------------- #
def _colour_enabled(stream=None) -> bool:
stream = stream or sys.stdout
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("FORCE_COLOR"):
return True
return hasattr(stream, "isatty") and stream.isatty()
def _style(text: str, code: str) -> str:
if not _colour_enabled():
return text
return f"\033[{code}m{text}\033[0m"
@dataclass
class Report:
"""Prints user-facing output, with optional ANSI colour when on a TTY."""
quiet: bool = False
def _emit(self, message: str, *, err: bool = False) -> None:
stream = sys.stderr if err else sys.stdout
print(message, file=stream)
def info(self, message: str = "") -> None:
"""Ordinary progress output."""
self._emit(message)
def ok(self, message: str) -> None:
self._emit(_style(f"✓ {message}", "32"))
def warn(self, message: str) -> None:
self._emit(_style(f"warning: {message}", "33"), err=True)
def error(self, message: str) -> None:
self._emit(_style(f"error: {message}", "31"), err=True)
def verbose(self, message: str) -> None:
if not self.quiet:
self._emit(_style(f" {message}", "2"))
#: Default reporter used when a caller doesn't supply one.
report = Report()
# --------------------------------------------------------------------------- #
# Formatting helpers
# --------------------------------------------------------------------------- #
_SIZE_UNITS = ("B", "KiB", "MiB", "GiB", "TiB")
# Characters that make a key unquoted-safe in YAML and legible in a table.
_SAFE_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
def human_size(num_bytes: int) -> str:
"""Format a byte count as ``1.5 MiB`` / ``12 B``."""
value = float(num_bytes)
for unit in _SIZE_UNITS[:-1]:
if value < 1024:
break
value /= 1024
else:
unit = _SIZE_UNITS[-1]
if unit == "B":
return f"{int(value)} B"
return f"{value:.1f} {unit}"
def text_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str:
"""Render a simple whitespace-aligned table (no boxes, pipe-friendly)."""
body = [[str(cell) for cell in row] for row in rows]
widths = [len(str(h)) for h in headers]
for row in body:
for i, cell in enumerate(row):
if i < len(widths):
widths[i] = max(widths[i], len(cell))
def line(cells: Sequence[str]) -> str:
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells)).rstrip()
out = [line([str(h) for h in headers])]
out.append(" ".join("-" * w for w in widths).rstrip())
for row in body:
out.append(line(row))
return "\n".join(out)
def glob_files(directory: str, suffix: str) -> list[str]:
"""Sorted list of files directly inside *directory* whose name ends in *suffix*.
Returns an empty list when the directory does not exist, so callers can treat
"no such folder" and "folder is empty" identically.
"""
try:
entries = os.listdir(directory)
except (FileNotFoundError, NotADirectoryError):
return []
suffix = suffix.lower()
found = [
os.path.join(directory, name)
for name in entries
if name.lower().endswith(suffix) and os.path.isfile(os.path.join(directory, name))
]
return sorted(found)
# --------------------------------------------------------------------------- #
# Subprocess plumbing
# --------------------------------------------------------------------------- #
def run(
cmd: Sequence[str],
*,
report: Report | None = None,
check: bool = True,
cwd: str | None = None,
dry_run: bool = False,
) -> subprocess.CompletedProcess:
"""Run *cmd* with a shell-free argument list, echoing it first.
``report`` is the reporter used for the echo; the helper deliberately takes a
reporter argument rather than closing over the module-level default so that a
``--quiet`` CLI cannot accidentally echo commands.
"""
log = report or globals()["report"]
printable = " ".join(shlex_quote(part) for part in cmd)
if dry_run:
log.info(_style(f"[dry-run] {printable}", "36"))
return subprocess.CompletedProcess(list(cmd), 0)
log.info(_style(f"$ {printable}", "2"))
# ffmpeg prints its whole build configuration as a banner unless told not to.
# Keep it quiet by default, and keep stderr so a failure can be explained.
full_cmd = list(cmd)
if os.path.basename(full_cmd[0]) in ("ffmpeg", "ffprobe"):
full_cmd[1:1] = ["-hide_banner"]
try:
result = subprocess.run(full_cmd, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError as exc:
raise Die(f"command not found: {cmd[0]}") from exc
if result.stdout:
sys.stdout.write(result.stdout)
if result.stderr:
# ffmpeg writes progress to stderr; pass it through so the user still sees
# the encode happen, but drop the wall of banner text.
sys.stderr.write(result.stderr)
if check and result.returncode != 0:
detail = _last_meaningful_line(result.stderr)
suffix = f": {detail}" if detail else ""
raise Die(f"{os.path.basename(cmd[0])} failed (exit {result.returncode}){suffix}")
return result
def _last_meaningful_line(text: str | None) -> str:
"""Pick the most useful line out of ffmpeg's stderr to show in an error."""
if not text:
return ""
for line in reversed(text.strip().splitlines()):
line = line.strip()
if not line:
continue
# Progress lines start with 'frame=' and overwrite each other.
if line.startswith(("frame=", "size=")) or "speed=" in line:
continue
return line
return ""
def shlex_quote(part: str) -> str:
"""Quote one argv element for display. Local copy keeps the module stdlib-only."""
if not part:
return "''"
if re.fullmatch(r"[A-Za-z0-9_@%+=:,./\-]+", part):
return part
return "'" + part.replace("'", "'\"'\"'") + "'"
def find_ffmpeg(override: str | None = None) -> str:
"""Locate the ``ffmpeg`` binary, or raise :class:`Die` with a useful message."""
if override:
if os.path.sep in override:
if not os.access(override, os.X_OK):
raise Die(f"--ffmpeg {override} is not an executable file")
return override
return override # a bare name; let the OS resolve it on $PATH
found = shutil.which("ffmpeg")
if not found:
raise Die("ffmpeg not found on $PATH — install it or pass --ffmpeg /path/to/ffmpeg")
return found
# --------------------------------------------------------------------------- #
# argparse ergonomics
# --------------------------------------------------------------------------- #
def subparsers_metavar(parser: argparse.ArgumentParser, name: str = "mode") -> None:
"""Stop argparse from printing an unreadable ``{a,b,c}`` in help output.
Call *after* every subparser has been registered.
"""
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
action.metavar = name
action.choices = None # type: ignore[assignment] # keep choices working
def add_common_arguments(parser: argparse.ArgumentParser, *, suppress_defaults: bool = False) -> None:
"""Switches every tool in the toolbox understands.
``suppress_defaults`` is used when the same switches are also registered on
subparsers: it stops the subparser copy from stamping its default over a value
the user gave before the mode word.
"""
default_suppress = argparse.SUPPRESS if suppress_defaults else False
parser.add_argument(
"-q",
"--quiet",
action="store_true",
default=default_suppress,
help="suppress progress output (errors still go to stderr)",
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
default=default_suppress,
help="print the work that would happen without doing it",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {VERSION}",
)
def add_ffmpeg_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--ffmpeg",
metavar="PATH",
default=None,
help="ffmpeg binary to use (default: whichever is on $PATH)",
)
@dataclass
class ReportMixin:
"""Mixin for subcommand classes that need a reporter and a dry-run flag."""
report: Report = field(default_factory=Report)
dry_run: bool = False
def main_wrapper(fn: Callable[[], int | None]) -> Callable[[], int]:
"""Decorator turning ``Die`` exceptions into a clean exit status."""
def wrapper() -> int:
try:
return fn() or 0
except Die as exc:
report.error(str(exc))
return 1
except KeyboardInterrupt:
report.error("interrupted")
return 130
return wrapper