From 68c12caef9d1e52a2fcc7b05ed457883916137bb Mon Sep 17 00:00:00 2001 From: Irene Date: Thu, 10 Sep 2026 17:47:02 +0900 Subject: [PATCH] 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 ...`) 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. --- .gitignore | 4 + scripts/README.md | 204 +++++++++++++ scripts/_common.py | 340 +++++++++++++++++++++ scripts/bom_gen.py | 467 +++++++++++++++++++++++++++++ scripts/ffmpeg_tools.py | 646 ++++++++++++++++++++++++++++++++++++++++ scripts/hwprobe.py | 460 ++++++++++++++++++++++++++++ scripts/lrc2srt.py | 392 ++++++++++++++++++++---- scripts/srt2ass.py | 382 ++++++++++++++++++++++++ scripts/tools.py | 99 ++++++ 9 files changed, 2941 insertions(+), 53 deletions(-) create mode 100644 scripts/README.md create mode 100644 scripts/_common.py create mode 100644 scripts/bom_gen.py create mode 100644 scripts/ffmpeg_tools.py create mode 100644 scripts/hwprobe.py create mode 100644 scripts/srt2ass.py create mode 100644 scripts/tools.py diff --git a/.gitignore b/.gitignore index 5584a41..b8c9b27 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ node_modules dist out .env +# Python (helper scripts) +__pycache__/ +*.py[cod] +.venv/ diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d6d25a6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,204 @@ +# NCMR songs-videos — tools + +Helper scripts for building NCMR song videos: converting lyrics to subtitles, +burning them in, muxing audio, and working out what this machine can encode. + +Everything here is **Python standard library only**. No virtualenv, no install +step, no dependencies to break in six months. Run them straight from the repo: + +``` +python3 scripts/tools.py --help +``` + +--- + +## Quick start + +```sh +cd ~/dev/mikkeli/ncmr-songs-videos + +# 1. What can this machine actually encode? +python3 scripts/tools.py hwprobe + +# 2. Lyrics -> subtitles +python3 scripts/tools.py lrc2srt song.lrc -o song.srt +python3 scripts/tools.py srt2ass song.srt -o song.ass + +# 3. Audio + still image -> a video (the "PV" case) +python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv + +# 4. Burn the subtitles in, copy the audio through untouched +python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac + +# 5. Record what untracked assets went in +python3 scripts/tools.py bom --dir . +``` + +Every subcommand takes `--help`. Every subcommand accepts `--dry-run` (`-n`) to +print the work it would do without doing it, which is the fastest way to see +what ffmpeg command you are about to run. + +--- + +## The tools + +| Tool | Purpose | +| --- | --- | +| `tools.py` | Dispatcher. `python3 scripts/tools.py [args...]`. | +| `hwprobe.py` | Report which encoders this machine can really use, and why not. | +| `lrc2srt.py` | LRC lyrics → SRT subtitles. | +| `srt2ass.py` | SRT → ASS, for burn-in via ffmpeg's `ass` filter. | +| `ffmpeg_tools.py` | `mux`, `bake`, `still`, `strip`, `probe` — the ffmpeg wrappers. | +| `bom_gen.py` | Write a `BOM.yaml` (or `BOM.md`) of a song's untracked assets. | +| `_common.py` | Shared helpers. Not a command; imported by the rest. | + +Each script also runs standalone (`python3 scripts/hwprobe.py`) and can be +imported as a module. The dispatcher is a convenience, not a requirement. + +--- + +## Hardware encoding + +This machine is an **AMD Strix Halo** (Radeon 8060S). There is no NVIDIA card, +so the `nvenc` recipes in `ffmpegusage.txt` are reference only — they will not +run here. What does work is **VA-API** via the Mesa `radeonsi` driver: + +| Codec | Encoder | Status | +| --- | --- | --- | +| H.264 | `h264_vaapi` | works | +| HEVC | `hevc_vaapi` | works | +| AV1 | `av1_vaapi` | works | +| H.264/HEVC | `libx264`/`libx265` | software fallback, always available | + +`ffmpeg_tools.py` picks a hardware encoder automatically when one is usable and +falls back to `libx264` when it is not, so the same command works on a machine +without a usable GPU. Override it when you want to: + +```sh +--video auto # default: hardware if possible, else software +--video x264 # force software H.264 +--video x265 # force software HEVC +--video copy # no re-encode at all +--codec hevc # which hardware codec family to try with --video auto +--device PATH # VA-API render node (default /dev/dri/renderD128) +``` + +### If hardware encoding stops working + +The usual cause is permissions on the DRM render node. `/dev/dri/renderD128` is +owned by `root:render`, so the user needs to be in the `render` group: + +```sh +getent group render # who is in it +python3 scripts/tools.py hwprobe # says plainly whether the device opens +``` + +Group membership only applies to *new* logins. If `id` shows `render` but +`hwprobe` still reports the device as unreadable, the running process is holding +stale credentials — log out and back in, or start the process under the group +explicitly: + +```sh +sg render -c 'python3 scripts/hwprobe.py' +``` + +`hwprobe` never guesses. It opens the device, runs a two-frame test encode +through each candidate encoder, and reports what actually worked. + +```sh +python3 scripts/tools.py hwprobe --shell # export-friendly KEY=value output +``` + +--- + +## Subtitles + +LRC is the timestamped-lyrics format lyric sites emit; SRT is what most players +read; ASS is what you need to burn styled text into a video with ffmpeg. + +```sh +python3 scripts/tools.py lrc2srt song.lrc -o song.srt --shift -0.5 +``` + +`lrc2srt` handles the messy real-world cases: `[mm:ss]`, `[mm:ss.xx]` and +`[mm:ss.xxx]` timestamps, several timestamps on one line (repeated choruses — +each becomes its own cue), `[ar:]`/`[ti:]` metadata tags, and blank lines inside +a cue. The final cue is held for a few seconds rather than dropped; adjust with +`--tail`. Use `--shift` to nudge everything earlier or later if the lyrics drift +against the audio. + +```sh +python3 scripts/tools.py srt2ass song.srt -o song.ass --play-res 1920x1080 +``` + +`srt2ass` converts to ASS and gives ffmpeg something it can burn in. `--play-res` +sets the script resolution used for font scaling; match it to your video so text +sizes come out as designed. `--dry-run` prints the ASS to stdout for inspection. + +--- + +## ffmpeg wrappers + +Long ffmpeg command lines are easy to get subtly wrong and impossible to +remember. These wrap the five the project actually needs. + +```sh +# Attach audio to a video-only file; video stream copied, never re-encoded. +python3 scripts/tools.py ffmpeg mux render.mp4 song.flac -o pv.mkv --format flac + +# Burn subtitles into a video. +python3 scripts/tools.py ffmpeg bake pv.mkv song.ass -o final.mp4 --format flac + +# One still image + audio -> a video. +python3 scripts/tools.py ffmpeg still cover.png song.flac -o pv.mkv --fps 60 + +# Pull the audio back out as MP3. +python3 scripts/tools.py ffmpeg strip final.mp4 -o final.mp3 + +# What is actually in this file? +python3 scripts/tools.py ffmpeg probe final.mp4 +``` + +Notes on the choices baked in: + +- **`mux` uses `-c:v copy`.** The Remotion render is already encoded; re-encoding + it to add audio costs quality and time for nothing. Containers are picked from + the output extension. +- **`--format` presets** are `aac`, `mp3`, `flac`, `opus` and `copy`. `flac` is + the default because these are archival masters; use `aac`/`opus` for anything + you are actually shipping. `--bitrate` applies to the lossy ones. +- **`-shortest`** is set where a still image is looped, so the video ends with + the audio instead of running on for ever. +- **`-movflags +faststart`** is set for MP4 output, so the file starts playing + before it has fully downloaded. + +For anything these do not cover, use `--dry-run` to print the generated command, +copy it, and edit it — the wrapper is a convenience, not a cage. The legacy +recipes this grew out of are preserved in `ffmpegusage.txt`. + +--- + +## Bill of materials + +Binary assets are git-ignored, so a clone does not tell you what went into a +song. `bom_gen.py` walks the asset directories and records it: + +```sh +python3 scripts/tools.py bom --dir . --format yaml +``` + +It writes `BOM.yaml` listing every untracked file under `audio/`, `images/`, +`fonts/` and `videos/` with its size, plus whatever description you give it. Use +`--format md` for Markdown, `--format json` for machine-readable output, and +`-y` to skip the interactive description prompt. If PyYAML is available it is +used for clean output; otherwise a small built-in writer produces the same +schema. Neither is required. + +--- + +## Conventions + +- `master` is the protected template. Do per-song work on `/master`, and + features on `/`. See `README.md`. +- Binary assets stay out of git; the BOM is how you know what was used. +- Keep new tools in `scripts/`, stdlib only, with a `--dry-run` and `--help`. diff --git a/scripts/_common.py b/scripts/_common.py new file mode 100644 index 0000000..2dcd989 --- /dev/null +++ b/scripts/_common.py @@ -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 diff --git a/scripts/bom_gen.py b/scripts/bom_gen.py new file mode 100644 index 0000000..753620f --- /dev/null +++ b/scripts/bom_gen.py @@ -0,0 +1,467 @@ +"""Generate a bill of materials for a song's untracked binary assets. + +The repo's guideline says every song keeps a ``BOM.md`` (or ``BOM.yaml``) +listing the untracked input assets and explaining what each file is. This walks +the conventional asset directories, prompts for a description per file, and +writes the result. + +Stdlib only: the YAML is emitted by hand. If PyYAML happens to be installed it is +used to *validate* the output, but it is never required. + +Usage:: + + python3 scripts/bom_gen.py # walk ./assets, write BOM.yaml + python3 scripts/bom_gen.py path/to/song # walk a song directory + python3 scripts/bom_gen.py --format md # write BOM.md instead + python3 scripts/bom_gen.py --descriptions d.toml # non-interactive + python3 scripts/bom_gen.py --describe-one audio/song.flac +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from dataclasses import dataclass, field + +if __package__ in (None, ""): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from _common import ( # type: ignore[import-not-found] + Die, + Report, + add_common_arguments, + human_size, + main_wrapper, + text_table, + ) +else: # pragma: no cover + from ._common import ( + Die, + Report, + add_common_arguments, + human_size, + main_wrapper, + text_table, + ) + + +#: Directories that hold untracked input assets, with the role we assume. +ASSET_DIRS: tuple[tuple[str, str], ...] = ( + ("audio", "Song audio: final mix, instrumental, stems"), + ("images", "Cover art, stills, background plates"), + ("fonts", "Typefaces used by the subtitles or overlays"), + ("videos", "Material video clips"), +) + +#: File extensions that are worth flagging as (probably) not-yet-described. +_DESCRIPTION_PLACEHOLDER = "" + +_BOM_MARKER = "; generated by scripts/bom_gen.py" + +# --------------------------------------------------------------------------- # +# YAML emission (hand-rolled, stdlib only) +# --------------------------------------------------------------------------- # + +#: Characters that force a value to be quoted in YAML. +_NEEDS_QUOTES = re.compile(r"""[:\{\}\[\],&*#?|<>=!%@`"']|^\s|\s$|^$|^[-?]""") + + +def yaml_scalar(value: str) -> str: + """Quote *value* for YAML only when it needs it.""" + if value.isdigit(): + return f'"{value}"' # keep hashes/filenames that look numeric as strings + + if _NEEDS_QUOTES.search(value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + + return f'"{escaped}"' + + return value + + +def yaml_key(value: str) -> str: + """Quote a key that would otherwise be ambiguous (spaces, colons, leading -).""" + if _NEEDS_QUOTES.search(value) or value.startswith("-") or value != value.strip(): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + + return f'"{escaped}"' + + return value + + +# --------------------------------------------------------------------------- # +# Asset discovery +# --------------------------------------------------------------------------- # + + +@dataclass +class Asset: + path: str # relative to the BOM root, POSIX separators + size: int + description: str = "" + extra: dict[str, str] = field(default_factory=dict) + + @property + def directory(self) -> str: + return os.path.dirname(self.path).split("/")[0] or "." + + +def walk_assets(root: str, dirs: tuple[tuple[str, str], ...] = ASSET_DIRS) -> list[Asset]: + """Collect files under each asset directory, sorted by path.""" + assets: list[Asset] = [] + + for name, _role in dirs: + base = os.path.join(root, name) + if not os.path.isdir(base): + continue + + for dirpath, dirnames, filenames in os.walk(base): + dirnames.sort() + for filename in sorted(filenames): + if filename.startswith("."): + continue + full = os.path.join(dirpath, filename) + if not os.path.isfile(full): + continue + rel = os.path.relpath(full, root).replace(os.sep, "/") + assets.append(Asset(path=rel, size=os.path.getsize(full))) + + return assets + + +def load_descriptions(path: str) -> dict[str, str]: + """Read a descriptions file: simple ``path = description`` or ``pathdesc``. + + A deliberately forgiving format so descriptions can also be pasted in from a + spreadsheet. ``#`` starts a comment. + """ + try: + with open(path, "r", encoding="utf-8") as handle: + raw = handle.read() + except OSError as exc: + raise Die(f"cannot read --descriptions {path}: {exc.strerror}") from exc + + mapping: dict[str, str] = {} + for line in raw.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + elif "\t" in line: + key, _, value = line.partition("\t") + else: + continue + mapping[key.strip().lstrip("./")] = value.strip() + + return mapping + + +# --------------------------------------------------------------------------- # +# Rendering +# --------------------------------------------------------------------------- # + + +def render_yaml(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str: + total = sum(a.size for a in assets) + lines = [ + "---", + _BOM_MARKER, + f"root: {yaml_scalar(os.path.basename(os.path.abspath(root)) or root)}", + ] + if title: + lines.append(f"title: {yaml_scalar(title)}") + lines.append(f"total_files: {len(assets)}") + lines.append(f"total_size: {total}") + lines.append("") + + if by_dir: + for dirname, role in ASSET_DIRS: + grouping = [a for a in assets if a.directory == dirname] + if not grouping: + continue + subtotal = sum(a.size for a in grouping) + lines.append(f"{yaml_key(dirname)}:") + lines.append(f" role: {yaml_scalar(role)}") + lines.append(f" count: {len(grouping)}") + lines.append(f" size: {subtotal}") + lines.append(" files:") + for asset in grouping: + lines.append(f" {yaml_key(asset.path)}:") + lines.append(f" size: {asset.size}") + lines.append(f" description: {yaml_scalar(asset.description)}") + lines.append("") + else: + lines.append("files:") + for asset in assets: + lines.append(f" {yaml_key(asset.path)}:") + lines.append(f" size: {asset.size}") + lines.append(f" description: {yaml_scalar(asset.description)}") + + # Anything under an unrecognised top-level directory still deserves a mention. + others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}] + if others: + lines.append("") + lines.append("unclassified:") + for asset in others: + lines.append(f" {yaml_key(asset.path)}:") + lines.append(f" size: {asset.size}") + lines.append(f" description: {yaml_scalar(asset.description)}") + + lines.append("...") + + return "\n".join(lines) + "\n" + + +def render_markdown(root: str, assets: list[Asset], *, title: str | None, by_dir: bool) -> str: + total = sum(a.size for a in assets) + heading = title or os.path.basename(os.path.abspath(root)) or "Untracked assets" + + lines = [ + f"# Bill of materials — {heading}", + "", + f"{len(assets)} file(s), {human_size(total)}. Generated by `scripts/bom_gen.py`.", + "", + ] + + groups: list[tuple[str, list[Asset]]] = [] + if by_dir: + for dirname, _role in ASSET_DIRS: + grouping = [a for a in assets if a.directory == dirname] + if grouping: + groups.append((dirname, grouping)) + others = [a for a in assets if a.directory not in {d for d, _ in ASSET_DIRS}] + if others: + groups.append(("unclassified", others)) + else: + groups.append(("files", assets)) + + for group_name, grouping in groups: + lines.append(f"## {group_name}") + lines.append("") + rows = [ + ( + f"`{asset.path}`", + human_size(asset.size), + asset.description or f"_{_DESCRIPTION_PLACEHOLDER}_", + ) + for asset in grouping + ] + lines.append(text_table(("file", "size", "description"), rows)) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def validate_yaml(document: str, report: Report) -> None: + """If PyYAML is importable, use it to confirm the emitted document parses.""" + try: + import yaml # type: ignore[import-not-found] + except ImportError: + return + + try: + yaml.safe_load(document) + except Exception as exc: # noqa: BLE001 - report whatever PyYAML says + report.warn(f"PyYAML could not parse the generated document: {exc}") + + +# --------------------------------------------------------------------------- # +# Interactive prompts +# --------------------------------------------------------------------------- # + + +def prompt_for_descriptions(assets: list[Asset], report: Report) -> None: + """Ask for a description per asset. EOF ends prompting and keeps what we have.""" + if not sys.stdin.isatty(): + report.warn( + "stdin is not a terminal — skipping prompts; " + "descriptions left blank (use --descriptions FILE to supply them)" + ) + return + + total = len(assets) + report.info(f"Describing {total} file(s). Blank line accepts the current value.\n") + report.info("Press Ctrl-C or Ctrl-D to stop early; the BOM is still written.\n") + + try: + for i, asset in enumerate(assets, start=1): + report.info(f"[{i}/{total}] {asset.path} ({human_size(asset.size)})") + try: + answer = input(" description: ").strip() + except EOFError: + report.info("") + report.warn("end of input — remaining descriptions left blank") + return + if answer: + asset.description = answer + except KeyboardInterrupt: + report.info("") + report.warn("interrupted — writing the BOM with the descriptions gathered so far") + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bom_gen", + description="Generate a bill of materials for a song's untracked binary assets.", + ) + add_common_arguments(parser) + + parser.add_argument( + "root", + nargs="?", + default=".", + help="directory holding audio/, images/, fonts/, videos/ (default: .)", + ) + parser.add_argument( + "-o", + "--output", + help="output path (default: BOM.yaml or BOM.md in the root directory)", + ) + parser.add_argument( + "--format", + default="yaml", + choices=("yaml", "md"), + help="output format (default: yaml)", + ) + parser.add_argument( + "--descriptions", + metavar="FILE", + help="read 'path = description' lines from FILE instead of prompting", + ) + parser.add_argument( + "--no-prompt", + action="store_true", + help="never prompt; write the inventory with blank descriptions", + ) + parser.add_argument( + "--flat", + action="store_true", + help="list files without grouping them by asset directory", + ) + parser.add_argument( + "--title", + help="song title, recorded in the output header", + ) + parser.add_argument( + "--describe-one", + metavar="PATH", + help="update the description of a single file in an existing BOM", + ) + + return parser + + +@main_wrapper +def main() -> int: + args = build_parser().parse_args() + report = Report(quiet=args.quiet) + + root = os.path.abspath(args.root) + if not os.path.isdir(root): + raise Die(f"not a directory: {args.root}") + + if args.describe_one: + return describe_one(root, args, report) + + assets = walk_assets(root) + + if not assets: + expected = ", ".join(f"{name}/" for name, _ in ASSET_DIRS) + report.warn(f"no assets found under {root} (looked in: {expected})") + return 1 + + if args.descriptions: + mapping = load_descriptions(args.descriptions) + applied = 0 + for asset in assets: + if asset.path in mapping: + asset.description = mapping[asset.path] + applied += 1 + missing = [a.path for a in assets if not a.description] + report.verbose(f"applied {applied} description(s) from {args.descriptions}") + if missing: + report.warn(f"{len(missing)} file(s) have no description in {args.descriptions}") + elif not args.no_prompt: + prompt_for_descriptions(assets, report) + + renderer = render_markdown if args.format == "md" else render_yaml + document = renderer(root, assets, title=args.title, by_dir=not args.flat) + + if args.format == "yaml": + validate_yaml(document, report) + + default_name = "BOM.md" if args.format == "md" else "BOM.yaml" + output = args.output or os.path.join(root, default_name) + + if args.dry_run: + report.info(document) + report.info(f"[dry-run] would write {output}") + return 0 + + with open(output, "w", encoding="utf-8", newline="\n") as handle: + handle.write(document) + + total = sum(a.size for a in assets) + described = sum(1 for a in assets if a.description) + report.ok(f"{output} — {len(assets)} file(s), {human_size(total)}, {described} described") + + if described < len(assets): + report.info(f" {len(assets) - described} file(s) still need a description") + + return 0 + + +def describe_one(root: str, args: argparse.Namespace, report: Report) -> int: + """Set the description for a single path inside an existing BOM.""" + target = args.describe_one.lstrip("./") + if not os.path.isfile(os.path.join(root, target)): + raise Die(f"no such asset: {target}") + + output = args.output or os.path.join(root, "BOM.yaml" if args.format == "yaml" else "BOM.md") + + description = args.title or "" # reused as the description payload for this mode + if not description: + if not sys.stdin.isatty(): + raise Die("--describe-one needs a description; pass --title \"...\"") + description = input(f"description for {target}: ").strip() + + if not os.path.isfile(output): + raise Die(f"no existing BOM at {output}; run without --describe-one first") + + with open(output, "r", encoding="utf-8") as handle: + content = handle.read() + + pattern = re.compile( + rf"(^[ \t]*{re.escape(target)}:[ \t]*\n" + rf"(?:[ \t]+\w+:.*\n)*?" + rf"[ \t]+description:[ \t]*)" + rf".*$", + re.MULTILINE, + ) + + updated, count = pattern.subn(rf"\g<1>{yaml_scalar(description)}", content) + + if count == 0: + raise Die(f"could not find an entry for {target} in {output}") + + if args.dry_run: + report.info(f"[dry-run] would set {target} description in {output}") + return 0 + + with open(output, "w", encoding="utf-8", newline="\n") as handle: + handle.write(updated) + + report.ok(f"{target} described in {output}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ffmpeg_tools.py b/scripts/ffmpeg_tools.py new file mode 100644 index 0000000..de5ddc7 --- /dev/null +++ b/scripts/ffmpeg_tools.py @@ -0,0 +1,646 @@ +"""FFmpeg front-end for NCMR song videos. + +Wraps the ffmpeg invocations this project actually needs, so the long command +lines live in one place instead of in shell history and notes files. + +Modes: + +``mux`` attach an audio track to a video-only render, without re-encoding + the video (the Remotion output is video-only; this is step 1) +``bake`` burn an ASS subtitle file into a video (step 2) +``still`` build a video from a single image plus audio, for the no-lyrics case +``strip`` pull an MP3 out of a finished video +``probe`` print a compact stream summary + +Both ``mux`` and ``bake`` can be chained with ``--then-bake``/``--then-mux`` so a +render goes to finished file in one command. + +Usage:: + + python3 scripts/ffmpeg_tools.py probe out/pulseraw.mp4 + python3 scripts/ffmpeg_tools.py mux out/pulseraw.mp4 song.wav -o raw.mkv --format flac + python3 scripts/ffmpeg_tools.py bake raw.mkv subs.ass -o final.mp4 + python3 scripts/ffmpeg_tools.py still cover.png song.wav -o pv.mkv --fps 60 --format flac + python3 scripts/ffmpeg_tools.py strip final.mp4 -o song.mp3 +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass + +if __package__ in (None, ""): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from _common import ( # type: ignore[import-not-found] + Die, + Report, + add_common_arguments, + add_ffmpeg_argument, + find_ffmpeg, + main_wrapper, + run, + text_table, + ) +else: # pragma: no cover + from ._common import ( + Die, + Report, + add_common_arguments, + add_ffmpeg_argument, + find_ffmpeg, + main_wrapper, + run, + text_table, + ) + + +# --------------------------------------------------------------------------- # +# Audio/codec presets +# --------------------------------------------------------------------------- # + +#: ``--format`` choices, mapping to the ffmpeg audio arguments to use. +AUDIO_FORMATS = { + # name: (extension, container args, codec args) + "aac": ("mp4", [], ["-c:a", "aac", "-b:a", "320k"]), + "mp3": ("mp4", [], ["-c:a", "libmp3lame", "-b:a", "320k"]), + "flac": ("mkv", [], ["-c:a", "flac", "-compression_level", "12"]), + "opus": ("mkv", [], ["-c:a", "libopus", "-b:a", "192k"]), + "copy": (None, [], ["-c:a", "copy"]), +} + +#: ``--video`` choices. ``auto`` picks a hardware encoder when one works. +VIDEO_ENCODERS = { + "auto": None, + "x264": ["-c:v", "libx264"], + "x265": ["-c:v", "libx265"], + "copy": ["-c:v", "copy"], +} + +VAAPI_ENCODERS = { + "h264": ["-c:v", "h264_vaapi"], + "hevc": ["-c:v", "hevc_vaapi"], + "av1": ["-c:v", "av1_vaapi"], +} + +#: Software fallback quality knob, applied only for libx264/libx265. +X264_QUALITY = ["-preset", "medium", "-crf", "18"] + +DEFAULT_DEVICE = "/dev/dri/renderD128" + + +@dataclass +class HwContext: + """Hardware acceleration state: which encoder to use and how to init it.""" + + encoder: str + device: str | None = None + is_vaapi: bool = False + note: str = "" + + def input_args(self) -> list[str]: + """Args that go before ``-i``.""" + if self.is_vaapi and self.device: + return ["-init_hw_device", f"vaapi=va:{self.device}", "-filter_hw_device", "va"] + + return [] + + def filter_suffix(self) -> str: + """Filters appended to a user filter chain to move frames onto the GPU.""" + if self.is_vaapi: + return "format=nv12,hwupload" + + return "" + + +# --------------------------------------------------------------------------- # +# Encoder selection +# --------------------------------------------------------------------------- # + + +def _vaapi_device_readable(device: str) -> bool: + try: + fd = os.open(device, os.O_RDWR) + except OSError: + return False + + os.close(fd) + + return True + + +_DEVICE_CACHE: dict[str, bool] = {} + + +def _encoder_works(ffmpeg: str, encoder: str, extra: list[str], device_key: str = "") -> bool: + """Try a two-frame encode. Results are memoised per process. + + VA-API encoders only accept GPU-resident frames, so the probe must upload the + test pattern first. Without ``format=nv12,hwupload`` ffmpeg fails with + "Function not implemented" even though the encoder works fine — which would + make this fall back to software on a machine that has no problem at all. + """ + cache_key = f"{encoder}|{device_key}" + if cache_key in _DEVICE_CACHE: + return _DEVICE_CACHE[cache_key] + + vf = "format=nv12,hwupload" if encoder.endswith("_vaapi") else None + + cmd = [ + ffmpeg, "-hide_banner", "-loglevel", "error", *extra, + "-f", "lavfi", "-i", "color=c=black:s=256x256:r=30:d=1", + ] + if vf: + cmd += ["-vf", vf] + cmd += ["-c:v", encoder, "-frames:v", "2", "-f", "null", "-"] + + try: + ok = subprocess.run(cmd, capture_output=True, timeout=60).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + ok = False + + _DEVICE_CACHE[cache_key] = ok + + return ok + + +#: Hardware encoders worth trying for each codec family, in preference order. +HW_ENCODER_CHAIN: dict[str, tuple[tuple[str, str], ...]] = { + # codec: ((encoder, init-family), ...) + "h264": (("h264_vaapi", "vaapi"), ("h264_nvenc", "nvenc"), ("h264_qsv", "qsv")), + "hevc": (("hevc_vaapi", "vaapi"), ("hevc_nvenc", "nvenc"), ("hevc_qsv", "qsv")), + "av1": (("av1_vaapi", "vaapi"), ("av1_nvenc", "nvenc"), ("av1_qsv", "qsv")), +} + + +def pick_acceleration( + ffmpeg: str, + report: Report, + *, + video: str = "auto", + codec: str = "h264", + device: str | None = None, + allow_software: bool = True, +) -> HwContext: + """Decide how to encode, honouring an explicit ``--video`` choice.""" + device = device or os.environ.get("HW_DEVICE") or DEFAULT_DEVICE + + if video == "copy": + return HwContext(encoder="copy") + + if video == "auto": + chain = HW_ENCODER_CHAIN.get(codec) + if chain is None: + raise Die(f"unknown --codec {codec!r}") + + attempted: list[str] = [] + + for encoder, family in chain: + if family == "vaapi": + if not _vaapi_device_readable(device): + attempted.append(f"{encoder} (unreadable {device})") + continue + init = ["-init_hw_device", f"vaapi=va:{device}", "-filter_hw_device", "va"] + if _encoder_works(ffmpeg, encoder, init, device): + return HwContext( + encoder=encoder, + device=device, + is_vaapi=True, + note="hardware (VA-API)", + ) + attempted.append(f"{encoder} (device rejects it)") + else: + init = [] + if _encoder_works(ffmpeg, encoder, init, family): + return HwContext(encoder=encoder, note=f"hardware ({family})") + attempted.append(f"{encoder} (unsupported)") + + if not allow_software: + raise Die( + "no working hardware encoder found and software fallback is disabled; " + f"tried {', '.join(attempted)}. Diagnose with: python3 scripts/hwprobe.py" + ) + + report.verbose(f"no hardware encoder for {codec}: {', '.join(attempted)}") + + return HwContext(encoder="libx264" if codec == "h264" else "libx265", note="software") + + if video in ("x264", "x265"): + return HwContext(encoder=VIDEO_ENCODERS[video][1], note=f"software ({video})") + + raise Die(f"unknown --video {video!r}") + + +def video_args(context: HwContext, *, quality: bool = True) -> list[str]: + """Encoder arguments for the selected context.""" + if context.encoder == "copy": + return ["-c:v", "copy"] + + args = ["-c:v", context.encoder] + + if context.encoder.startswith("libx") and quality: + args += X264_QUALITY + + return args + + +def pix_fmt_args(context: HwContext) -> list[str]: + # Other pixel formats come from the VA-API upload filter; without a filter + # chain, hardware encoders still need 4:2:0 input. + if context.is_vaapi: + return [] + + return ["-pix_fmt", "yuv420p"] + + +def audio_args(fmt: str) -> tuple[list[str], str]: + if fmt not in AUDIO_FORMATS: + raise Die(f"unknown --format {fmt!r}; choose from {', '.join(AUDIO_FORMATS)}") + + extension, container, codec = AUDIO_FORMATS[fmt] + + return container + codec, extension or ("") + + +def resolve_output(output: str | None, stem: str, fmt: str, suffix: str) -> str: + """Work out the output filename, fixing up the extension for the container.""" + if output: + return output + + extension = AUDIO_FORMATS[fmt][0] + if extension and not stem.lower().endswith(f".{extension}"): + return f"{stem}{suffix}.{extension}" + + return f"{stem}{suffix}" + + +def build_filter(context: HwContext, *filters: str) -> str | None: + """Join filter fragments, appending the VA-API upload step when needed.""" + parts = [f for f in filters if f] + upload = context.filter_suffix() + + if upload: + parts.append(upload) + + return ",".join(parts) if parts else None + + +# --------------------------------------------------------------------------- # +# Commands +# --------------------------------------------------------------------------- # + + +def cmd_mux(args: argparse.Namespace, context: HwContext, report: Report) -> int: + """Attach audio to a video-only file. Video stream is stream-copied.""" + codec_args, _ = audio_args(args.format) + output = resolve_output(args.output, os.path.splitext(args.input_video)[0], args.format, "_mux") + + filter_chain = build_filter(context, *args.vf) if context.is_vaapi else (",".join(args.vf) or None) + video = video_args(context, quality=False) if context.is_vaapi else ["-c:v", "copy"] + + cmd = [ + args.ffmpeg, "-y", + *context.input_args(), + "-i", args.input_video, + "-i", args.audio, + "-map", "0:v:0", "-map", "1:a:0", + *video, + *codec_args, + ] + + if filter_chain: + cmd += ["-vf", filter_chain] + + cmd += ["-shortest", output] + + run(cmd, report=report, dry_run=args.dry_run) + if not args.dry_run: + report.ok(f"muxed -> {output}") + + return 0 + + +def cmd_bake(args: argparse.Namespace, context: HwContext, report: Report) -> int: + """Burn ASS subtitles into the video stream.""" + if not os.path.isfile(args.subtitles): + raise Die(f"no such subtitle file: {args.subtitles}") + + # libass needs the filename escaped for the filtergraph; ':' and '\' and "'" + # all have meaning there. + escaped = ( + args.subtitles.replace("\\", "\\\\") + .replace(":", "\\:") + .replace("'", r"\'") + .replace("[", r"\[") + .replace("]", r"\]") + .replace(",", r"\,") + ) + + # Burning subtitles means changing pixels, so the video stream cannot be copied. + # Fail early with a clear message instead of letting ffmpeg refuse the filter. + if context.encoder == "copy": + raise Die( + "cannot bake subtitles with --video copy: burning them in requires " + "re-encoding. Use --video auto (default) or --video x264." + ) + + filter_chain = build_filter(context, f"ass={escaped}", *args.vf) + output = args.output or _append_suffix(args.input_video, "_sub") + + codec_args, _ = audio_args(args.format) if args.audio_format != "keep" else (["-c:a", "copy"], None) + + cmd = [ + args.ffmpeg, "-y", + *context.input_args(), + "-i", args.input_video, + "-vf", filter_chain or "null", + *video_args(context), + *pix_fmt_args(context), + *codec_args, + "-shortest" if args.shortest else "-map_metadata", "-1", + output, + ] + + run(cmd, report=report, dry_run=args.dry_run) + if not args.dry_run: + report.ok(f"subtitles baked -> {output}") + + return 0 + + +def cmd_still(args: argparse.Namespace, context: HwContext, report: Report) -> int: + """Build a video from one image and one audio file (the no-lyrics render).""" + codec_args, _ = audio_args(args.format) + output = resolve_output(args.output, os.path.splitext(args.image)[0], args.format, "_pv") + + filters = [f"fps={args.fps}", f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease", "pad=ceil(iw/2)*2:ceil(ih/2)*2"] + + if context.is_vaapi: + filter_chain = build_filter(context, *filters) + cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, *context.input_args(), "-vf", filter_chain] + else: + cmd = [args.ffmpeg, "-y", "-loop", "1", "-i", args.image, "-i", args.audio, "-vf", ",".join(filters)] + + cmd += ["-r", str(args.fps), *video_args(context), *pix_fmt_args(context), *codec_args, "-shortest", output] + + run(cmd, report=report, dry_run=args.dry_run) + if not args.dry_run: + report.ok(f"still-image video -> {output}") + + return 0 + + +def cmd_strip(args: argparse.Namespace, context: HwContext | None, report: Report) -> int: + """Extract the audio track as MP3.""" + output = args.output or os.path.splitext(args.input_video)[0] + ".mp3" + + cmd = [ + args.ffmpeg, "-y", "-i", args.input_video, "-vn", + "-ar", str(args.sample_rate), "-ac", str(args.channels), + "-c:a", "libmp3lame", "-b:a", args.bitrate, + output, + ] + + run(cmd, report=report, dry_run=args.dry_run) + if not args.dry_run: + report.ok(f"audio -> {output}") + + return 0 + + +def cmd_probe(args: argparse.Namespace, context: HwContext | None, report: Report) -> int: + """Print a compact stream summary for a media file.""" + if not os.path.isfile(args.media[0]): + raise Die(f"no such file: {args.media[0]}") + + cmd = [ + "ffprobe" if args.ffmpeg.endswith("ffmpeg") else args.ffmpeg.replace("ffmpeg", "ffprobe"), + "-hide_banner", "-loglevel", "error", + "-show_entries", "stream=index,codec_type,codec_name,width,height,r_frame_rate,channels,sample_rate,bit_rate", + "-show_entries", "format=duration,size,format_name", + "-of", "json", + *args.media, + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except OSError as exc: + raise Die(f"could not run ffprobe: {exc}") from exc + + if result.returncode != 0: + raise Die(f"ffprobe failed: {(result.stderr or '').strip()}") + + data = json.loads(result.stdout or "{}") + + rows = [] + for stream in data.get("streams", []): + kind = stream.get("codec_type", "?") + if kind == "video": + detail = f"{stream.get('width')}x{stream.get('height')} @ {stream.get('r_frame_rate')}" + elif kind == "audio": + detail = f"{stream.get('channels')}ch @ {stream.get('sample_rate')} Hz" + else: + detail = "" + rows.append((str(stream.get("index", "")), kind, stream.get("codec_name", "?"), detail)) + + report.info(text_table(("stream", "type", "codec", "detail"), rows)) + + fmt = data.get("format", {}) + duration = fmt.get("duration") + size = fmt.get("size") + if duration: + report.info(f"\ncontainer: {fmt.get('format_name', '?')}") + report.info(f"duration: {float(duration):.2f}s") + if size: + report.info(f"size: {int(size):,} bytes") + + return 0 + + +def _append_suffix(path: str, suffix: str) -> str: + stem, extension = os.path.splitext(path) + + return f"{stem}{suffix}{extension}" + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def _add_video_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--video", + default=None, + choices=("auto", "x264", "x265", "copy"), + help="video encoder: auto (hardware if possible), x264, x265, or copy (default: auto)", + ) + parser.add_argument( + "--codec", + default=None, + choices=("h264", "hevc", "av1"), + help="hardware codec family to use when --video auto (default: h264)", + ) + parser.add_argument( + "--device", + default=None, + metavar="PATH", + help=f"VA-API render node (default: $HW_DEVICE or {DEFAULT_DEVICE})", + ) + parser.add_argument( + "--vf", + action="append", + default=[], + metavar="FILTER", + help="extra video filter, repeatable (appended after the built-in chain)", + ) + + +def _add_audio_options(parser: argparse.ArgumentParser, *, default: str = "flac") -> None: + parser.add_argument( + "-f", + "--format", + default=default, + choices=tuple(AUDIO_FORMATS), + help=f"audio format preset (default: {default})", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="ffmpeg_tools", + description="FFmpeg front-end for NCMR song videos (mux, bake, still, strip, probe).", + epilog="Run `%(prog)s hwprobe` for a separate encoder report, or see scripts/hwprobe.py.", + ) + add_common_arguments(parser) + add_ffmpeg_argument(parser) + + subparsers = parser.add_subparsers(dest="mode", metavar="MODE") + + mux = subparsers.add_parser("mux", help="attach audio to a video-only file, video copied as-is") + add_common_arguments(mux, suppress_defaults=True) + mux.add_argument("input_video", help="video-only input (e.g. the Remotion render)") + mux.add_argument("audio", help="audio input (e.g. the exported song)") + mux.add_argument("-o", "--output", help="output path") + _add_audio_options(mux) + _add_video_options(mux) + mux.set_defaults(func=cmd_mux) + + bake = subparsers.add_parser("bake", help="burn an ASS subtitle file into a video") + add_common_arguments(bake, suppress_defaults=True) + bake.add_argument("input_video", help="input video") + bake.add_argument("subtitles", help="ASS subtitle file") + bake.add_argument("-o", "--output", help="output path (default: