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.
341 lines
10 KiB
Python
341 lines
10 KiB
Python
"""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
|