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.

This commit is contained in:
Jeremy Anderson 2026-07-11 03:39:13 -04:00
parent 55e6f4b1a7
commit cd06fb7116
1 changed files with 811 additions and 40 deletions

View File

@ -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:
# 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(i)
self.container_combo.setCurrentIndex(match)
self.container_combo.blockSignals(False)
break
# Re-evaluate compatibility after auto-container change.
self._check_combo_compatibility()
def _populate_presets(self, codec_idx: int):
self.preset_combo.blockSignals(True)
@ -3394,20 +3955,128 @@ 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
self.resolution_combo.addItem(rp.label)
self._res_preset_indices[combo_pos] = i
@ -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():
for idx, profile in enumerate(AUDIO_PROFILES):
if idx >= self.audio_combo.count():
break # combo not yet populated, defensive
# params layout is ["-c:a", "<encoder>", ...]; 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):
self.audio_combo.model().item(idx).setEnabled(False)
self.audio_combo.model().item(idx).setToolTip(
f"DISABLED: FFmpeg missing {lib_name} encoder."
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 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) # Fallback to Opus (96k)
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):