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:
@@ -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
|
||||
Reference in New Issue
Block a user