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:
parent
55e6f4b1a7
commit
cd06fb7116
|
|
@ -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", "<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):
|
||||
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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue