846 lines
37 KiB
Python
Executable File
846 lines
37 KiB
Python
Executable File
"""SourceBuildWorker (QThread) — builds VS / av1an / ffmpeg from git.
|
|
|
|
Resolves VapourSynth/av1an ABI mismatches by compiling the affected
|
|
components from source. Installs to the user's home dir (no sudo for
|
|
the install step). v3-08 made this worker stop mutating
|
|
``os.environ`` directly — it carries its own ``_build_env`` snapshot.
|
|
|
|
v4.7.1: the rebuild is ALWAYS usable, even on a bare system. It
|
|
generates its own dependency tree: missing build tools and libraries
|
|
are installed via the distro package manager (arch/debian/redhat/suse)
|
|
before anything is compiled, and the VapourSynth build is followed by
|
|
a BestSource plugin build (submodules + vapoursynth dev headers from
|
|
the freshly installed VS) so av1an gets a fast, reliable chunk method
|
|
instead of the slow "select" fallback.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import site
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
from .distro_probe import DistroProfile, detect_distro
|
|
from .gpu_profiles import gpu_profile_by_key
|
|
|
|
# ──────────────────────────────────────────────
|
|
# BUILD DEPENDENCY TREE (v4.7.1 — distro-aware)
|
|
# ──────────────────────────────────────────────
|
|
|
|
# Binaries the build needs. pkgconf/pkg-config and python/python3 are
|
|
# aliased — any one of each pair satisfies the check.
|
|
BUILD_TOOL_ALIASES: dict[str, tuple[str, ...]] = {
|
|
"git": ("git",),
|
|
"meson": ("meson",),
|
|
"ninja": ("ninja",),
|
|
"c++ compiler": ("g++", "c++", "clang++"),
|
|
"make": ("make",),
|
|
"pkg-config": ("pkg-config", "pkgconf"),
|
|
"python3": ("python3",),
|
|
"nasm": ("nasm",),
|
|
"cmake": ("cmake",),
|
|
}
|
|
|
|
# Packages providing the toolchain + the libraries the builds link
|
|
# against (zimg is VapourSynth's one hard library dependency; rust is
|
|
# only needed for the av1an build).
|
|
BUILD_DEPS_BY_FAMILY: dict[str, list[str]] = {
|
|
"arch": ["base-devel", "meson", "ninja", "cmake", "nasm", "git",
|
|
"python", "pkgconf", "zimg", "rust"],
|
|
"debian": ["build-essential", "meson", "ninja-build", "cmake", "nasm",
|
|
"git", "python3", "python3-dev", "pkg-config", "libzimg-dev",
|
|
"cargo", "rustc"],
|
|
"redhat": ["gcc", "gcc-c++", "make", "meson", "ninja-build", "cmake",
|
|
"nasm", "git", "python3", "python3-devel",
|
|
"pkgconf-pkg-config", "zimg-devel", "cargo", "rust"],
|
|
"suse": ["gcc", "gcc-c++", "make", "meson", "ninja", "cmake", "nasm",
|
|
"git", "python3", "python3-devel", "pkg-config",
|
|
"zimg-devel", "rust", "cargo"],
|
|
}
|
|
|
|
PKG_INSTALL_CMD: dict[str, list[str]] = {
|
|
"arch": ["pacman", "-S", "--needed", "--noconfirm"],
|
|
"debian": ["apt-get", "install", "-y"],
|
|
"redhat": ["dnf", "install", "-y"],
|
|
"suse": ["zypper", "--non-interactive", "install"],
|
|
}
|
|
|
|
MANUAL_DEP_NOTE = (
|
|
"No automatic package install for this distro family. Install a C++ "
|
|
"toolchain plus meson, ninja, cmake, nasm, git, python3, pkg-config, "
|
|
"zimg development headers{rust} manually, then press REBUILD again."
|
|
)
|
|
|
|
# v4.8.0: per-GPU-profile build/runtime packages, on top of the base
|
|
# toolchain. nvidia needs nv-codec-headers at ffmpeg build time (the
|
|
# distro ffmpeg already ships nvenc; a matched git build needs the
|
|
# headers); vaapi/qsv need the driver + dev stacks for their vendor.
|
|
GPU_BUILD_PACKAGES: dict[str, dict[str, list[str]]] = {
|
|
"nvenc": {
|
|
"arch": ["nv-codec-headers"],
|
|
"debian": [],
|
|
"redhat": [],
|
|
"suse": [],
|
|
},
|
|
"vaapi": {
|
|
"arch": ["libva", "libdrm", "mesa"],
|
|
"debian": ["libva-dev", "libdrm-dev", "mesa-va-drivers"],
|
|
"redhat": ["libva-devel", "libdrm-devel", "mesa-va-drivers"],
|
|
"suse": ["libva-devel", "libdrm-devel", "Mesa-libva"],
|
|
},
|
|
"qsv": {
|
|
"arch": ["libva", "intel-media-driver", "onevpl"],
|
|
"debian": ["libva-dev", "intel-media-va-driver-non-free", "libvpl-dev"],
|
|
"redhat": ["libva-devel", "intel-media-driver", "oneVPL-devel"],
|
|
"suse": ["libva-devel", "intel-media-driver", "oneVPL-devel"],
|
|
},
|
|
}
|
|
|
|
|
|
def gpu_dep_packages(api: str, distro_family: str) -> list[str]:
|
|
"""Extra packages for a GPU hardware API on this distro (empty when
|
|
the family has no packaged set — the log says so)."""
|
|
return list(GPU_BUILD_PACKAGES.get(api, {}).get(distro_family, []))
|
|
|
|
|
|
@dataclass
|
|
class DepPlan:
|
|
"""What the rebuild needs, and how to get it on this distro."""
|
|
packages: list[str] = field(default_factory=list)
|
|
install_cmd: list[str] | None = None
|
|
manual_note: str | None = None
|
|
|
|
|
|
def build_dep_plan(distro: DistroProfile, build_av1an: bool = True) -> DepPlan:
|
|
"""Pure: the package list + install command for this distro family.
|
|
|
|
Works even when the environment probe failed — it only needs the
|
|
distro family, which is detectable from /etc/os-release alone.
|
|
"""
|
|
packages = list(BUILD_DEPS_BY_FAMILY.get(distro.family, []))
|
|
if not build_av1an:
|
|
for rust_pkg in ("rust", "rustc", "cargo"):
|
|
if rust_pkg in packages:
|
|
packages.remove(rust_pkg)
|
|
install_cmd = PKG_INSTALL_CMD.get(distro.family)
|
|
manual_note = None
|
|
if not install_cmd or not packages:
|
|
manual_note = MANUAL_DEP_NOTE.format(
|
|
rust=" and Rust/cargo" if build_av1an else "")
|
|
return DepPlan(packages=packages, install_cmd=install_cmd,
|
|
manual_note=manual_note)
|
|
|
|
# ──────────────────────────────────────────────
|
|
# SOURCE BUILD WORKER — compile VS + av1an from git
|
|
# ──────────────────────────────────────────────
|
|
|
|
class SourceBuildWorker(QThread):
|
|
"""Builds VapourSynth and/or av1an from git to resolve ABI mismatches.
|
|
|
|
Runs in a background thread. Emits progress via log_msg.
|
|
When done, emits build_done(success, message).
|
|
|
|
Everything installs to the user's home directory (no sudo for install):
|
|
VapourSynth → ~/.local/lib/ (av1an finds it via LD_LIBRARY_PATH)
|
|
av1an → ~/.cargo/bin/ (already in PATH)
|
|
Only build-dependency installation (pacman -S) may need sudo.
|
|
"""
|
|
log_msg = Signal(str)
|
|
build_done = Signal(bool, str) # (success, detail)
|
|
|
|
def __init__(self, build_vs: bool = True, build_av1an: bool = True,
|
|
build_ffmpeg_iamf: bool = False, gpu_profile_key: str = ""):
|
|
super().__init__()
|
|
self.build_vs = build_vs
|
|
self.build_av1an = build_av1an
|
|
self.build_ffmpeg_iamf = build_ffmpeg_iamf
|
|
# v4.8.0: selected GPU capability profile — extends the dep tree
|
|
# with the vendor's build/runtime packages.
|
|
self.gpu_profile_key = gpu_profile_key
|
|
self._stop = False
|
|
# Private per-worker environment snapshot. Mutating os.environ is
|
|
# process-global and leaks across threads/subsequent subprocesses;
|
|
# _build_env is local to this worker and passed via env= to every
|
|
# subprocess.run call below (see _run_cmd).
|
|
self._build_env: dict[str, str] = os.environ.copy()
|
|
|
|
def _extend_env(self, var: str, value: str, prepend: bool = False):
|
|
"""Add ``value`` to ``self._build_env[var]`` (NOT ``os.environ``).
|
|
|
|
``prepend=True`` places ``value`` first so it shadows any existing
|
|
entry (e.g. ~/.local/bin must shadow /usr/bin, libiamf's
|
|
PKG_CONFIG_PATH must shadow the system pkgconfig dir); default
|
|
appends (e.g. extending PATH with ~/.cargo/bin). Caller is
|
|
responsible for any idempotency check (matches the original
|
|
per-site ``if x not in existing:`` pattern). rstrip(":") on
|
|
prepend avoids a trailing colon when ``var`` was previously unset.
|
|
"""
|
|
existing = self._build_env.get(var, "")
|
|
if prepend:
|
|
self._build_env[var] = f"{value}:{existing}".rstrip(":")
|
|
else:
|
|
self._build_env[var] = f"{existing}:{value}" if existing else value
|
|
|
|
def _run_cmd(self, cmd, cwd=None, timeout=600, label=""):
|
|
"""Run a command, log output, return (returncode, combined_output)."""
|
|
self.log_msg.emit(f" $ {' '.join(cmd[:6])}{'...' if len(cmd)>6 else ''}")
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True,
|
|
timeout=timeout, cwd=cwd, env=self._build_env)
|
|
# Log last few lines of stderr for diagnostics
|
|
if r.stderr:
|
|
for line in r.stderr.strip().splitlines()[-5:]:
|
|
self.log_msg.emit(f" {line}")
|
|
if r.returncode != 0 and r.stdout:
|
|
for line in r.stdout.strip().splitlines()[-3:]:
|
|
self.log_msg.emit(f" {line}")
|
|
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
|
except subprocess.TimeoutExpired:
|
|
self.log_msg.emit(f" TIMEOUT ({timeout}s) running: {label or cmd[0]}")
|
|
return -1, f"timeout after {timeout}s"
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
self.log_msg.emit(f" ERROR: {e}")
|
|
return -1, str(e)
|
|
|
|
def _sudo_cmd(self, cmd, timeout=120, label=""):
|
|
"""Run a command with sudo (or pkexec as graphical fallback)."""
|
|
# Try pkexec first (graphical polkit prompt — works in desktop sessions)
|
|
pkexec = shutil.which("pkexec")
|
|
if pkexec:
|
|
return self._run_cmd([pkexec] + cmd, timeout=timeout, label=label or cmd[0])
|
|
# Fall back to sudo (needs a terminal; may fail silently)
|
|
return self._run_cmd(["sudo"] + cmd, timeout=timeout, label=label or cmd[0])
|
|
|
|
def run(self):
|
|
try:
|
|
# ── v4.7.1: distro-aware dependency tree ──
|
|
# The rebuild must work on a bare system: detect missing
|
|
# tools/libraries and install them via the distro package
|
|
# manager (one privilege prompt via pkexec/sudo) BEFORE
|
|
# compiling anything.
|
|
self.log_msg.emit("")
|
|
self.log_msg.emit("=== Generating dependency tree ===")
|
|
self._distro = detect_distro()
|
|
self.log_msg.emit(
|
|
f" Distro: {self._distro.name} (family={self._distro.family})"
|
|
)
|
|
plan = build_dep_plan(self._distro, build_av1an=self.build_av1an)
|
|
|
|
# v4.8.0: GPU-profile packages on top of the base toolchain.
|
|
gpu_profile = gpu_profile_by_key(self.gpu_profile_key)
|
|
if gpu_profile is not None and gpu_profile.api != "none":
|
|
gpu_pkgs = gpu_dep_packages(gpu_profile.api, self._distro.family)
|
|
if gpu_pkgs:
|
|
self.log_msg.emit(
|
|
f" GPU profile {gpu_profile.key} ({gpu_profile.api}): "
|
|
f"+{len(gpu_pkgs)} package(s)"
|
|
)
|
|
plan.packages.extend(p for p in gpu_pkgs
|
|
if p not in plan.packages)
|
|
|
|
missing = self._missing_build_tools()
|
|
zimg_ok = self._pkgconfig_exists("zimg")
|
|
if zimg_ok:
|
|
self.log_msg.emit(" OK: zimg (VapourSynth dependency)")
|
|
else:
|
|
missing.append("zimg (library, via pkg-config)")
|
|
if self.build_av1an and not shutil.which("cargo"):
|
|
missing.append("cargo (rust)")
|
|
|
|
if missing:
|
|
self.log_msg.emit(f" Missing: {', '.join(missing)}")
|
|
if plan.install_cmd:
|
|
self.log_msg.emit(
|
|
f" Installing {len(plan.packages)} package(s) via "
|
|
f"{plan.install_cmd[0]} (privilege prompt possible)..."
|
|
)
|
|
rc, _ = self._sudo_cmd(
|
|
plan.install_cmd + plan.packages,
|
|
timeout=900, label=f"{plan.install_cmd[0]} build-deps",
|
|
)
|
|
if rc != 0:
|
|
self.log_msg.emit(
|
|
" (install reported an error — continuing; "
|
|
"some packages may already be present)"
|
|
)
|
|
else:
|
|
self.log_msg.emit(f" {plan.manual_note}")
|
|
else:
|
|
self.log_msg.emit(" All build dependencies already installed.")
|
|
|
|
# Re-verify the critical tools after install.
|
|
still_missing = self._missing_build_tools()
|
|
if still_missing:
|
|
self.log_msg.emit(
|
|
f" FATAL: still missing after install: {', '.join(still_missing)}. "
|
|
f"Install them manually and press REBUILD again."
|
|
)
|
|
self.build_done.emit(False, f"missing build tools: {still_missing}")
|
|
return
|
|
|
|
# Ensure cargo is in PATH after potential install.
|
|
# NOTE: /root/.cargo/bin was dropped (OTC-015/v3-08) — root's
|
|
# cargo dir is not readable by a non-root user. ~/.cargo/bin
|
|
# covers the user's rustup install; /usr/bin is already in the
|
|
# default PATH and is appended here only to match the original
|
|
# mutation's intent (cargo from pacman lives there).
|
|
self._extend_env("PATH", "/usr/bin")
|
|
self._extend_env("PATH", str(Path.home() / ".cargo" / "bin"))
|
|
if not shutil.which("cargo") and self.build_av1an:
|
|
self.log_msg.emit(" FATAL: cargo not found after deps install. Aborting.")
|
|
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()
|
|
# v4.7.1: BestSource right after VS, compiled against the
|
|
# fresh VS headers — gives av1an a fast chunk method.
|
|
self._build_bestsource()
|
|
|
|
# ── Build av1an to ~/.cargo/bin (NO sudo needed) ──
|
|
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 the runtime env can find the fresh VS stack ──
|
|
# The git VapourSynth installs self-contained into the user
|
|
# site-packages (module + libs + plugins). av1an dlopens
|
|
# libvapoursynth-script from there, so both LD_LIBRARY_PATH
|
|
# and PYTHONPATH must include it.
|
|
local_lib = str(Path.home() / ".local" / "lib")
|
|
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
|
|
if local_lib not in existing_ld:
|
|
self._extend_env("LD_LIBRARY_PATH", local_lib, prepend=True)
|
|
user_site = self._vs_user_site()
|
|
if user_site and (user_site / "vapoursynth" / "libvsscript.so").exists():
|
|
vs_dir = str(user_site / "vapoursynth")
|
|
if vs_dir not in self._build_env.get("LD_LIBRARY_PATH", ""):
|
|
self._extend_env("LD_LIBRARY_PATH", vs_dir, prepend=True)
|
|
if str(user_site) not in self._build_env.get("PYTHONPATH", ""):
|
|
self._extend_env("PYTHONPATH", str(user_site), prepend=True)
|
|
self.log_msg.emit(
|
|
f" Runtime env: LD_LIBRARY_PATH/PYTHONPATH include {vs_dir}"
|
|
)
|
|
|
|
self.log_msg.emit("")
|
|
self.log_msg.emit("=== Source build complete ===")
|
|
self.build_done.emit(True, "Build and install completed (local ~/.local/).")
|
|
|
|
except Exception as e:
|
|
# SEI CERT ERR01-C: justified — this method orchestrates a long
|
|
# multi-step build (git clone, meson, ninja, cargo install) whose
|
|
# helper methods signal failure by `raise Exception(msg)` (15
|
|
# sites). Catching Exception here converts any of those into a
|
|
# user-facing build_done(False, ...) signal instead of crashing
|
|
# the QThread. Narrowing would require refactoring all `raise
|
|
# Exception(...)` call sites — out of scope for ERR01-C pass.
|
|
self.log_msg.emit(f"BUILD FAILED: {e}")
|
|
self.build_done.emit(False, str(e))
|
|
|
|
def _missing_build_tools(self) -> list[str]:
|
|
"""Binaries from BUILD_TOOL_ALIASES that are not on PATH."""
|
|
missing = []
|
|
for label, candidates in BUILD_TOOL_ALIASES.items():
|
|
if not any(shutil.which(c) for c in candidates):
|
|
missing.append(label)
|
|
return missing
|
|
|
|
def _pkgconfig_exists(self, name: str) -> bool:
|
|
rc, _ = self._run_cmd(
|
|
["pkg-config", "--exists", name],
|
|
timeout=10, label=f"pkg-config {name}",
|
|
)
|
|
return rc == 0
|
|
|
|
def _vs_user_site(self) -> Path | None:
|
|
"""The user site-packages dir of the system python3 — where the
|
|
VapourSynth git install places its self-contained stack (module,
|
|
libs, headers, plugins/)."""
|
|
rc, out = self._run_cmd(
|
|
["python3", "-m", "site", "--user-site"],
|
|
timeout=15, label="python3 -m site --user-site",
|
|
)
|
|
if rc == 0 and out.strip():
|
|
return Path(out.strip().splitlines()[-1])
|
|
return None
|
|
|
|
def _build_bestsource(self):
|
|
"""Clone and build the BestSource VapourSynth plugin from git.
|
|
|
|
BestSource gives av1an a fast, frame-accurate chunk source — the
|
|
difference between 'select' (quadratic decoding, minutes per
|
|
file) and normal chunk-parallel speed. Compiled against the
|
|
vapoursynth headers of the JUST-INSTALLED git VS (via
|
|
PYTHONPATH/PKG_CONFIG_PATH), so the plugin ABI always matches
|
|
the VS that av1an will load. Requires the repo's libp2p
|
|
submodule (initialized here).
|
|
"""
|
|
self.log_msg.emit("")
|
|
self.log_msg.emit("=== Building BestSource plugin from git ===")
|
|
self.log_msg.emit(" Source: https://github.com/vapoursynth/bestsource")
|
|
|
|
build_dir = Path("/tmp/bestsource-git-build")
|
|
if build_dir.exists():
|
|
shutil.rmtree(build_dir, ignore_errors=True)
|
|
|
|
rc, out = self._run_cmd(
|
|
["git", "clone", "--depth", "1",
|
|
"https://github.com/vapoursynth/bestsource.git",
|
|
str(build_dir)],
|
|
timeout=120, label="git clone bestsource",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"git clone bestsource failed: {out[-300:]}")
|
|
|
|
# libp2p is a required submodule (R9+ builds source from it).
|
|
rc, out = self._run_cmd(
|
|
["git", "submodule", "update", "--init", "--depth", "1"],
|
|
cwd=str(build_dir), timeout=120, label="git submodule update",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"bestsource submodule init failed: {out[-300:]}")
|
|
|
|
# Point meson/pkg-config at the freshly built VS stack.
|
|
user_site = self._vs_user_site()
|
|
if user_site and (user_site / "vapoursynth").is_dir():
|
|
self._extend_env("PYTHONPATH", str(user_site), prepend=True)
|
|
self._extend_env("PKG_CONFIG_PATH",
|
|
str(user_site / "vapoursynth" / "pkgconfig"),
|
|
prepend=True)
|
|
self._extend_env("LD_LIBRARY_PATH",
|
|
str(user_site / "vapoursynth"), prepend=True)
|
|
else:
|
|
self.log_msg.emit(
|
|
" NOTE: git VapourSynth install not found in user "
|
|
"site-packages — building against system vapoursynth."
|
|
)
|
|
|
|
self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...")
|
|
rc, out = self._run_cmd(
|
|
["meson", "setup", "build",
|
|
f"--prefix={Path.home() / '.local'}", "--libdir=lib"],
|
|
cwd=str(build_dir), timeout=180, label="meson setup bestsource",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"bestsource meson setup failed: {out[-500:]}")
|
|
|
|
self.log_msg.emit(" Compiling BestSource (a minute or two)...")
|
|
rc, out = self._run_cmd(
|
|
["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
|
|
cwd=str(build_dir), timeout=600, label="ninja bestsource",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"bestsource build failed: {out[-500:]}")
|
|
|
|
rc, out = self._run_cmd(
|
|
["ninja", "-C", "build", "install"],
|
|
cwd=str(build_dir), timeout=120, label="ninja install bestsource",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"bestsource install failed: {out[-500:]}")
|
|
|
|
plugin = None
|
|
if user_site:
|
|
candidate = user_site / "vapoursynth" / "plugins" / "libbestsource.so"
|
|
if candidate.exists():
|
|
plugin = candidate
|
|
if plugin:
|
|
self.log_msg.emit(f" BestSource plugin installed: {plugin}")
|
|
self.log_msg.emit(
|
|
" av1an will now auto-select the fast 'bestsource' chunk "
|
|
"method (restart the app so the probe sees it)."
|
|
)
|
|
else:
|
|
self.log_msg.emit(
|
|
" WARNING: libbestsource.so not found at the expected "
|
|
"user-site path — check the meson install log above."
|
|
)
|
|
|
|
shutil.rmtree(build_dir, ignore_errors=True)
|
|
|
|
def _build_vapoursynth(self):
|
|
"""Clone, build, and install VapourSynth to ~/.local/ (no sudo needed)."""
|
|
self.log_msg.emit("")
|
|
self.log_msg.emit("=== Building VapourSynth from git ===")
|
|
self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)")
|
|
build_dir = Path("/tmp/vapoursynth-git-build")
|
|
local_prefix = str(Path.home() / ".local")
|
|
|
|
if build_dir.exists():
|
|
self.log_msg.emit(f" Cleaning old build directory...")
|
|
shutil.rmtree(build_dir, ignore_errors=True)
|
|
|
|
# Clone (shallow — faster)
|
|
rc, out = self._run_cmd(
|
|
["git", "clone", "--depth", "1",
|
|
"https://github.com/vapoursynth/vapoursynth.git",
|
|
str(build_dir)],
|
|
timeout=120, label="git clone vapoursynth",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"git clone VapourSynth failed: {out[-300:]}")
|
|
|
|
# Meson setup — install to ~/.local so it doesn't touch system dirs
|
|
self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...")
|
|
rc, out = self._run_cmd(
|
|
["meson", "setup", "build",
|
|
f"--prefix={local_prefix}", "--libdir=lib"],
|
|
cwd=str(build_dir), timeout=120, label="meson setup",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"meson setup failed: {out[-500:]}")
|
|
|
|
# Build
|
|
self.log_msg.emit(" Compiling VapourSynth (this may take a few minutes)...")
|
|
rc, out = self._run_cmd(
|
|
["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
|
|
cwd=str(build_dir), timeout=900, label="ninja build",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"ninja build failed: {out[-500:]}")
|
|
|
|
# Install to ~/.local/ — NO sudo needed (user owns this directory)
|
|
self.log_msg.emit(" Installing VapourSynth to ~/.local/ ...")
|
|
rc, out = self._run_cmd(
|
|
["ninja", "-C", "build", "install"],
|
|
cwd=str(build_dir), timeout=120, label="ninja install",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"ninja install failed: {out[-500:]}")
|
|
|
|
self.log_msg.emit(f" VapourSynth installed to {local_prefix}/ (libs in {local_prefix}/lib/)")
|
|
|
|
# Cleanup build directory
|
|
shutil.rmtree(build_dir, ignore_errors=True)
|
|
|
|
def _build_av1an(self):
|
|
"""Clone and build av1an from git. Installs to ~/.cargo/bin/ (no sudo needed)."""
|
|
self.log_msg.emit("")
|
|
self.log_msg.emit("=== Building av1an from git ===")
|
|
self.log_msg.emit(" Install target: ~/.cargo/bin/ (no system-wide changes)")
|
|
|
|
# Ensure cargo is in PATH
|
|
cargo_bin = shutil.which("cargo")
|
|
if not cargo_bin:
|
|
# Common locations
|
|
for p in [Path.home() / ".cargo" / "bin" / "cargo", "/usr/bin/cargo"]:
|
|
if p.exists():
|
|
self._extend_env("PATH", str(p.parent))
|
|
cargo_bin = str(p)
|
|
break
|
|
if not cargo_bin:
|
|
raise Exception("cargo not found — cannot build av1an")
|
|
|
|
self.log_msg.emit(f" Using cargo at: {cargo_bin}")
|
|
self.log_msg.emit(" Compiling av1an (this may take 10-30 minutes)...")
|
|
|
|
rc, out = self._run_cmd(
|
|
["cargo", "install", "av1an",
|
|
"--git", "https://github.com/master-of-zen/av1an",
|
|
"--force", "--root", str(Path.home() / ".cargo")],
|
|
timeout=3600, label="cargo install av1an",
|
|
)
|
|
if rc != 0:
|
|
raise Exception(f"cargo install av1an failed: {out[-500:]}")
|
|
|
|
new_av1an = Path.home() / ".cargo" / "bin" / "av1an"
|
|
if new_av1an.exists():
|
|
self.log_msg.emit(f" av1an installed: {new_av1an}")
|
|
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():
|
|
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 = self._build_env.get("PKG_CONFIG_PATH", "")
|
|
if str(pc_dir) not in existing_pkgs:
|
|
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
|
|
self.log_msg.emit(f" Added {pc_dir} to PKG_CONFIG_PATH")
|
|
|
|
lib_dir = local_prefix / "lib"
|
|
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
|
|
if str(lib_dir) not in existing_ld:
|
|
self._extend_env("LD_LIBRARY_PATH", str(lib_dir), prepend=True)
|
|
|
|
self.log_msg.emit(f" libiamf installed to {local_prefix}/")
|
|
|
|
# Cleanup
|
|
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)
|
|
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():
|
|
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 = self._build_env.get("PKG_CONFIG_PATH", "")
|
|
if str(pc_dir) not in existing_pkgs:
|
|
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
|
|
|
|
# 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 = self._build_env.get("PATH", "")
|
|
if str(local_bin) not in existing_path:
|
|
self._extend_env("PATH", str(local_bin), prepend=True)
|
|
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.)
|
|
shutil.rmtree(build_dir, ignore_errors=True)
|
|
|
|
def stop(self):
|
|
self._stop = True
|
|
|