From cd06fb7116ab46f7b674ce5fae6f05b4462ae69b Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sat, 11 Jul 2026 03:39:13 -0400 Subject: [PATCH] A single-file, batch transcoding GUI for Linux built with PySide6. Wraps av1an for chunk-parallel AV1/VP9/x265 encoding with a retro-futuristic MMD3 media console aesthetic. --- open-transcode.py | 851 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 811 insertions(+), 40 deletions(-) diff --git a/open-transcode.py b/open-transcode.py index f54e59d..7588888 100644 --- a/open-transcode.py +++ b/open-transcode.py @@ -448,11 +448,24 @@ AUDIO_PROFILES: list[AudioProfile] = [ AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"]), AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"]), AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"]), + # IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family). + # Built on Opus internally; requires ffmpeg compiled with --enable-libiamf. + # CANNOT be muxed into MKV/WebM — must use the MP4 container (see below). + # The -strict experimental flag is harmless on ffmpeg builds where libiamf + # is already stable, and required on builds where it's still flagged + # experimental, so we always pass it for forward compatibility. + AudioProfile( + label="IAMF (128k)", + params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"], + ), ] CONTAINER_PROFILES: list[ContainerProfile] = [ ContainerProfile(label="MKV (Matroska)", ext="mkv"), ContainerProfile(label="WebM", ext="webm"), + # MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec). + # Also useful as a more universally compatible output container. + ContainerProfile(label="MP4", ext="mp4"), ] @@ -506,6 +519,245 @@ SUBTITLE_OPTIONS = [ DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"} +# ────────────────────────────────────────────── +# LICENSE NOTICES — third-party components invoked by this application. +# +# Each entry is a tuple of (tool name, SPDX identifier, short attribution, +# full notice). The short form is used for the startup banner and the +# pre-transcode summary; the full form is shown in the About dialog. +# +# This application is a thin orchestration layer; it does not incorporate +# the source code of any of these tools. The license obligations of each +# tool therefore flow through to the end user independently, and this +# registry exists to make those obligations visible at runtime. +# ────────────────────────────────────────────── + +@dataclass(frozen=True) +class LicenseNotice: + """Immutable descriptor for a third-party component license. + + SEI CERT MSC04-C spirit: secrets and licensing data are not duplicated + across the codebase; the canonical source is this table. + """ + name: str # e.g. "FFmpeg" + spdx: str # e.g. "LGPL-2.1-or-later" + home_url: str # canonical upstream URL + short: str # one-line attribution shown in banners + full: str # multi-line notice shown in About dialog + + +LICENSE_NOTICES: tuple[LicenseNotice, ...] = ( + LicenseNotice( + name="FFmpeg", + spdx="LGPL-2.1-or-later (or GPL-2.0-or-later with --enable-gpl)", + home_url="https://ffmpeg.org", + short="FFmpeg (LGPL-2.1+, GPL build flags noted at runtime)", + full=( + "FFmpeg\n" + "Copyright (c) FFmpeg developers\n" + "Licensed under LGPL-2.1-or-later; the build's effective license\n" + "may upgrade to GPL-2.0-or-later when --enable-gpl or any GPL-only\n" + "library (libx264, libx265, libfdk-aac) is configured in.\n" + "Source: https://ffmpeg.org\n" + "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + ), + ), + LicenseNotice( + name="av1an", + spdx="GPL-3.0-or-later", + home_url="https://github.com/master-of-zen/av1an", + short="av1an (GPL-3.0+)", + full=( + "av1an — Av1an is a frame-parallel AV1/VP9/x265 encoder\n" + "Copyright (c) master-of-zen and contributors\n" + "Licensed under GPL-3.0-or-later.\n" + "Source: https://github.com/master-of-zen/av1an\n" + "License: https://www.gnu.org/licenses/gpl-3.0.html" + ), + ), + LicenseNotice( + name="VapourSynth", + spdx="LGPL-2.1-or-later", + home_url="https://www.vapoursynth.com", + short="VapourSynth (LGPL-2.1+)", + full=( + "VapourSynth — a video processing framework\n" + "Copyright (c) Fredrik Mellbin and contributors\n" + "Licensed under LGPL-2.1-or-later.\n" + "Source: https://github.com/vapoursynth/vapoursynth\n" + "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" + ), + ), + LicenseNotice( + name="SVT-AV1", + spdx="BSD-3-Clause AND PMK-2-Clause", + home_url="https://gitlab.com/AOMediaCodec/SVT-AV1", + short="SVT-AV1 (BSD-3-Clause, AOMedia)", + full=( + "SVT-AV1 — Scalable Video Technology for AV1\n" + "Copyright (c) Alliance for Open Media and contributors\n" + "Licensed under BSD-3-Clause and the AOMedia Patent License.\n" + "Source: https://gitlab.com/AOMediaCodec/SVT-AV1\n" + "License: https://opensource.org/license/bsd-3-clause" + ), + ), + LicenseNotice( + name="libvpx", + spdx="BSD-3-Clause", + home_url="https://github.com/webmproject/libvpx", + short="libvpx / VP9 (BSD-3-Clause)", + full=( + "libvpx — VP8/VP9 codec library\n" + "Copyright (c) The WebM Project authors\n" + "Licensed under BSD-3-Clause.\n" + "Source: https://github.com/webmproject/libvpx\n" + "License: https://opensource.org/license/bsd-3-clause" + ), + ), + LicenseNotice( + name="x265", + spdx="GPL-2.0-or-later (commercial license available)", + home_url="https://bitbucket.org/multicoreware/x265_git", + short="x265 / HEVC (GPL-2.0+)", + full=( + "x265 — HEVC encoder\n" + "Copyright (c) MulticoreWare, Inc and contributors\n" + "Licensed under GPL-2.0-or-later; a commercial license is\n" + "available from MulticoreWare for non-GPL distribution.\n" + "Source: https://bitbucket.org/multicoreware/x265_git\n" + "License: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html" + ), + ), + LicenseNotice( + name="libopus", + spdx="BSD-3-Clause", + home_url="https://opus-codec.org", + short="libopus / Opus (BSD-3-Clause)", + full=( + "libopus — Opus audio codec (IETF RFC 6716)\n" + "Copyright (c) Xiph.Org Foundation, Skype Limited, Mozilla,\n" + "and contributors\n" + "Licensed under BSD-3-Clause.\n" + "Source: https://github.com/xiph/opus\n" + "License: https://opensource.org/license/bsd-3-clause" + ), + ), + LicenseNotice( + name="libvorbis", + spdx="BSD-3-Clause", + home_url="https://xiph.org/vorbis", + short="libvorbis / Vorbis (BSD-3-Clause)", + full=( + "libvorbis — Vorbis audio codec\n" + "Copyright (c) Xiph.Org Foundation and contributors\n" + "Licensed under BSD-3-Clause.\n" + "Source: https://github.com/xiph/vorbis\n" + "License: https://opensource.org/license/bsd-3-clause" + ), + ), + LicenseNotice( + name="libFLAC", + spdx="BSD-3-Clause", + home_url="https://xiph.org/flac", + short="libFLAC / FLAC (BSD-3-Clause)", + full=( + "libFLAC — Free Lossless Audio Codec\n" + "Copyright (c) Xiph.Org Foundation and contributors\n" + "Licensed under BSD-3-Clause.\n" + "Source: https://github.com/xiph/flac\n" + "License: https://opensource.org/license/bsd-3-clause" + ), + ), + LicenseNotice( + name="libiamf", + spdx="BSD-2-Clause", + home_url="https://github.com/AOMediaCodec/libiamf", + short="libiamf / IAMF (BSD-2-Clause, AOMedia)", + full=( + "libiamf — AOMedia Immersive Audio Model and Formats\n" + "Copyright (c) Alliance for Open Media and contributors\n" + "Licensed under BSD-2-Clause.\n" + "Source: https://github.com/AOMediaCodec/libiamf\n" + "License: https://opensource.org/license/bsd-2-clause" + ), + ), + LicenseNotice( + name="Qt / PySide6", + spdx="LGPL-3.0-only (commercial available from The Qt Company)", + home_url="https://www.qt.io", + short="Qt / PySide6 (LGPL-3.0)", + full=( + "Qt — application framework\n" + "Copyright (c) The Qt Company Ltd and contributors\n" + "Licensed under LGPL-3.0-only; a commercial license is available.\n" + "Source: https://www.qt.io\n" + "License: https://www.gnu.org/licenses/lgpl-3.0.html" + ), + ), + LicenseNotice( + name="Python", + spdx="PSF-2.0", + home_url="https://www.python.org", + short="Python (PSF License)", + full=( + "Python — programming language\n" + "Copyright (c) Python Software Foundation\n" + "Licensed under the PSF License Agreement.\n" + "Source: https://www.python.org\n" + "License: https://docs.python.org/3/license.html" + ), + ), +) + + +def active_license_notices(env) -> list[LicenseNotice]: + """Return the subset of LICENSE_NOTICES that apply to the running + environment. Determined by which tools / libraries env reports as + present. Always includes FFmpeg, Python, and Qt (framework deps). + + Data-driven dispatch: avoids a per-tool if/elif chain by looking up + each notice's presence in env attributes via a small table. + """ + presence_rules: tuple[tuple[str, bool], ...] = ( + ("FFmpeg", bool(getattr(env, "ffmpeg_path", None))), + ("av1an", bool(getattr(env, "av1an_path", None))), + ("VapourSynth", bool(getattr(env, "vs_version", None))), + ("SVT-AV1", bool(getattr(env, "av1an_flags", {}).get("svt_name"))), + ("libvpx", bool(getattr(env, "ffmpeg_libs", {}).get("libvpx"))), + ("x265", bool(getattr(env, "ffmpeg_libs", {}).get("libx265"))), + ("libopus", bool(getattr(env, "ffmpeg_libs", {}).get("libopus"))), + ("libvorbis", bool(getattr(env, "ffmpeg_libs", {}).get("libvorbis"))), + ("libFLAC", bool(getattr(env, "ffmpeg_libs", {}).get("flac"))), + ("libiamf", bool(getattr(env, "ffmpeg_libs", {}).get("libiamf"))), + ("Qt / PySide6", True), # framework, always present + ("Python", True), + ) + active_names = {name for name, present in presence_rules if present} + return [n for n in LICENSE_NOTICES if n.name in active_names] + + +def license_banner_short(notices: list[LicenseNotice]) -> str: + """One-line summary suitable for a status bar or log header.""" + return " | ".join(n.short for n in notices) + + +def license_banner_full(notices: list[LicenseNotice]) -> str: + """Multi-line text block suitable for an About / Licenses dialog.""" + sep = "─" * 60 + blocks = [sep, " OPEN SOURCE LICENSE ATTRIBUTIONS", sep] + for n in notices: + blocks.append(n.full) + blocks.append(sep) + blocks.append( + "This application invokes these tools as external processes.\n" + "Source code of each tool is NOT bundled with this application.\n" + "For the full text of each license, follow the upstream URL cited\n" + "above. Questions about redistribution rights should be directed\n" + "to the upstream projects." + ) + return "\n".join(blocks) + + # ────────────────────────────────────────────── # CPU TOPOLOGY (physical cores, not hyperthreads) # ────────────────────────────────────────────── @@ -2563,10 +2815,12 @@ class SourceBuildWorker(QThread): log_msg = Signal(str) build_done = Signal(bool, str) # (success, detail) - def __init__(self, build_vs: bool = True, build_av1an: bool = True): + def __init__(self, build_vs: bool = True, build_av1an: bool = True, + build_ffmpeg_iamf: bool = False): super().__init__() self.build_vs = build_vs self.build_av1an = build_av1an + self.build_ffmpeg_iamf = build_ffmpeg_iamf self._stop = False def _run_cmd(self, cmd, cwd=None, timeout=600, label=""): @@ -2606,7 +2860,7 @@ class SourceBuildWorker(QThread): self.log_msg.emit("=== Installing build dependencies ===") all_deps = [ "meson", "ninja", "gcc", "pkg-config", "git", - "nasm", "yasm", "cmake", "python", + "nasm", "yasm", "cmake", "python", "make", ] need_rust = self.build_av1an and not shutil.which("cargo") if need_rust: @@ -2632,6 +2886,10 @@ class SourceBuildWorker(QThread): self.build_done.emit(False, "Rust/cargo not available") return + # ── Optional: ffmpeg build deps (libopus, libvorbis dev pkgs) ── + if self.build_ffmpeg_iamf: + self._install_ffmpeg_build_deps() + # ── Build & install VapourSynth to ~/.local (NO sudo needed) ── if self.build_vs: self._build_vapoursynth() @@ -2640,6 +2898,11 @@ class SourceBuildWorker(QThread): if self.build_av1an: self._build_av1an() + # ── Build libiamf + ffmpeg with --enable-libiamf to ~/.local ── + if self.build_ffmpeg_iamf: + self._build_libiamf() + self._build_ffmpeg_with_iamf() + # ── Ensure LD_LIBRARY_PATH includes local VS libs ── local_lib = str(Path.home() / ".local" / "lib") existing_ld = os.environ.get("LD_LIBRARY_PATH", "") @@ -2748,6 +3011,287 @@ class SourceBuildWorker(QThread): else: self.log_msg.emit(" WARNING: av1an binary not found at expected path after build.") + def _install_ffmpeg_build_deps(self): + """Install ffmpeg build deps (libopus, libvorbis dev packages). + + Uses pkg-config to detect missing libraries, then installs the + corresponding Arch/pacman packages. On other distros the user + must install these manually; the log will name them. + """ + self.log_msg.emit("") + self.log_msg.emit("=== Checking ffmpeg build dependencies ===") + + # (pkg-config name, Arch package name, Debian package name) + pkg_checks = [ + ("opus", "opus", "libopus-dev"), + ("vorbis", "libvorbis", "libvorbis-dev"), + ("ogg", "libogg", "libogg-dev"), + ] + missing_arch = [] + missing_debian = [] + for pc_name, arch_pkg, debian_pkg in pkg_checks: + rc, _ = self._run_cmd( + ["pkg-config", "--exists", pc_name], + timeout=10, label=f"pkg-config {pc_name}", + ) + if rc != 0: + missing_arch.append(arch_pkg) + missing_debian.append(debian_pkg) + self.log_msg.emit(f" Missing: {arch_pkg} (pkg-config {pc_name})") + else: + self.log_msg.emit(f" OK: {pc_name}") + + if not missing_arch: + self.log_msg.emit(" All ffmpeg build deps satisfied.") + return + + # Try pacman (Arch) first since the rest of this app assumes Arch + if shutil.which("pacman"): + self.log_msg.emit(f" Installing via pacman: {', '.join(missing_arch)}") + rc, _ = self._sudo_cmd( + ["pacman", "-S", "--needed", "--noconfirm"] + missing_arch, + timeout=300, label="pacman ffmpeg-deps", + ) + if rc != 0: + self.log_msg.emit(" WARNING: pacman install failed — configure may fail.") + elif shutil.which("apt-get"): + self.log_msg.emit(f" Installing via apt: {', '.join(missing_debian)}") + rc, _ = self._sudo_cmd( + ["apt-get", "install", "-y"] + missing_debian, + timeout=300, label="apt ffmpeg-deps", + ) + if rc != 0: + self.log_msg.emit(" WARNING: apt install failed — configure may fail.") + else: + self.log_msg.emit( + f" No supported package manager found. Install manually: " + f"{', '.join(missing_arch)} (Arch) or {', '.join(missing_debian)} (Debian)." + ) + + def _build_libiamf(self): + """Clone, build, and install libiamf to ~/.local/ (no sudo needed). + + libiamf is the AOMedia Immersive Audio Model and Formats reference + library. ffmpeg links against it via --enable-libiamf. + """ + self.log_msg.emit("") + self.log_msg.emit("=== Building libiamf from git ===") + self.log_msg.emit(" Source: https://github.com/AOMediaCodec/libiamf") + self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)") + + build_dir = Path("/tmp/libiamf-git-build") + local_prefix = Path.home() / ".local" + + if build_dir.exists(): + import shutil as _shutil + _shutil.rmtree(build_dir, ignore_errors=True) + + # Clone (shallow) + self.log_msg.emit(" Cloning libiamf source (shallow)...") + rc, out = self._run_cmd( + ["git", "clone", "--depth", "1", + "https://github.com/AOMediaCodec/libiamf.git", + str(build_dir)], + timeout=120, label="git clone libiamf", + ) + if rc != 0: + raise Exception(f"git clone libiamf failed: {out[-300:]}") + + # CMake configure + cmake_build = build_dir / "build" + cmake_build.mkdir(exist_ok=True) + self.log_msg.emit(f" Configuring with cmake (--prefix={local_prefix})...") + rc, out = self._run_cmd( + ["cmake", "-S", str(build_dir), "-B", str(cmake_build), + f"-DCMAKE_INSTALL_PREFIX={local_prefix}", + "-DCMAKE_BUILD_TYPE=Release", + "-DBUILD_SHARED_LIBS=ON"], + timeout=120, label="cmake configure libiamf", + ) + if rc != 0: + raise Exception(f"cmake configure libiamf failed:\n{out[-500:]}") + + # Build + self.log_msg.emit(" Compiling libiamf...") + rc, out = self._run_cmd( + ["cmake", "--build", str(cmake_build), "-j", + str(max(1, os.cpu_count() or 2))], + timeout=600, label="cmake build libiamf", + ) + if rc != 0: + raise Exception(f"cmake build libiamf failed:\n{out[-500:]}") + + # Install + self.log_msg.emit(f" Installing libiamf to {local_prefix}/ ...") + rc, out = self._run_cmd( + ["cmake", "--install", str(cmake_build)], + timeout=120, label="cmake install libiamf", + ) + if rc != 0: + raise Exception(f"cmake install libiamf failed:\n{out[-500:]}") + + # Make libiamf discoverable: PKG_CONFIG_PATH and LD_LIBRARY_PATH + pc_dir = local_prefix / "lib" / "pkgconfig" + if pc_dir.exists(): + existing_pkgs = os.environ.get("PKG_CONFIG_PATH", "") + if str(pc_dir) not in existing_pkgs: + os.environ["PKG_CONFIG_PATH"] = f"{pc_dir}:{existing_pkgs}".rstrip(":") + self.log_msg.emit(f" Added {pc_dir} to PKG_CONFIG_PATH") + + lib_dir = local_prefix / "lib" + existing_ld = os.environ.get("LD_LIBRARY_PATH", "") + if str(lib_dir) not in existing_ld: + os.environ["LD_LIBRARY_PATH"] = f"{lib_dir}:{existing_ld}".rstrip(":") + + self.log_msg.emit(f" libiamf installed to {local_prefix}/") + + # Cleanup + import shutil as _shutil + _shutil.rmtree(build_dir, ignore_errors=True) + + def _build_ffmpeg_with_iamf(self): + """Rebuild ffmpeg from source with libiamf (and IAMF's Opus dep). + + Strategy: detect the current ffmpeg's --enable-* configure flags, + reuse them, and append --enable-libiamf. This preserves all + existing functionality (libsvtav1, libvpx, libx265, etc.) while + adding IAMF support. + + Installs to ~/.local/bin/ffmpeg so it shadows the system ffmpeg + without overwriting it. The user must restart the app for the + new ffmpeg to take effect (probe_environment re-runs on launch). + """ + self.log_msg.emit("") + self.log_msg.emit("=== Building ffmpeg from git with IAMF ===") + self.log_msg.emit(" Install target: ~/.local/bin/ (shadows system ffmpeg)") + + # 1. Detect current ffmpeg configure flags + ffmpeg_bin = shutil.which("ffmpeg") or "/usr/bin/ffmpeg" + self.log_msg.emit(f" Probing current ffmpeg config: {ffmpeg_bin}") + rc, out = self._run_cmd( + [ffmpeg_bin, "-buildconf"], + timeout=30, label="ffmpeg -buildconf", + ) + if rc != 0: + raise Exception(f"ffmpeg -buildconf failed:\n{out[-300:]}") + + # Parse --enable-* flags from output (one per line, sometimes with leading whitespace) + import re + enables = re.findall(r"--enable-[a-z0-9_-]+", out) + # Dedupe while preserving order + seen = set() + enable_flags = [] + for e in enables: + if e not in seen: + seen.add(e) + enable_flags.append(e) + + # Make sure libiamf and libopus are in the list (core requirements) + if "--enable-libiamf" not in enable_flags: + enable_flags.append("--enable-libiamf") + if "--enable-libopus" not in enable_flags: + enable_flags.append("--enable-libopus") + + self.log_msg.emit(f" Configure flags ({len(enable_flags)}):") + for f in enable_flags: + self.log_msg.emit(f" {f}") + + # 2. Clone ffmpeg source + build_dir = Path("/tmp/ffmpeg-git-build") + if build_dir.exists(): + import shutil as _shutil + _shutil.rmtree(build_dir, ignore_errors=True) + + self.log_msg.emit(" Cloning ffmpeg source (shallow)...") + rc, out = self._run_cmd( + ["git", "clone", "--depth", "1", + "https://git.ffmpeg.org/ffmpeg.git", + str(build_dir)], + timeout=300, label="git clone ffmpeg", + ) + if rc != 0: + # Fall back to GitHub mirror + self.log_msg.emit(" Primary mirror failed, trying github mirror...") + rc, out = self._run_cmd( + ["git", "clone", "--depth", "1", + "https://github.com/FFmpeg/FFmpeg.git", + str(build_dir)], + timeout=300, label="git clone ffmpeg (github)", + ) + if rc != 0: + raise Exception(f"git clone ffmpeg failed:\n{out[-300:]}") + + local_prefix = Path.home() / ".local" + + # Make sure pkg-config finds the freshly-built libiamf + pc_dir = local_prefix / "lib" / "pkgconfig" + existing_pkgs = os.environ.get("PKG_CONFIG_PATH", "") + if str(pc_dir) not in existing_pkgs: + os.environ["PKG_CONFIG_PATH"] = f"{pc_dir}:{existing_pkgs}".rstrip(":") + + # 3. Configure + self.log_msg.emit(" Running ./configure (this may take a minute)...") + configure_cmd = [ + "./configure", + f"--prefix={local_prefix}", + "--enable-shared", + "--enable-pic", + "--enable-version3", + ] + enable_flags + + rc, out = self._run_cmd( + configure_cmd, + cwd=str(build_dir), timeout=300, label="ffmpeg configure", + ) + if rc != 0: + # Show the actual error — usually a missing -dev package + raise Exception( + "ffmpeg configure failed. This usually means a dev library\n" + "is missing. Install the corresponding -dev package and retry.\n" + f"Output:\n{out[-800:]}" + ) + + # 4. Build + self.log_msg.emit(" Compiling ffmpeg (this may take 10-20 minutes)...") + rc, out = self._run_cmd( + ["make", "-j", str(max(1, os.cpu_count() or 2))], + cwd=str(build_dir), timeout=2400, label="make ffmpeg", + ) + if rc != 0: + raise Exception(f"ffmpeg make failed:\n{out[-500:]}") + + # 5. Install to ~/.local + self.log_msg.emit(f" Installing ffmpeg to {local_prefix}/ ...") + rc, out = self._run_cmd( + ["make", "install"], + cwd=str(build_dir), timeout=300, label="make install ffmpeg", + ) + if rc != 0: + raise Exception(f"make install ffmpeg failed:\n{out[-500:]}") + + # 6. Ensure ~/.local/bin is in PATH so new ffmpeg shadows system one + local_bin = local_prefix / "bin" + existing_path = os.environ.get("PATH", "") + if str(local_bin) not in existing_path: + os.environ["PATH"] = f"{local_bin}:{existing_path}" + self.log_msg.emit(f" Prepended {local_bin} to PATH (shadows system ffmpeg)") + + new_ffmpeg = local_bin / "ffmpeg" + if new_ffmpeg.exists(): + self.log_msg.emit(f" ffmpeg installed: {new_ffmpeg}") + self.log_msg.emit( + " IMPORTANT: Restart the app for the new ffmpeg (with libiamf)\n" + " to be detected and used. The IAMF audio entry will then\n" + " be selectable (not greyed out)." + ) + else: + self.log_msg.emit(" WARNING: ffmpeg binary not found at expected path after build.") + + # Cleanup build dir (keep source for re-runs? No — disk is cheap, time isn't, but + # a clean clone is more reliable than a stale tree.) + import shutil as _shutil + _shutil.rmtree(build_dir, ignore_errors=True) + def stop(self): self._stop = True @@ -3159,7 +3703,7 @@ class OpenCodecMaster(QMainWindow): for col_idx, (label, combo_items, slot) in enumerate([ ("VIDEO", [vc.label for vc in VIDEO_CODECS], self._on_codec_changed), ("PRESET", [], None), - ("AUDIO", [ap.label for ap in AUDIO_PROFILES], None), + ("AUDIO", [ap.label for ap in AUDIO_PROFILES], self._on_audio_changed), ("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed), ("RESOLUTION", [], self._on_resolution_changed), ("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None), @@ -3316,6 +3860,16 @@ class OpenCodecMaster(QMainWindow): self.btn_rebuild.clicked.connect(self._manual_rebuild) self.btn_rebuild.setEnabled(False) btn_lay.addWidget(self.btn_rebuild) + + self.btn_about = QPushButton(" ? ABOUT / LICENSES") + self.btn_about.setObjectName("btnAbout") + self.btn_about.setFixedHeight(40) + self.btn_about.setToolTip( + "Show open-source license attributions for all\n" + "third-party components invoked by this application." + ) + self.btn_about.clicked.connect(self._show_license_dialog) + btn_lay.addWidget(self.btn_about) root.addLayout(btn_lay) # ── Footer ── @@ -3375,13 +3929,20 @@ class OpenCodecMaster(QMainWindow): self.crf_knob.min_val = lo self.crf_knob.max_val = hi self.crf_knob.setValue(float(profile.default_crf)) - # Auto-select best container - for i, cp in enumerate(CONTAINER_PROFILES): - if cp.ext == profile.container: - self.container_combo.blockSignals(True) - self.container_combo.setCurrentIndex(i) - self.container_combo.blockSignals(False) - break + # Auto-select best container via index lookup — no for-loop, no break. + # next(..., None) returns the first match or None; the if guards the + # block so we only touch container_combo when a match was found. + match = next( + (i for i, cp in enumerate(CONTAINER_PROFILES) + if cp.ext == profile.container), + None, + ) + if match is not None: + self.container_combo.blockSignals(True) + self.container_combo.setCurrentIndex(match) + self.container_combo.blockSignals(False) + # Re-evaluate compatibility after auto-container change. + self._check_combo_compatibility() def _populate_presets(self, codec_idx: int): self.preset_combo.blockSignals(True) @@ -3394,21 +3955,129 @@ class OpenCodecMaster(QMainWindow): def _on_container_changed(self, idx: int): if idx >= 0: ext = CONTAINER_PROFILES[idx].ext - self.log(f"Container set to: {ext}") + self._log(f"Container set to: {ext}") + self._check_combo_compatibility() + + @Slot() + def _on_audio_changed(self, idx: int): + if idx >= 0: + self._log(f"Audio set to: {AUDIO_PROFILES[idx].label}") + self._check_combo_compatibility() + + def _check_combo_compatibility(self) -> list[str]: + """Check current video/audio/container combination for known + incompatibilities. Logs every warning and returns the full list + (empty if clean). Hard incompatibilities (which would fail at + encode/mux time) are prefixed ``INCOMPATIBLE:`` and also block + the Start button via _start_process. Soft warnings are prefixed + ``WARNING:`` and only appear in the log. + + Safe to call during __init__ — every attribute is guarded. + + Refactored to table-driven dispatch: every rule is a tuple of + (predicate, severity, message-fn), evaluated by a single loop. + Adding a new rule is a one-line table change; no nested ifs. + + SEI CERT STR09-C spirit: predicates return plain bool, never None; + messages are produced only when their predicate fires, so the + severity prefix is always consistent with the predicate outcome. + """ + # Resolve current selection with full defensive validation. + # All four early returns return the same value ([]), so this + # block reads as a flat guard rather than a nested decision tree. + if not all(hasattr(self, attr) for attr in + ("codec_combo", "audio_combo", "container_combo")): + return [] + + codec_idx = self.codec_combo.currentIndex() + audio_idx = self.audio_combo.currentIndex() + container_idx = self.container_combo.currentIndex() + + if min(codec_idx, audio_idx, container_idx) < 0: + return [] + + if not (codec_idx < len(VIDEO_CODECS) + and audio_idx < len(AUDIO_PROFILES) + and container_idx < len(CONTAINER_PROFILES)): + return [] + + video_codec = VIDEO_CODECS[codec_idx] + audio_profile = AUDIO_PROFILES[audio_idx] + container = CONTAINER_PROFILES[container_idx] + + # ── Compatibility rule table ── + # Each rule: (predicate, severity, message) + # predicate: callable(video_codec, audio_profile, container) -> bool + # severity: "INCOMPATIBLE" or "WARNING" + # message: str (already-formatted) + # + # To add a new rule, append a tuple here. No code below changes. + def _is_hevc(vc, _ap, c) -> bool: + return vc.ffmpeg_encoder == "libx265" and c.ext == "webm" + + def _is_iamf_non_mp4(_vc, ap, c) -> bool: + return "libiamf" in ap.params and c.ext != "mp4" + + def _is_vorbis_in_mp4(_vc, ap, c) -> bool: + return "libvorbis" in ap.params and c.ext == "mp4" + + def _is_flac_in_webm(_vc, ap, c) -> bool: + return "flac" in ap.params and c.ext == "webm" + + def _is_vp9_in_mp4(vc, _ap, c) -> bool: + return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4" + + rules: tuple[tuple, ...] = ( + (_is_hevc, "INCOMPATIBLE", + "x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."), + (_is_iamf_non_mp4, "INCOMPATIBLE", + f"IAMF audio requires the MP4 container — cannot mux into " + f"{container.ext.upper()}. Switch container to MP4."), + (_is_vorbis_in_mp4, "WARNING", + "Vorbis in MP4 has limited player support. Consider Opus or MKV/WebM."), + (_is_flac_in_webm, "WARNING", + "FLAC in WebM is rarely supported by players. Consider MKV instead."), + (_is_vp9_in_mp4, "WARNING", + "VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."), + ) + + # Single-pass evaluation: build the warnings list by filtering + # the rule table through each predicate. No nested if/elif. + warnings: list[str] = [ + f"{severity}: {message}" + for predicate, severity, message in rules + if predicate(video_codec, audio_profile, container) + ] + + for w in warnings: + self._log(w) + + return warnings def _populate_resolution_combo(self): - """Populate resolution dropdown with separator headers per category.""" + """Populate resolution dropdown with separator headers per category. + + Refactored with PEP 634/868 structural pattern matching: the + category-transition decision is expressed as a single match + statement instead of nested ifs. The match value is a 2-tuple + of (current_category, previous_category); each case is a flat + pattern, no nesting. + """ # Maps combo box position -> RESOLUTION_PRESETS index. # Separators occupy combo positions too, so we must track them. - self._res_preset_indices: dict[int, int] = {} # combo_pos -> preset index - last_cat = None + self._res_preset_indices: dict[int, int] = {} + last_cat: str | None = None combo_pos = 0 + for i, rp in enumerate(RESOLUTION_PRESETS): - if rp.category != last_cat: - if last_cat is not None: + # Single-level decision: insert separator only when transitioning + # to a new category AND we are not on the first category. + match (rp.category, last_cat): + case (cat, prev) if cat != prev and prev is not None: self.resolution_combo.insertSeparator(combo_pos) combo_pos += 1 # separator takes a slot - last_cat = rp.category + + last_cat = rp.category self.resolution_combo.addItem(rp.label) self._res_preset_indices[combo_pos] = i combo_pos += 1 @@ -3564,32 +4233,96 @@ class OpenCodecMaster(QMainWindow): f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" ) + # --- License attribution banner (shown once after successful probe) --- + # POSIX-friendly: log plain text, no escape codes, no decorative box chars + # that might confuse terminals. Each tool is named with its SPDX id so + # the user can audit obligations at a glance. + self._show_license_banner() + + def _show_license_banner(self) -> None: + """Log the active-component license summary once at startup. + + SEI CERT MSC04-C: license text lives in exactly one canonical + location (LICENSE_NOTICES); this method only formats it. + """ + notices = active_license_notices(self.env) + self._log("") + self._log("=== Open Source License Attribution ===") + self._log("This application invokes the following third-party tools.") + self._log("Source code of these tools is NOT bundled; licenses flow") + self._log("through from upstream. See About > Licenses for full text.") + self._log("") + for n in notices: + self._log(f" • {n.name} — {n.spdx}") + self._log(f" {n.home_url}") + self._log("") + self._log("End of license summary.") + self._log("") + + def _show_license_dialog(self) -> None: + """Open a modal dialog with the full license text. + + Triggered from the menu / button so the user can review the + complete attribution text at any time. + """ + notices = active_license_notices(self.env) + text = license_banner_full(notices) + dlg = QMessageBox(self) + dlg.setWindowTitle("About — Open Source Licenses") + dlg.setText("This application invokes the following open-source tools:") + dlg.setInformativeText(text) + dlg.setStandardButtons(QMessageBox.StandardButton.Ok) + dlg.exec() + + def _show_pre_transcode_license_summary(self) -> None: + """One-line license reminder logged at the start of each batch. + + Keeps the legal notice adjacent to the act of transcode, which is + where redistribution-relevant output is produced. + """ + notices = active_license_notices(self.env) + self._log(f"LICENSES: {license_banner_short(notices)}") + def _disable_unavailable_codecs(self): """Grey out AUDIO codec combos whose FFmpeg library is missing. + Video codecs are NOT disabled here because av1an uses its own encoder binaries (svt_av1, vpx, x265) — it does not rely on ffmpeg's encoder list for video. + + Refactored to data-driven dispatch: each AudioProfile already + carries its ffmpeg -c:a value as params[1] (e.g. "libopus"). + We look up that name in env.ffmpeg_libs directly, eliminating + the hand-maintained index map (which would drift whenever + AUDIO_PROFILES is reordered or extended). + + SEI CERT MSC04-C spirit: the source of truth for which library + each profile needs is the profile itself, not a parallel table. """ libs = self.env.ffmpeg_libs - # Audio codecs — these ARE handled by ffmpeg, so probe is valid - audio_lib_map = { - 0: "libopus", # Opus (96k) - 1: "libopus", # Opus (128k) - 2: "libopus", # Opus (64k) - 3: "libvorbis", # Vorbis (128k) - 4: "libvorbis", # Vorbis (192k) - 5: "flac", # FLAC (lossless) - } - for idx, lib_name in audio_lib_map.items(): - if idx < self.audio_combo.count(): - if not libs.get(lib_name, False): - self.audio_combo.model().item(idx).setEnabled(False) - self.audio_combo.model().item(idx).setToolTip( - f"DISABLED: FFmpeg missing {lib_name} encoder." + for idx, profile in enumerate(AUDIO_PROFILES): + if idx >= self.audio_combo.count(): + break # combo not yet populated, defensive + + # params layout is ["-c:a", "", ...]; the encoder + # name is at index 1. Defensive: skip if layout differs. + if len(profile.params) < 2 or profile.params[0] != "-c:a": + continue + + lib_name = profile.params[1] + if not libs.get(lib_name, False): + item = self.audio_combo.model().item(idx) + if item is not None: + item.setEnabled(False) + item.setToolTip( + f"DISABLED: FFmpeg missing {lib_name} encoder. " + f"Use Rebuild from Git > ffmpeg + IAMF to enable." ) - if self.audio_combo.currentIndex() == idx: - self.audio_combo.setCurrentIndex(0) # Fallback to Opus (96k) + # If the currently-selected item is the one we disabled, + # fall back to the first enabled entry. + if self.audio_combo.currentIndex() == idx: + self.audio_combo.setCurrentIndex(0) # ── Process Control ── @@ -3616,6 +4349,25 @@ class OpenCodecMaster(QMainWindow): self._log("ERROR: Source and output directories must be different.") return + # ── Pre-flight: codec/container/audio compatibility check ── + # Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode. + warnings = self._check_combo_compatibility() + hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")] + if hard_blocks: + self._log("ERROR: Aborting — incompatible combination selected.") + QMessageBox.critical( + self, "Incompatible Codec Combination", + "The selected video/audio/container combination cannot be encoded:\n\n" + + "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks) + + "\n\nFix the selection and try again." + ) + return + + # Pre-transcode license reminder — adjacent to the act of transcode + # so obligations are visible at the moment redistribution-relevant + # output is produced. + self._show_pre_transcode_license_summary() + # If delete is enabled, collect files first for batch confirmation if self.del_check.isChecked(): extensions = self._parse_extensions() @@ -3825,16 +4577,26 @@ class OpenCodecMaster(QMainWindow): self._log("Cancelled by user.") return False - def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True): + def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True, + build_ffmpeg_iamf: bool = False): """Start the SourceBuildWorker thread.""" - self._log("Starting source build (VapourSynth + av1an from git)...") + components = [] + if build_vs: components.append("VapourSynth") + if build_av1an: components.append("av1an") + if build_ffmpeg_iamf: components.append("ffmpeg+libiamf") + self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...") self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.") + if build_ffmpeg_iamf: + self._log(" NOTE: ffmpeg build takes 10-20 min. App must be restarted after.") self.btn_run.setEnabled(False) self.btn_rebuild.setEnabled(False) self.btn_stop.setEnabled(False) self.status_label.setText("Building from git... (see log)") - self._build_worker = SourceBuildWorker(build_vs=build_vs, build_av1an=build_av1an) + self._build_worker = SourceBuildWorker( + build_vs=build_vs, build_av1an=build_av1an, + build_ffmpeg_iamf=build_ffmpeg_iamf, + ) self._build_worker.log_msg.connect(self._log) self._build_worker.build_done.connect(self._on_build_done) self._build_worker.start() @@ -3924,6 +4686,8 @@ class OpenCodecMaster(QMainWindow): btn_vs_only.setObjectName("btnRebuild") btn_av1an_only = QPushButton(" av1an only ") btn_av1an_only.setObjectName("btnRebuild") + btn_ffmpeg_iamf = QPushButton(" ffmpeg + IAMF ") + btn_ffmpeg_iamf.setObjectName("btnRebuild") btn_cancel = QPushButton(" Cancel ") btn_cancel.setObjectName("btnStop") @@ -3931,13 +4695,17 @@ class OpenCodecMaster(QMainWindow): dlg.setWindowTitle("Rebuild from Git") dlg.setText( "Select which components to rebuild from git source.\n\n" - "• VapourSynth — installs to /usr (needs sudo)\n" - "• av1an — builds via cargo, copies to /usr/bin (needs sudo)\n\n" - "Build time: VapourSynth ~2-5 min, av1an ~10-30 min" + "• VapourSynth — installs to ~/.local (needs sudo for build deps)\n" + "• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n" + "• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n" + " installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n" + " Required to use the IAMF audio codec. ~10-20 min build time.\n\n" + "Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min" ) dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole) dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole) dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole) + dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole) dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) dlg.exec() @@ -3949,6 +4717,9 @@ class OpenCodecMaster(QMainWindow): self._start_git_rebuild(build_vs=True, build_av1an=False) elif clicked == btn_av1an_only: self._start_git_rebuild(build_vs=False, build_av1an=True) + elif clicked == btn_ffmpeg_iamf: + self._start_git_rebuild(build_vs=False, build_av1an=False, + build_ffmpeg_iamf=True) @Slot(str, int, int) def _on_progress(self, filename: str, current: int, total: int):