#!/usr/bin/env python3 """ OpenCodec Transcode Master — Merged & Refactored ================================================= Merges best of: trans.py (CTk probe/tooltips), custom-transcoder (QThread/pathlib), and the bash script's ffmpeg-fallback + WebM concept. Key refactors over originals: - Config-driven codec/audio/container profiles (no more nested if/else chains) - Distro-aware: Arch, Fedora, RHEL/CentOS/Rocky/Alma, openSUSE, NixOS, Debian/Ubuntu - Per-distro binary search paths, package manager, install hints, encoder name quirks - FFmpeg encoder library availability probe (greys out unavailable codecs in UI) - ffprobe pre-validation before encoding - QThread worker (thread-safe UI, proper signal/slot) - Runtime av1an flag + version probing - Dynamic worker count (os.cpu_count - 2) - pathlib throughout - Per-file progress tracking - Container format selection (MKV / WebM) - AV1/VP9/x265 preset selection - Configurable input extensions - Batch delete with summary prompt (not per-file) """ import hashlib import math import os import platform import re import shutil import subprocess import sys import tempfile import ctypes from dataclasses import dataclass, field from pathlib import Path from typing import Optional from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox, QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox, QStyleFactory, ) from PySide6.QtCore import Qt, QThread, Signal, Slot, QPointF, QRectF from PySide6.QtGui import ( QFont, QPalette, QColor, QPainter, QPen, QBrush, QRadialGradient, QFontMetrics, ) # ────────────────────────────────────────────── # RADIO KNOB WIDGET (oldschool rotary control) # ────────────────────────────────────────────── class RadioKnob(QWidget): """ A retro radio-style rotary knob widget. Supports arc range, tick marks, and a glowing indicator dot. Rotation: 7 o'clock (min) to 5 o'clock (max) = 300 degrees. """ valueChanged = Signal(float) def __init__( self, parent=None, min_val: float = 0.0, max_val: float = 100.0, default_val: float = 50.0, label: str = "", unit: str = "", color: tuple = (42, 130, 218), num_ticks: int = 17, tick_labels: list[str] | None = None, snap_ticks: bool = False, compact: bool = False, ): super().__init__(parent) self.min_val = min_val self.max_val = max_val self._value = default_val self.label = label self.unit = unit self.color = QColor(*color) self.num_ticks = num_ticks self.tick_labels = tick_labels self.snap_ticks = snap_ticks self._dragging = False self.compact = compact # Arc geometry: 300-degree sweep, centered at 12 o'clock self._arc_start = 210.0 # degrees (7 o'clock) self._arc_span = -300.0 # negative = clockwise # Scaling factor for compact mode (~70% of full size) s = 0.70 if compact else 1.0 self._s = s self.setFixedSize(int(180 * s), int(210 * s)) self.setCursor(Qt.CursorShape.PointingHandCursor) # --- Public API --- def value(self) -> float: return self._value def setValue(self, v: float): v = max(self.min_val, min(self.max_val, v)) if self.snap_ticks: v = self._snap(v) if v != self._value: self._value = v self.update() self.valueChanged.emit(v) def intValue(self) -> int: return int(round(self._value)) def _snap(self, v: float) -> float: """Snap to nearest tick.""" step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1) return round((v - self.min_val) / step) * step + self.min_val def _val_to_angle(self, v: float) -> float: """Map value to angle in degrees (matching the conical gradient).""" ratio = (v - self.min_val) / (self.max_val - self.min_val) if self.max_val != self.min_val else 0 return self._arc_start + ratio * self._arc_span # goes from 210 -> -90 def _angle_to_val(self, angle_deg: float) -> float: """Map angle back to value.""" # Normalize angle relative to arc start ratio = (angle_deg - self._arc_start) / self._arc_span ratio = max(0.0, min(1.0, ratio)) v = self.min_val + ratio * (self.max_val - self.min_val) if self.snap_ticks: v = self._snap(v) return v # --- Painting --- def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) w, h = self.width(), self.height() s = self._s # scale factor (0.7 for compact, 1.0 for full) cx = w / 2 cy = h / 2 - 4 * s outer_r = 70 * s knob_r = 40 * s arc_w = max(1, int(8 * s)) tick_w = max(1, 1.5 * s) bezel_pad = 6 * s # --- Outer bezel ring --- bezel_grad = QRadialGradient(cx, cy, outer_r + bezel_pad) bezel_grad.setColorAt(0.85, QColor(48, 48, 52)) bezel_grad.setColorAt(1.0, QColor(26, 26, 30)) p.setBrush(QBrush(bezel_grad)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(cx, cy), outer_r + bezel_pad, outer_r + bezel_pad) # --- Inactive arc (dark track) --- p.setPen(QPen(QColor(50, 50, 56), arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2), int(self._arc_start * 16), int(self._arc_span * 16)) # --- Active arc (colored fill up to current value) --- val_angle = self._val_to_angle(self._value) active_span = val_angle - self._arc_start if abs(active_span) > 0.5: arc_color = QColor(self.color) p.setPen(QPen(arc_color, arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2), int(self._arc_start * 16), int(active_span * 16)) # --- Tick marks --- for i in range(self.num_ticks): t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0 tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val)) tick_rad = tick_angle * math.pi / 180.0 ox = cx + (outer_r + 12 * s) * (-1) * math.sin(tick_rad) oy = cy + (outer_r + 12 * s) * (-1) * (-math.cos(tick_rad)) ix_ = cx + (outer_r + 3 * s) * (-1) * math.sin(tick_rad) iy_ = cy + (outer_r + 3 * s) * (-1) * (-math.cos(tick_rad)) p.setPen(QPen(QColor(130, 130, 130), tick_w)) p.drawLine(QPointF(ix_, iy_), QPointF(ox, oy)) # Tick labels (if provided) if self.tick_labels: p.setFont(QFont("Sans", max(5, int(7 * s)))) p.setPen(QColor(160, 160, 160)) step = max(1, self.num_ticks // len(self.tick_labels)) label_idx = 0 for i in range(0, self.num_ticks, step): if label_idx >= len(self.tick_labels): break t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0 tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val)) tick_rad = tick_angle * math.pi / 180.0 lx = cx + (outer_r + 24 * s) * (-1) * math.sin(tick_rad) ly = cy + (outer_r + 24 * s) * (-1) * (-math.cos(tick_rad)) txt = self.tick_labels[label_idx] fm = QFontMetrics(p.font()) tw = fm.horizontalAdvance(txt) p.drawText(QPointF(lx - tw / 2, ly + 2 * s), txt) label_idx += 1 # --- Knob body (dark brushed aluminum) --- knob_grad = QRadialGradient(cx - 6 * s, cy - 6 * s, knob_r * 1.3) knob_grad.setColorAt(0.0, QColor(72, 72, 78)) knob_grad.setColorAt(0.5, QColor(50, 50, 55)) knob_grad.setColorAt(1.0, QColor(34, 34, 38)) p.setBrush(QBrush(knob_grad)) p.setPen(QPen(QColor(26, 26, 30), max(1, 1.5 * s))) p.drawEllipse(QPointF(cx, cy), knob_r, knob_r) # --- Inner shadow ring --- inner_shadow = QRadialGradient(cx, cy, knob_r - 2) inner_shadow.setColorAt(0.85, QColor(0, 0, 0, 0)) inner_shadow.setColorAt(1.0, QColor(0, 0, 0, 60)) p.setBrush(QBrush(inner_shadow)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(cx, cy), knob_r - 1, knob_r - 1) # --- Indicator line (pointer) --- ptr_angle = self._val_to_angle(self._value) ptr_rad = ptr_angle * 3.14159265 / 180.0 ptr_len = knob_r - 8 * s px = cx + ptr_len * (-1) * math.sin(ptr_rad) py = cy + ptr_len * (-1) * (-math.cos(ptr_rad)) p.setPen(QPen(QColor(255, 255, 255, 220), max(1, 2.5 * s), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) p.drawLine(QPointF(cx, cy), QPointF(px, py)) # --- Center cap dot --- cap_r = max(2, 5 * s) cap_grad = QRadialGradient(cx, cy, cap_r) cap_grad.setColorAt(0.0, QColor(60, 60, 65)) cap_grad.setColorAt(1.0, QColor(30, 30, 34)) p.setBrush(QBrush(cap_grad)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(cx, cy), cap_r, cap_r) # --- Glow dot at arc tip --- glow_r = max(3, 10 * s) glow_x = cx + outer_r * (-1) * math.sin(ptr_rad) glow_y = cy + outer_r * (-1) * (-math.cos(ptr_rad)) glow = QRadialGradient(glow_x, glow_y, glow_r * 1.2) glow.setColorAt(0.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 200)) glow.setColorAt(1.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 0)) p.setBrush(QBrush(glow)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(glow_x, glow_y), glow_r, glow_r) p.end() # --- Label + value text below knob --- p2 = QPainter(self) p2.setRenderHint(QPainter.RenderHint.Antialiasing) # Value line (e.g. "32.0 CRF") val_font_sz = max(6, int(13 * s)) p2.setFont(QFont("Consolas", val_font_sz, QFont.Weight.Bold)) val_color = QColor(self.color.red(), self.color.green(), self.color.blue()) p2.setPen(val_color) val_text = f"{self._value:.0f} {self.unit}" if self.unit else f"{self._value:.0f}" p2.drawText(QRectF(0, h - 38 * s, w, 20 * s), Qt.AlignmentFlag.AlignCenter, val_text) # Label line (e.g. "Quality") lbl_font_sz = max(5, int(9 * s)) p2.setFont(QFont("Consolas", lbl_font_sz, QFont.Weight.Bold)) p2.setPen(QColor(160, 160, 160)) p2.drawText(QRectF(0, h - 18 * s, w, 16 * s), Qt.AlignmentFlag.AlignCenter, self.label) p2.end() # --- Input handling --- def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._dragging = True self._update_from_mouse(event.position()) def mouseMoveEvent(self, event): if self._dragging: self._update_from_mouse(event.position()) def mouseReleaseEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self._dragging = False def wheelEvent(self, event): delta = event.angleDelta().y() step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1) if delta > 0: self.setValue(self._value + step) elif delta < 0: self.setValue(self._value - step) def _update_from_mouse(self, pos: QPointF): cx = self.width() / 2 cy = self.height() / 2 - 4 * self._s dx = pos.x() - cx dy = pos.y() - cy angle = math.degrees(math.atan2(dx, -dy)) # 0=north, CW positive if angle < 0: angle += 360 # Clamp to arc range: 210..510 (which is 210..360 and 0..150) # Our arc: 210 degrees to -90 (=270) degrees clockwise if angle < 210 and angle > 150: # Dead zone at bottom (between 150 and 210) # Push to nearest end angle = 210 if abs(angle - 210) < abs(angle - 510) else 510 if angle > 360: angle -= 360 # normalize back to 0..360 self.setValue(self._angle_to_val(angle)) # ────────────────────────────────────────────── # CONFIG-DRIVEN PROFILES (replaces all if/else chains) # ────────────────────────────────────────────── @dataclass class VideoCodecProfile: label: str # Display name in combo box av1an_encoder: str # Encoder name passed to --encoder ffmpeg_encoder: str # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1") container: str # Default container extension (mkv or webm) crf_range: tuple[int, int] # (min, max) valid CRF values default_crf: int params_fn: callable # (crf: int, preset: int) -> str (av1an format) ffmpeg_vargs_fn: callable # (crf: int, preset: int) -> list[str] (ffmpeg -c:v args) presets: list[str] # Human-readable preset labels preset_map: dict[str, int] # label -> internal preset value @dataclass class AudioProfile: label: str params: list[str] # Tokens passed to --audio-params (joined with space) @dataclass class ContainerProfile: label: str ext: str # e.g. "mkv", "webm" def _av1_params(crf: int, preset: int) -> str: """SVT-AV1 encoder params for av1an's --video-params. av1an splits the --video-params value by whitespace (``split_whitespace()``) and passes each resulting token as a separate argument to SvtAv1EncApp. Therefore the string must contain space-separated ``--flag value`` pairs that SvtAv1EncApp can parse natively. Colon-separated ``key=value:key=value`` does NOT work because there are no whitespace boundaries for av1an to split on — the entire string reaches SvtAv1EncApp as one opaque argument, producing: ``Maybe missing spacing between tokens``. """ return f"--preset {preset} --crf {crf} --keyint 240" def _vp9_params(crf: int, preset: int) -> str: """VP9 encoder params for av1an's --video-params. av1an splits by whitespace, so we use space-separated --flag=value tokens that vpxenc parses natively. """ cpu_used = max(0, 8 - preset) return f"--end-usage=q --cq-level={crf} --cpu-used={cpu_used}" def _x265_params(crf: int, preset: int) -> str: """x265 encoder params for av1an's --video-params. av1an splits by whitespace, so we use space-separated --flag value tokens that x265 parses natively. """ return f"--crf {crf} --preset {preset}" def _svtav1_ffmpeg_args(crf: int, preset: int) -> list[str]: """FFmpeg args for SVT-AV1 (maps av1an preset=0..8 → svtav1 -preset 0..13).""" # av1an preset range 0-8 maps to SVT-AV1 preset range 0-13 # Scale roughly: 8→0, 6→4, 4→7, 2→10 svt_preset = max(0, min(13, round((8 - preset) * 13 / 8))) return ["-c:v", "libsvtav1", "-preset", str(svt_preset), "-crf", str(crf), "-pix_fmt", "yuv420p10le", "-g", "240"] def _vp9_ffmpeg_args(crf: int, preset: int) -> list[str]: """FFmpeg args for VP9 (maps av1an cpu-used 0..8 → -cpu-used 0..8).""" cpu_used = max(0, min(8, preset)) return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0", "-cpu-used", str(cpu_used), "-pix_fmt", "yuv420p", "-g", "240", "-row-mt", "1", "-tiles", "2x2"] def _x265_ffmpeg_args(crf: int, preset: int) -> list[str]: """FFmpeg args for x265 (maps av1an preset 5..10 → x265 -preset).""" # av1an x265 preset range 5-10 maps to x265 preset names preset_names = {5: "slow", 7: "medium", 9: "fast", 10: "faster"} p = preset_names.get(preset, "medium") return ["-c:v", "libx265", "-preset", p, "-crf", str(crf), "-pix_fmt", "yuv420p10le", "-g", "240"] VIDEO_CODECS: list[VideoCodecProfile] = [ VideoCodecProfile( label="AV1 (SVT-AV1)", av1an_encoder="svt_av1", ffmpeg_encoder="libsvtav1", container="mkv", crf_range=(18, 52), default_crf=32, params_fn=_av1_params, ffmpeg_vargs_fn=_svtav1_ffmpeg_args, presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"], preset_map={"Slow (8)": 8, "Medium (6)": 6, "Fast (4)": 4, "Faster (2)": 2}, ), VideoCodecProfile( label="VP9", av1an_encoder="vpx", ffmpeg_encoder="libvpx-vp9", container="webm", crf_range=(18, 52), default_crf=32, params_fn=_vp9_params, ffmpeg_vargs_fn=_vp9_ffmpeg_args, presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"], preset_map={"Slow (0)": 0, "Medium (2)": 2, "Fast (4)": 4, "Faster (6)": 6}, ), VideoCodecProfile( label="x265 (HEVC)", av1an_encoder="x265", ffmpeg_encoder="libx265", container="mkv", crf_range=(18, 40), default_crf=28, params_fn=_x265_params, ffmpeg_vargs_fn=_x265_ffmpeg_args, presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"], preset_map={"Slow (5)": 5, "Medium (7)": 7, "Fast (9)": 9, "Faster (10)": 10}, ), ] AUDIO_PROFILES: list[AudioProfile] = [ AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"]), AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"]), AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"]), 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"]), ] CONTAINER_PROFILES: list[ContainerProfile] = [ ContainerProfile(label="MKV (Matroska)", ext="mkv"), ContainerProfile(label="WebM", ext="webm"), ] # ── Resolution presets ── # Aspect ratios: # Standard 16:9 -> w/h = 1.778 # Wide 21:9 -> w/h = 2.333 # Ultrawide 32:9 -> w/h = 3.556 @dataclass class ResolutionProfile: label: str # Display label in dropdown, e.g. "1080p Wide (2560x1080)" category: str # Grouping key: "standard", "wide", "ultrawide", "original" width: Optional[int] # None for "original" (no scaling) height: Optional[int] # None for "original" aspect_label: str # "16:9", "21:9", "32:9", "Source" RESOLUTION_PRESETS: list[ResolutionProfile] = [ # ── Original (no scaling) ── ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"), # ── Standard 16:9 ── ResolutionProfile("480p ( 854x 480)", "standard", 854, 480, "16:9"), ResolutionProfile("720p (1280x 720)", "standard", 1280, 720, "16:9"), ResolutionProfile("1080p (1920x1080)", "standard", 1920, 1080, "16:9"), ResolutionProfile("2K (2560x1440)", "standard", 2560, 1440, "16:9"), ResolutionProfile("4K (3840x2160)", "standard", 3840, 2160, "16:9"), # ── Wide 21:9 ── ResolutionProfile("480p Wide ( 854x 366)", "wide", 854, 366, "21:9"), ResolutionProfile("720p Wide (1280x 549)", "wide", 1280, 549, "21:9"), ResolutionProfile("1080p Wide (2560x1080)", "wide", 2560, 1080, "21:9"), ResolutionProfile("2K Wide (3440x1440)", "wide", 3440, 1440, "21:9"), ResolutionProfile("4K Wide (5120x2160)", "wide", 5120, 2160, "21:9"), # ── Ultrawide 32:9 ── ResolutionProfile("480p UW (1706x 480)", "ultrawide", 1706, 480, "32:9"), ResolutionProfile("1080p UW (3840x1080)", "ultrawide", 3840, 1080, "32:9"), ResolutionProfile("2K UW (5120x1440)", "ultrawide", 5120, 1440, "32:9"), ResolutionProfile("4K UW (7680x2160)", "ultrawide", 7680, 2160, "32:9"), ] SUBTITLE_OPTIONS = [ ("None", None), ("English", "eng"), ] DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"} # ────────────────────────────────────────────── # CPU TOPOLOGY (physical cores, not hyperthreads) # ────────────────────────────────────────────── @dataclass class CpuTopology: physical_cores: int logical_threads: int threads_per_core: int model_name: str def _read_sysfs_cores() -> Optional[tuple[int, int]]: """ Read /sys/devices/system/cpu/cpu*/topology/ to count unique (physical_package_id, core_id) pairs — i.e. physical cores. Returns (physical_cores, logical_threads) or None. """ cpu_base = Path("/sys/devices/system/cpu") if not cpu_base.exists(): return None unique_cores: set[tuple[str, str]] = set() logical = 0 for cpu_dir in sorted(cpu_base.glob("cpu[0-9]*")): core_id_file = cpu_dir / "topology" / "core_id" pkg_id_file = cpu_dir / "topology" / "physical_package_id" if core_id_file.exists() and pkg_id_file.exists(): try: pkg = pkg_id_file.read_text().strip() core = core_id_file.read_text().strip() unique_cores.add((pkg, core)) logical += 1 except Exception: pass if unique_cores and logical: return (len(unique_cores), logical) return None def _read_lscpu_cores() -> Optional[tuple[int, int]]: """Fallback: parse lscpu -p=CORE,SOCKET for unique physical cores.""" if not shutil.which("lscpu"): return None try: res = subprocess.run( ["lscpu", "-p=CORE,SOCKET"], capture_output=True, text=True, timeout=5, ) lines = [l.strip() for l in res.stdout.strip().splitlines() if l.strip() and not l.startswith("#")] if lines: unique = set(lines) return (len(unique), len(lines)) except Exception: pass return None def detect_cpu_topology() -> CpuTopology: """ Detect physical CPU topology. Prefers /sys filesystem, falls back to lscpu, then estimates from os.cpu_count(). """ logical = os.cpu_count() or 1 physical = logical # Try /sys first (most reliable) result = _read_sysfs_cores() if result: physical, logical = result else: # Try lscpu result = _read_lscpu_cores() if result: physical, logical = result else: # Estimate: assume 2 threads/core if cpu_count > 2 and is even if logical > 2 and logical % 2 == 0: physical = logical // 2 tpc = logical // physical if physical > 0 else 1 # Try to get CPU model name model = "Unknown CPU" model_file = Path("/proc/cpuinfo") if model_file.exists(): for line in model_file.read_text(errors="replace").splitlines(): if line.startswith("model name"): model = line.split(":", 1)[1].strip() break else: # Non-x86 / non-Linux: try lscpu if shutil.which("lscpu"): try: res = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5) for line in res.stdout.splitlines(): if "Model name" in line: model = line.split(":", 1)[1].strip() break except Exception: pass return CpuTopology( physical_cores=physical, logical_threads=logical, threads_per_core=tpc, model_name=model, ) # ────────────────────────────────────────────── # DISTRO DETECTION & PROFILES # ────────────────────────────────────────────── @dataclass class DistroProfile: family: str # Canonical family: arch, debian, redhat, suse, nixos, unknown name: str # Pretty name: "Arch Linux", "Fedora 40", etc. version_id: str # e.g. "40", "15.6", "24.05" pkg_manager: str # e.g. "pacman", "dnf", "zypper", "apt", "nix" install_cmd_template: str # e.g. "sudo pacman -S {packages}" binary_extra_paths: list[str] # Distro-specific dirs to search for binaries av1an_known_encoder_names: list[str] # Names this distro's av1an build may accept ffmpeg_pkg: str # Package name providing ffmpeg av1an_pkg: str # Package name providing av1an notes: str # Distro-specific quirks worth showing the user # Runtime dependency packages (key = generic name, value = distro package name) dep_pkgs: dict[str, str] = field(default_factory=dict) # Binaries that av1an invokes directly (not via ffmpeg) encoder_binaries: dict[str, list[str]] = field(default_factory=dict) # VSScript package name — on most distros this is bundled into 'vapoursynth', # but Debian/Ubuntu split it into a separate -script-dev package. # If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check. vsscript_pkg: str = "" def _read_os_release() -> dict[str, str]: """Parse /etc/os-release into a dict. Falls back to empty dict.""" os_release = Path("/etc/os-release") fallback = Path("/usr/lib/os-release") target = os_release if os_release.exists() else fallback if not target.exists(): return {} data = {} for line in target.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if "=" in line and not line.startswith("#"): key, _, val = line.partition("=") data[key.strip()] = val.strip().strip('"') return data def detect_distro() -> DistroProfile: """ Detect the running Linux distribution via /etc/os-release. Returns a DistroProfile with distro-specific package manager, install commands, binary search paths, and known quirks. """ info = _read_os_release() id_like = info.get("ID_LIKE", "").lower().split() distro_id = info.get("ID", "").lower() pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system())) version = info.get("VERSION_ID", "?") # --- Classify into families --- if distro_id in ("arch", "manjaro", "endeavouros", "garuda", "cachyos") or "arch" in id_like: return DistroProfile( family="arch", name=pretty, version_id=version, pkg_manager="pacman", install_cmd_template="sudo pacman -S {packages}", binary_extra_paths=[ "/usr/bin", "/usr/local/bin", "~/.local/bin", # AUR user installs "~/.cargo/bin", # Rust-based av1an installs ], av1an_known_encoder_names=["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "libopus", "vorbis": "libvorbis", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes=( "Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. " "SVT-AV1 encoder name is typically 'svt_av1'. " "Cargo-installed av1an may live in ~/.cargo/bin." ), ) if distro_id in ("fedora",) or "fedora" in id_like: return DistroProfile( family="redhat", name=pretty, version_id=version, pkg_manager="dnf", install_cmd_template="sudo dnf install {packages}", binary_extra_paths=[ "/usr/bin", "/usr/local/bin", "~/.cargo/bin", ], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx-tools", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes=( "Fedora: av1an may require COPR enablement first: " "sudo dnf copr enable sergiomb/av1an (or build from source). " "SVT-AV1 is in the main repos as 'svt-av1'. " "Ensure RPM Fusion is enabled for full codec support." ), ) if distro_id in ("rhel", "centos", "rocky", "almalinux", "ol") or "rhel" in id_like or "centos" in id_like: return DistroProfile( family="redhat", name=pretty, version_id=version, pkg_manager="dnf" if Path("/usr/bin/dnf").exists() else "yum", install_cmd_template=( "sudo dnf install {packages}" if Path("/usr/bin/dnf").exists() else "sudo yum install {packages}" ), binary_extra_paths=[ "/usr/bin", "/usr/local/bin", "~/.cargo/bin", ], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx-tools", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes=( "RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. " "Options: (1) cargo install av1an, (2) build from GitHub source, " "(3) use pre-built binary from releases. " "Enable EPEL + RPM Fusion for FFmpeg codec support." ), ) if distro_id in ("opensuse-leap", "opensuse-tumbleweed", "sles") or "suse" in id_like: return DistroProfile( family="suse", name=pretty, version_id=version, pkg_manager="zypper", install_cmd_template="sudo zypper install {packages}", binary_extra_paths=[ "/usr/bin", "/usr/local/bin", "~/.cargo/bin", ], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "libopus", "vorbis": "libvorbis", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes=( "openSUSE: av1an may be available via OBS (Open Build Service). " "Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. " "Packman repo provides FFmpeg with full codec support." ), ) if "nixos" in distro_id or "nixos" in " ".join(id_like): return DistroProfile( family="nixos", name=pretty, version_id=version, pkg_manager="nix", install_cmd_template="nix-shell -p {packages}", binary_extra_paths=[ "/run/current-system/sw/bin", "~/.nix-profile/bin", ], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes=( "NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. " "Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. " "av1an CLI flags may differ from other distros depending on the nixpkgs channel." ), ) # Debian / Ubuntu / Mint / Pop!_OS / etc. if distro_id in ("debian", "ubuntu", "linuxmint", "pop") or "debian" in id_like: return DistroProfile( family="debian", name=pretty, version_id=version, pkg_manager="apt", install_cmd_template="sudo apt install {packages}", binary_extra_paths=[ "/usr/bin", "/usr/local/bin", "~/.cargo/bin", ], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svtav1", "x265": "x265", "vpx": "libvpx-tools", "opus": "libopus-dev", "vorbis": "libvorbis-dev", "flac": "flac", }, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, vsscript_pkg="libvapoursynth-script-dev", notes=( "Debian/Ubuntu: av1an is in the repos (apt install av1an). " "Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. " "VSScript is in a separate package: libvapoursynth-script-dev. " "For newer builds, consider cargo install av1an." ), ) # Fallback: unknown distro return DistroProfile( family="unknown", name=pretty, version_id=version, pkg_manager="unknown", install_cmd_template="# Unknown distro — install ffmpeg and av1an manually", binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"], av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={}, encoder_binaries={ "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], }, notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.", ) # ────────────────────────────────────────────── # ENVIRONMENT PROBE (distro-aware, extended) # ────────────────────────────────────────────── @dataclass class EnvProbe: distro: DistroProfile = field(default_factory=lambda: DistroProfile( family="unknown", name="Unknown", version_id="?", pkg_manager="unknown", install_cmd_template="", binary_extra_paths=[], av1an_known_encoder_names=[], ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes="" )) av1an_path: Optional[str] = None ffmpeg_path: Optional[str] = None ffprobe_path: Optional[str] = None av1an_flags: dict = field(default_factory=dict) av1an_version: Optional[str] = None ffmpeg_version: Optional[str] = None ffmpeg_libs: dict = field(default_factory=dict) # lib name -> bool (available) runtime_deps: dict = field(default_factory=dict) # dep name -> bool (present) missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install vs_version: Optional[str] = None # VapourSynth version string (for diagnostics) vs_script_lib: Optional[str] = None # path to libvapoursynth-script.so that passed cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown")) errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @property def ready(self) -> bool: return (self.av1an_path is not None and self.ffmpeg_path is not None and not self.errors and not self.missing_dep_pkgs) @property def dep_install_hint(self) -> str: """Generate a distro-specific install command for missing runtime deps.""" if not self.missing_dep_pkgs or self.distro.family == "unknown": return "" return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs)) @property def install_hint(self) -> str: """Generate a distro-specific install command for missing packages.""" missing = [] if self.av1an_path is None: missing.append(self.distro.av1an_pkg) if self.ffmpeg_path is None: missing.append(self.distro.ffmpeg_pkg) if not missing: return "" return self.distro.install_cmd_template.format(packages=" ".join(missing)) def _find_binary(name: str, distro: DistroProfile) -> Optional[str]: """ Search for a binary in: (1) standard PATH via shutil.which, then (2) distro-specific extra paths (expanded ~). Returns first match. """ # Standard PATH search found = shutil.which(name) if found: return found # Distro-specific extra paths for raw_path in distro.binary_extra_paths: expanded = Path(raw_path).expanduser() candidate = expanded / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) return None def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]: """Check which encoder/decoder libraries ffmpeg was compiled with. Runs ffmpeg -encoders ONCE and greps for all known encoder names. Each entry: (key, [search_strings]) — any match = available. """ try: res = subprocess.run( [ffmpeg_bin, "-encoders"], capture_output=True, text=True, timeout=10, ) output = res.stdout except Exception: output = "" checks = [ ("libsvtav1", ["libsvt_av1", "svt_av1"]), ("libaom", ["libaom_av1", "aom_av1"]), ("libvpx", ["libvpx-vp9", "libvpx_vp9", "vpx_vp9"]), ("libx265", ["libx265"]), ("libopus", ["libopus"]), ("libvorbis", ["libvorbis"]), ("flac", ["flac"]), ] libs = {} for lib_name, search_strings in checks: libs[lib_name] = any(s in output for s in search_strings) return libs def _probe_av1an_version(av1an_bin: str) -> Optional[str]: """Extract av1an version string.""" try: # Try --version first, fall back to parsing --help header for args in (["--version"], ["--help"]): res = subprocess.run( [av1an_bin] + args, capture_output=True, text=True, timeout=10, ) output = res.stdout or res.stderr match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE) if match: return match.group(1) if res.stdout.strip(): # If --version produced output but no version match return res.stdout.strip().splitlines()[0][:60] except Exception: pass return None def _probe_ffmpeg_version(ffmpeg_bin: str) -> Optional[str]: """Extract ffmpeg version string.""" try: res = subprocess.run( [ffmpeg_bin, "-version"], capture_output=True, text=True, timeout=10, ) first_line = res.stdout.splitlines()[0] if res.stdout else "" match = re.search(r"ffmpeg version (\S+)", first_line) return match.group(1) if match else first_line[:60] except Exception: return None def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]: """Check runtime dependencies that av1an needs to function. Returns (deps_dict, missing_pkg_names). Checks: - VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen to get the VSScript API — without this it panics with 'Failed to get VSScript API') - Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc) """ deps: dict[str, bool] = {} missing_pkgs: list[str] = [] # --- VapourSynth + VSScript (critical: av1an will panic without it) --- # av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls # vsscript_init() / vsscript_createScript() / etc. It does NOT use the # Python vapoursynth module. The shared library and the VSScript API # library can be packaged separately on some distros (e.g. Debian has # libvapoursynth-script-dev). We must check what av1an actually loads. # # IMPORTANT: We do NOT call vsscript_init() in our probe. VSScript's init # internally calls Py_Initialize(), which crashes/fails when Python is # already running (our probe runs inside a Python subprocess). Instead, # we verify the shared library exists AND can be dlopen'd (CDLL constructor # resolves all .so dependencies). If it loads, it will work for av1an. vs_ok = False vs_detail = "" vs_ver_str = "" vs_lib_path = None # --- Step 1: Direct filesystem check (most reliable) --- # Check well-known install paths. Works even if ldconfig cache is stale. _vs_script_search = [ "/usr/lib/libvapoursynth-script.so", "/usr/lib/libvapoursynth_script.so", "/usr/lib64/libvapoursynth-script.so", "/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so", "/usr/local/lib/libvapoursynth-script.so", ] for p in _vs_script_search: if Path(p).is_file(): vs_lib_path = p break # --- Step 2: Glob search on known lib dirs --- if not vs_lib_path: for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib", "/usr/lib/x86_64-linux-gnu"): d = Path(lib_dir) if d.is_dir(): matches = list(d.glob("libvapoursynth-script.so*")) # Prefer unversioned .so over .so.0 (dev symlink) for m in sorted(matches, key=lambda p: p.name): vs_lib_path = str(m) break if vs_lib_path: break # --- Step 3: ldconfig -p --- if not vs_lib_path: try: res = subprocess.run( ["ldconfig", "-p"], capture_output=True, text=True, timeout=5, ) for line in res.stdout.splitlines(): if "libvapoursynth-script" in line or "libvapoursynth_script" in line: parts = line.split("=>") if len(parts) >= 2: vs_lib_path = parts[1].strip().split()[0] break except Exception: pass # --- Step 4: ctypes.util.find_library --- if not vs_lib_path: try: for name in ("vapoursynth-script", "vapoursynth_script"): found = ctypes.util.find_library(name) if found: vs_lib_path = found break except Exception: pass # --- Step 5: Distro-specific package file listing --- if not vs_lib_path: pkg_query = { "arch": ["pacman", "-Ql", "vapoursynth"], "debian": ["dpkg", "-L", "vapoursynth"], "redhat": ["rpm", "-ql", "vapoursynth"], "suse": ["rpm", "-ql", "vapoursynth"], } query_cmd = pkg_query.get(distro.family) if query_cmd: try: res = subprocess.run( query_cmd, capture_output=True, text=True, timeout=10, ) for line in res.stdout.splitlines(): line = line.strip() # Skip directory entries and grab .so files if "libvapoursynth-script" in line and line.endswith(".so"): vs_lib_path = line break if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path: vs_lib_path = line # versioned .so as fallback except Exception: pass # --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) --- # We do NOT gate on dlopen success. The library's constructor may call # Py_Initialize() which conflicts with our Python subprocess, causing a # silent segfault. av1an loads this library in its own fresh Rust process # where no Python is running — so it works there even if our probe crashes. # We only use dlopen to produce an optional warning. vs_dlopen_warning = "" if vs_lib_path: try: _escaped = vs_lib_path.replace("'", "\\'") probe_code = ( "import ctypes; " f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') " f"except OSError as e: print(f'LOAD_FAIL|{{e}}') " f"except Exception as e: print(f'LOAD_OTHER|{{e}}') " ) res = subprocess.run( [sys.executable, "-c", probe_code], capture_output=True, text=True, timeout=10, ) out = res.stdout.strip() if out == "LOAD_OK": vs_ok = True elif out: vs_dlopen_warning = f"dlopen test failed: {out}" vs_ok = True # file exists — let av1an try in its own process else: # subprocess produced no output — likely segfault in library # constructor (Py_Initialize conflict). File still exists. vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)" vs_ok = True except subprocess.TimeoutExpired: vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)" vs_ok = True except Exception as e: vs_dlopen_warning = f"dlopen probe error: {e}" vs_ok = True # Final gate: library file was found on disk if vs_lib_path and not vs_ok: vs_ok = True # file found on disk is sufficient if vs_ok: vs_detail = vs_lib_path or "found" # Try to get VapourSynth version from the core lib for diagnostics try: ver_probe = ( "import ctypes, ctypes.util; " "_lib = ctypes.util.find_library('vapoursynth'); " "if not _lib: " " import subprocess as _sp; " " _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); " " _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() " " if 'libvapoursynth.so.' in l and 'script' not in l]; " " _lib = _m[0] if _m else None; " "if _lib: " " try: " " _h = ctypes.CDLL(_lib); " " _fn = _h.vapoursynth_version; " " _fn.restype = ctypes.c_int; " " print(_fn()) " " except: pass " ) res = subprocess.run( [sys.executable, "-c", ver_probe], capture_output=True, text=True, timeout=10, ) ver_out = res.stdout.strip() if ver_out and ver_out.isdigit() and int(ver_out) > 0: vs_ver_str = f"R{ver_out}" except Exception: pass else: if not vs_detail: vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)" deps["vapoursynth"] = vs_ok if not vs_ok: # Determine which package(s) to suggest. # Most distros bundle VSScript into the main 'vapoursynth' package, # but some split it (Debian/Ubuntu: libvapoursynth-script-dev). # Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs. if distro.vsscript_pkg: missing_pkgs.append(distro.vsscript_pkg) elif "vapoursynth" in distro.dep_pkgs: missing_pkgs.append(distro.dep_pkgs["vapoursynth"]) deps["vs_detail"] = False # extra key for the diagnostic message else: deps["vs_detail"] = True # --- Encoder binaries (av1an invokes these directly, not via ffmpeg) --- for enc_key, binary_names in distro.encoder_binaries.items(): found = False for bin_name in binary_names: if _find_binary(bin_name, distro) is not None: found = True break deps[enc_key] = found if not found: # Map encoder key to dep_pkgs key dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"} dep_key = dep_key_map.get(enc_key, enc_key) if dep_key in distro.dep_pkgs: pkg_name = distro.dep_pkgs[dep_key] if pkg_name not in missing_pkgs: missing_pkgs.append(pkg_name) # --- ffprobe (needed for input file validation) --- # Already checked in probe_environment() for the main binary, but let's # make sure the dep dict reflects it for consistency. # (ffprobe_path is set separately in probe_environment) return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning def probe_environment() -> EnvProbe: """ Distro-aware binary detection + av1an flag compatibility probe + ffmpeg library availability check. """ distro = detect_distro() result = EnvProbe(distro=distro) result.cpu = detect_cpu_topology() cpu = result.cpu result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})") result.warnings.append( f"CPU: {cpu.model_name} — {cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical" ) # --- Binary detection (distro-aware path search) --- for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]: path = _find_binary(name, distro) if path is None: result.errors.append(f"Missing binary: {name}") else: setattr(result, attr, path) # --- Install hint for missing binaries --- if result.install_hint: result.warnings.append(f"Install command: {result.install_hint}") # --- FFmpeg version + library probe --- if result.ffmpeg_path: result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path) if result.ffmpeg_version: result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}") result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path) # Warn about missing AUDIO libs (video codecs are handled by av1an's own # encoder binaries — ffmpeg's video encoder list is irrelevant) audio_lib_warnings = { "Opus": "libopus", "Vorbis": "libvorbis", "FLAC": "flac", } for codec_label, lib_name in audio_lib_warnings.items(): if not result.ffmpeg_libs.get(lib_name, False): result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)") # --- Av1an version --- if result.av1an_path: result.av1an_version = _probe_av1an_version(result.av1an_path) if result.av1an_version: result.warnings.append(f"av1an version: {result.av1an_version}") # --- Av1an flag compatibility probe --- if result.av1an_path: try: help_out = subprocess.run( [result.av1an_path, "--help"], capture_output=True, text=True, timeout=15, ).stdout result.av1an_flags = { "worker": "--workers" if "--workers" in help_out else "-w", "video_params": "--video-params" if "--video-params" in help_out else "-v", "audio_params": "--audio-params" if "--audio-params" in help_out else "-a", } # Detect which encoder names this av1an build actually accepts. # Substring matching on --help is unreliable (e.g. "svt" appears in # descriptions but the real name may be "svtav1" or "svt_av1"). # Instead, pass a bogus encoder name and parse the clap error which # lists all valid values. svt_name = _detect_av1an_svt_encoder(result.av1an_path) if svt_name: result.av1an_flags["svt_name"] = svt_name result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'") else: # Absolute fallback — should rarely be needed result.av1an_flags["svt_name"] = "svt_av1" result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)") # Check for chunk-method availability (differs by av1an version/distro) if "--chunk-method" in help_out: result.av1an_flags["has_chunk_method"] = True # Check for --temp flag (lets us relocate av1an work dir out of user folders) if "--temp" in help_out: result.av1an_flags["has_temp"] = True result.av1an_flags["temp_flag"] = "--temp" elif "-T" in help_out: result.av1an_flags["has_temp"] = True result.av1an_flags["temp_flag"] = "-T" # Check for -s/segments flag (newer av1an) if "-s" in help_out or "--scenes" in help_out: result.av1an_flags["has_scenes"] = True # Detect concat method: prefer mkvmerge, fall back to ffmpeg if shutil.which("mkvmerge"): result.av1an_flags["concat_method"] = "mkvmerge" else: result.av1an_flags["concat_method"] = "ffmpeg" except Exception as e: result.errors.append(f"av1an probe failed: {e}") # --- Distro-specific notes --- if distro.notes: result.warnings.append(f"Distro note: {distro.notes}") # --- Runtime dependency probe (vapoursynth, encoder binaries) --- if result.av1an_path: deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro) result.runtime_deps = deps result.missing_dep_pkgs = missing_pkgs if deps.get("vapoursynth"): result.vs_version = vs_ver result.vs_script_lib = vs_detail # Log VapourSynth/VSScript with extra detail vs_status = "OK" if deps.get("vapoursynth") else "MISSING" result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}") if deps.get("vapoursynth"): # vs_detail is the library path on success result.warnings.append(f" VSScript lib: {vs_detail}") if vs_dlopen_warn: result.warnings.append(f" dlopen note: {vs_dlopen_warn}") else: # vs_detail is the failure reason result.warnings.append(f" Reason: {vs_detail}") # Log encoder binary deps (skip vs_detail key) for dep_name, present in deps.items(): if dep_name in ("vapoursynth", "vs_detail"): continue status = "OK" if present else "MISSING" result.warnings.append(f"Dependency: {dep_name} = {status}") if missing_pkgs: hint = result.dep_install_hint result.errors.append( f"Missing runtime dependencies: {', '.join(missing_pkgs)}" ) if hint: result.errors.append(f" FIX: {hint}") return result # ────────────────────────────────────────────── # FFPREPBE VALIDATION # ────────────────────────────────────────────── def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> Optional[dict]: """Returns stream info dict or None if invalid/unreadable.""" try: res = subprocess.run( [ffprobe_bin, "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", str(filepath)], capture_output=True, text=True, timeout=30, ) if res.returncode != 0: return None import json return json.loads(res.stdout) except Exception: return None def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool: """Verify that an encoded file actually has the requested output resolution. Returns True if the output matches (or is within 2px due to force_divisible_by=2), False otherwise. """ import json try: res = subprocess.run( [ffprobe_bin, "-v", "quiet", "-print_format", "json", "-show_streams", "-select_streams", "v:0", str(output_path)], capture_output=True, text=True, timeout=15, ) if res.returncode != 0: return True # can't verify, don't block data = json.loads(res.stdout) streams = data.get("streams", []) if not streams: return True ow = int(streams[0].get("width", 0) or 0) oh = int(streams[0].get("height", 0) or 0) # Allow 2px tolerance (force_divisible_by=2 rounding) if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2: return True return False except Exception: return True # can't verify, don't block def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None: """Determine the exact encoder name av1an accepts for SVT-AV1. Strategy (in order): 1. Run ``av1an --encoder __PROBE__`` and parse clap's error for ``[possible values: ...]``. 2. Parse ``--help`` for ``[default: ]`` next to ``--encoder``. 3. Regex fallback on the error output. """ import re try: # --- Method 1: clap error with possible values --- res = subprocess.run( [av1an_bin, "--encoder", "__PROBE_TEST__"], capture_output=True, text=True, timeout=10, ) stderr = res.stderr or "" stdout = res.stdout or "" combined = stderr + stdout m = re.search(r"\[possible values:\s*([^\]]+)\]", combined) if m: values = [v.strip().rstrip(',') for v in m.group(1).split()] for v in values: if "svt" in v.lower(): return v # --- Method 2: parse --help for encoder default value --- help_res = subprocess.run( [av1an_bin, "--help"], capture_output=True, text=True, timeout=10, ) help_text = (help_res.stdout or "") + (help_res.stderr or "") # Look for pattern: --encoder ... [default: svt-av1] m2 = re.search( r"--encoder\s+.*?\[default:\s*(\S+?)\]", help_text, re.DOTALL, ) if m2: return m2.group(1) # --- Method 3: regex fallback on the error output --- for line in combined.splitlines(): for token in re.findall(r"\bsvt[a-z_-]*av1[a-z_-]*\b", line, re.IGNORECASE): return token for token in re.findall(r"\bsvtav1\b", line, re.IGNORECASE): return token return None except Exception: return None def _av1an_env() -> dict[str, str]: """Build an env dict for subprocess that includes ~/.local/lib in LD_LIBRARY_PATH. When VapourSynth is built from git and installed to ~/.local/, the linker won't find libvapoursynth-script.so unless LD_LIBRARY_PATH points there. This function ensures every av1an invocation inherits that path. """ env = os.environ.copy() local_lib = str(Path.home() / ".local" / "lib") existing = env.get("LD_LIBRARY_PATH", "") if local_lib not in existing: env["LD_LIBRARY_PATH"] = f"{local_lib}:{existing}".rstrip(":") return env def _av1an_vsscript_smoke_test( av1an_bin: str, ffmpeg_bin: str, av1an_flags: dict, svt_name: str = "svt_av1", timeout: int = 30, ) -> tuple[bool, str]: """Pre-flight test: create a tiny video and try to run av1an on it. This catches 'Failed to get VSScript API' panics BEFORE the real queue starts. File-existence checks for libvapoursynth-script.so pass even when the ABI is incompatible (av1an's Rust vapoursynth crate built against a different VS version). Only actually invoking av1an reveals the mismatch. Returns (ok, detail_message). ok=True -> av1an initialized VSScript successfully. ok=False -> av1an panicked or failed; detail_message explains why. """ import tempfile with tempfile.TemporaryDirectory(prefix="av1an_smoke_") as tmpdir: test_in = Path(tmpdir) / "test_smoke.mkv" test_out = Path(tmpdir) / "test_smoke_out.mkv" # Create a 1-second 64x64 black video (video-only is enough to # trigger VSScript init in av1an — no audio needed). gen_cmd = [ ffmpeg_bin, "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24", "-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(test_in), ] try: res = subprocess.run(gen_cmd, capture_output=True, text=True, timeout=15) if res.returncode != 0: return False, f"ffmpeg test-video failed (rc={res.returncode}): {(res.stderr or '')[-200:]}" except Exception as e: return False, f"Could not generate smoke test video: {e}" if not test_in.exists(): return False, "Smoke test video was not created by ffmpeg" # Build minimal av1an command worker_flag = av1an_flags.get("worker", "--workers") vparams_flag = av1an_flags.get("video_params", "--video-params") aparams_flag = av1an_flags.get("audio_params", "--audio-params") cmd = [ av1an_bin, "-i", str(test_in), worker_flag, "1", "--encoder", svt_name, vparams_flag, "--preset 8 --crf 40 --keyint 240", "-o", str(test_out), ] # Use chunk-method select if available (triggers VSScript init) if av1an_flags.get("has_chunk_method"): cmd.extend(["--chunk-method", "select"]) try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=_av1an_env()) stderr = res.stderr or "" if res.returncode == 0 and test_out.exists(): # av1an succeeded — VSScript works test_out.unlink(missing_ok=True) return True, "av1an VSScript init OK" if "Failed to get VSScript API" in stderr: return False, "VSScript_API_INCOMPAT" # Check for invalid encoder name — means probe was wrong if "invalid value" in stderr and "--encoder" in stderr: return False, f"INVALID_ENCODER: {stderr[-200:]}" # Other failure — could be missing encoder binary, etc. tail = stderr[-300:] if stderr else "(no stderr)" return True, f"av1an smoke test had non-VSScript error (rc={res.returncode}): {tail}" except subprocess.TimeoutExpired: return True, "av1an smoke test timed out (not a VSScript issue)" except Exception as e: return True, f"av1an smoke test exception (not a VSScript issue): {e}" # ────────────────────────────────────────────── # TEMP DIRECTORY MANAGEMENT # ────────────────────────────────────────────── _APP_CACHE_DIR: Path | None = None def _get_app_temp_dir() -> Path: """Return the shared temp directory for all intermediate files. Priority: 1. ``~/.cache/OpenTranscode/tmp/`` (XDG-compliant, persistent across reboots) 2. ``/tmp/OpenTranscode/`` (fallback if home cache is unwritable) The directory is created on first call. All temp intermediates (pre-scaled MKVs, av1an work dirs) go here so the user's video folders stay clean. """ global _APP_CACHE_DIR if _APP_CACHE_DIR is not None: return _APP_CACHE_DIR # Try XDG cache dir first xdg_cache = os.environ.get("XDG_CACHE_HOME", "") if xdg_cache: candidate = Path(xdg_cache) / "OpenTranscode" / "tmp" else: candidate = Path.home() / ".cache" / "OpenTranscode" / "tmp" try: candidate.mkdir(parents=True, exist_ok=True) _APP_CACHE_DIR = candidate return _APP_CACHE_DIR except (OSError, PermissionError): pass # Fallback: /tmp/OpenTranscode fallback = Path("/tmp/OpenTranscode") try: fallback.mkdir(parents=True, exist_ok=True) _APP_CACHE_DIR = fallback return _APP_CACHE_DIR except (OSError, PermissionError): # Last resort: system temp _APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode" _APP_CACHE_DIR.mkdir(parents=True, exist_ok=True) return _APP_CACHE_DIR def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv") -> Path: """Build a unique temp path for *file_path* inside the app temp dir. Uses a short hash of the original absolute path to avoid collisions when files in different subdirs share the same stem. """ tmp_dir = _get_app_temp_dir() # Hash the absolute source path for uniqueness path_hash = hashlib.sha256(str(file_path.resolve()).encode()).hexdigest()[:12] return tmp_dir / f"{file_path.stem}.{path_hash}{suffix}" # ────────────────────────────────────────────── # ENCODER WORKER (QThread, from PySide6 ver, extended) # ────────────────────────────────────────────── class EncoderWorker(QThread): log_msg = Signal(str) progress_msg = Signal(str, int, int) # (filename, current, total) finished_queue = Signal(int, int) # (success_count, fail_count) def __init__( self, in_dir: Path, out_dir: Path, video_codec: VideoCodecProfile, audio_profile: AudioProfile, container: ContainerProfile, crf: int, preset_label: str, delete_source: bool, env: EnvProbe, extensions: set[str], resolution: ResolutionProfile, audio_level_db: float = 0.0, use_ffmpeg_fallback: bool = False, subtitle_lang: Optional[str] = None, ): super().__init__() self.in_dir = in_dir self.out_dir = out_dir self.video_codec = video_codec self.audio_profile = audio_profile self.container = container self.crf = crf self.preset_val = video_codec.preset_map.get(preset_label, 6) self.delete_source = delete_source self.env = env self.extensions = extensions self.resolution = resolution self.audio_level_db = audio_level_db self.use_ffmpeg_fallback = use_ffmpeg_fallback self.subtitle_lang = subtitle_lang self._stop = False self._current_temps: list[Path] = [] # temps for the file currently being processed self._sources_to_delete: list[Path] = [] # sources deferred for deletion after final cleanup self.success_count = 0 self.fail_count = 0 # Resolve temp dir once per worker (shared across all files in the queue) self._temp_dir = _get_app_temp_dir() def _ffmpeg_fallback_encode( self, file_path: Path, encode_input: Path, output_f: Path, ) -> bool: """Encode a single file using pure ffmpeg (av1an fallback path). Used when av1an cannot initialize VapourSynth. No chunk-parallel mode, but ffmpeg uses multithreaded encoding internally. Returns True on success, False on failure. """ # Check if ffmpeg has the video encoder we need ffmpeg_enc = self.video_codec.ffmpeg_encoder # Map our ffmpeg_encoder name to the ffmpeg_libs key ffmpeg_lib_key = { "libsvtav1": "libsvtav1", "libaom-av1": "libaom", "libvpx-vp9": "libvpx", "libx265": "libx265", }.get(ffmpeg_enc, ffmpeg_enc) if not self.env.ffmpeg_libs.get(ffmpeg_lib_key, False): self.log_msg.emit( f" FATAL: ffmpeg does not have '{ffmpeg_enc}' encoder. " f"Cannot fall back. Install a ffmpeg build with {ffmpeg_enc} support." ) return False v_args = self.video_codec.ffmpeg_vargs_fn(self.crf, self.preset_val) # Belt-and-suspenders: if a target resolution is set, inject -vf scale # directly into the ffmpeg command. This guarantees the output resolution # matches the dropdown even if the intermediate pre-scale was bypassed. vf_scale_args: list[str] = [] if self.resolution.width is not None and self.resolution.height is not None: vf_scale_args = [ "-vf", ( f"scale={self.resolution.width}:{self.resolution.height}:" f"force_original_aspect_ratio=decrease:force_divisible_by=2" ), ] # Audio args from profile audio_args = list(self.audio_profile.params) if abs(self.audio_level_db) > 0.01: per_file_gain = self._analyze_audio_loudness(file_path) if per_file_gain is not None and abs(per_file_gain) > 0.01: audio_args.extend(["-af", f"volume={per_file_gain:+.1f}dB"]) else: static_db = f"{self.audio_level_db:+.1f}".replace("+", "") audio_args.extend(["-af", f"volume={static_db}dB"]) cmd = [ self.env.ffmpeg_path, "-i", str(encode_input), ] + vf_scale_args + v_args + audio_args + [ "-movflags", "+faststart", # useful for MKV/WebM streaming "-y", str(output_f), ] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) if res.returncode == 0 and output_f.exists(): src_size = file_path.stat().st_size out_size = output_f.stat().st_size ratio = out_size / src_size if src_size > 0 else 0 if out_size > (src_size * 0.05): return True else: self.log_msg.emit( f" INTEGRITY: output only {ratio * 100:.1f}% of source." ) output_f.unlink(missing_ok=True) return False else: stderr_snip = (res.stderr or "")[-300:] self.log_msg.emit( f" ffmpeg error (rc={res.returncode}): {stderr_snip.strip()}" ) return False except subprocess.TimeoutExpired: self.log_msg.emit(f" TIMEOUT: ffmpeg exceeded 2h limit.") return False except Exception as e: self.log_msg.emit(f" SYSTEM ERROR: {e}") return False def run(self): # Use physical core count, leave 1 for OS/UI breathing room worker_count = max(1, self.env.cpu.physical_cores - 1) phys = self.env.cpu.physical_cores logical = self.env.cpu.logical_threads # Collect all valid files first (for progress tracking) # Exclude our own temp intermediates from previous failed runs. all_files = sorted( f for f in self.in_dir.rglob("*") if f.is_file() and f.suffix.lower() in self.extensions and not f.name.endswith(".scaled_tmp.mkv") ) total = len(all_files) if total == 0: self.log_msg.emit("INFO: No matching files found in source directory.") self.finished_queue.emit(0, 0) return # ── Mode banner ── # use_ffmpeg_fallback is set by the main thread's pre-flight check. # If True, the main thread already logged the fallback reason. if self.use_ffmpeg_fallback: self.log_msg.emit( f"FFmpeg fallback: {self.video_codec.ffmpeg_encoder} on {phys} cores " f"(single-pass, no chunk-parallel)" ) else: self.log_msg.emit( f"Chunk-parallel: {worker_count} workers on {phys} physical cores " f"({logical} logical threads, {self.env.cpu.threads_per_core}T/core)" ) self.log_msg.emit(f"Found {total} file(s) to process.") self.log_msg.emit(f"Temp dir: {self._temp_dir}") # ── Pre-scan: show each file's source → output resolution ── needs_scale = ( self.resolution.width is not None and self.resolution.height is not None ) if needs_scale: self.log_msg.emit(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})") else: self.log_msg.emit("Output resolution: Original (no scaling)") self.log_msg.emit("─── FILE RESOLUTION MAP ───") self._file_res_map: dict[Path, tuple] = {} # file -> (src_w, src_h, out_w, out_h) if self.env.ffprobe_path: for f in all_files: info = ffprobe_validate(f, self.env.ffprobe_path) sw, sh = None, None if info: for s in info.get("streams", []): if s.get("codec_type") == "video": sw = int(s.get("width", 0) or 0) sh = int(s.get("height", 0) or 0) break if sw and sh: ow, oh = (self.resolution.width, self.resolution.height) if needs_scale else (sw, sh) self._file_res_map[f] = (sw, sh, ow, oh) arrow = "->" if needs_scale else "=" action = "" if needs_scale or sw == ow else " (no change)" self.log_msg.emit(f" {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}") else: self._file_res_map[f] = (None, None, self.resolution.width if needs_scale else None, self.resolution.height if needs_scale else None) self.log_msg.emit(f" {f.name:<40s} (unknown resolution)") else: self.log_msg.emit(" (ffprobe unavailable — resolution map skipped)") self.log_msg.emit("───────────────────────────") scale_filter = ( f"scale={self.resolution.width}:{self.resolution.height}:" f"force_original_aspect_ratio=decrease:force_divisible_by=2," f"pad={self.resolution.width}:{self.resolution.height}:(ow-iw)/2:(oh-ih)/2" ) if needs_scale else "" for idx, file_path in enumerate(all_files, 1): if self._stop: self.log_msg.emit("STOP: Aborted by user.") break self.progress_msg.emit(file_path.name, idx, total) self.log_msg.emit(f"[{idx}/{total}] Encoding: {file_path.name}") # --- ffprobe pre-validation --- # Reuse pre-scanned dimensions if available, otherwise probe now prescan = self._file_res_map.get(file_path) src_w, src_h = (prescan[0], prescan[1]) if prescan else (None, None) info = None if self.env.ffprobe_path: info = ffprobe_validate(file_path, self.env.ffprobe_path) if info is None: self.log_msg.emit(f"WARN: ffprobe could not read {file_path.name} — attempting encode anyway.") else: duration = float(info.get("format", {}).get("duration", 0)) has_video = any(s.get("codec_type") == "video" for s in info.get("streams", [])) if not has_video: self.log_msg.emit(f"SKIP: {file_path.name} has no video stream.") self.fail_count += 1 continue if duration < 0.5: self.log_msg.emit(f"SKIP: {file_path.name} is too short ({duration:.1f}s).") self.fail_count += 1 continue # Extract dims if pre-scan didn't have them if not src_w or not src_h: for s in info.get("streams", []): if s.get("codec_type") == "video": src_w = int(s.get("width", 0) or 0) src_h = int(s.get("height", 0) or 0) break # --- Determine actual output resolution --- if src_w and src_h: out_w, out_h = src_w, src_h if needs_scale: out_w, out_h = self.resolution.width, self.resolution.height self.log_msg.emit(f" Source: {src_w}x{src_h} -> Output: {out_w}x{out_h}") else: if needs_scale: self.log_msg.emit(f" Source: unknown -> Output: {self.resolution.width}x{self.resolution.height}") else: self.log_msg.emit(f" Source: unknown -> Output: original") # --- Pre-scale with ffmpeg if target resolution selected --- # ALL intermediates (scaled files, av1an work dirs) go to the app # temp directory so the user's video folders stay clean. encode_input = file_path av1an_work: Path | None = None if needs_scale: try: temp_scaled = _temp_path_for(file_path, ".scaled_tmp.mkv") self._current_temps.append(temp_scaled) # Use libx265 lossless for the intermediate — NOT ffv1. # ffv1 is not supported by VapourSynth source plugins (bestsource, # ffms2, lsmash), so av1an's chunking pipeline produces an empty # pipe and the encoder emits "Fatal: Failed to open input file". # libx265 -crf 0 is bit-for-bit lossless, fast at ultrafast preset, # and HEVC-in-MKV is universally supported by every VS plugin. scale_cmd = [ self.env.ffmpeg_path, "-i", str(file_path), "-vf", scale_filter, "-c:v", "libx265", "-crf", "0", "-preset", "ultrafast", "-pix_fmt", "yuv420p", # force 8-bit 4:2:0 "-y", str(temp_scaled), ] self.log_msg.emit(f" Scaling {src_w or '?'}x{src_h or '?'} -> {self.resolution.width}x{self.resolution.height}...") scale_res = subprocess.run( scale_cmd, capture_output=True, text=True, timeout=1800, ) if scale_res.returncode == 0 and temp_scaled.exists(): encode_input = temp_scaled scaled_size = temp_scaled.stat().st_size / 1_048_576 self.log_msg.emit(f" Pre-scale OK ({scaled_size:.1f} MB intermediate)") else: stderr_snip = (scale_res.stderr or "")[-200:] self.log_msg.emit( f" FAIL: Pre-scale failed for {file_path.name} (rc={scale_res.returncode}). " f"Output resolution must match selected {self.resolution.width}x{self.resolution.height}." ) if stderr_snip.strip(): self.log_msg.emit(f" ffmpeg stderr: {stderr_snip.strip()}") temp_scaled.unlink(missing_ok=True) self._cleanup_current_temps() self.fail_count += 1 continue except Exception as e: self.log_msg.emit( f" FAIL: Pre-scale error for {file_path.name}: {e}. " f"Cannot guarantee output resolution {self.resolution.width}x{self.resolution.height}." ) self._cleanup_current_temps() self.fail_count += 1 continue # --- Ensure av1an work dir lands in the temp directory --- # av1an creates its work dir as {input_path}.av1an by default. # We do NOT use av1an's --temp flag because it causes "Error: End of file" # during scene detection when the input file is in the same directory # as --temp (av1an 0.5.2-unstable). Instead, we ensure the -i argument # always points into the temp dir (pre-scaled files already live there; # for no-scale we create a symlink). if not encode_input.is_relative_to(self._temp_dir): symlink_path = _temp_path_for(file_path, encode_input.suffix) try: symlink_path.unlink(missing_ok=True) symlink_path.symlink_to(file_path.resolve()) self._current_temps.append(symlink_path) encode_input = symlink_path except OSError as e: self.log_msg.emit( f" WARN: Could not create symlink in temp dir: {e}. " f"av1an work dir will be created next to source file." ) # Track the work dir where av1an will actually create it av1an_work = Path(f"{encode_input}.av1an") self._current_temps.append(av1an_work) # --- Build output path (preserve directory structure) --- rel_path = file_path.relative_to(self.in_dir) target_dir = self.out_dir / rel_path.parent target_dir.mkdir(parents=True, exist_ok=True) ext = self.container.ext # Always add resolution suffix when a target resolution is selected res_suffix = f"_{self.resolution.width}x{self.resolution.height}" if needs_scale else "" output_f = target_dir / f"{file_path.stem}{res_suffix}_archived.{ext}" # ── Choose encode path: av1an or ffmpeg fallback ── if self.use_ffmpeg_fallback: # ── Pure ffmpeg encode path ── self.log_msg.emit(f" Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})") success = self._ffmpeg_fallback_encode( file_path, encode_input, output_f, ) if success: # Post-encode resolution verification if needs_scale and self.env.ffprobe_path: if not _verify_output_resolution( output_f, self.env.ffprobe_path, self.resolution.width, self.resolution.height, ): self.log_msg.emit( f" FAIL: Output resolution verification failed for {file_path.name}. " f"Expected {self.resolution.width}x{self.resolution.height}." ) output_f.unlink(missing_ok=True) self._cleanup_current_temps() self.fail_count += 1 continue src_size = file_path.stat().st_size out_size = output_f.stat().st_size ratio = out_size / src_size if src_size > 0 else 0 # Duration integrity check (>= 95% of source) dur_ok = True dur_info = "" if self.env.ffprobe_path: src_dur = ffprobe_duration(file_path, self.env.ffprobe_path) out_dur = ffprobe_duration(output_f, self.env.ffprobe_path) if src_dur and out_dur: dur_ratio = out_dur / src_dur dur_ok = dur_ratio >= 0.95 dur_info = f", duration {out_dur:.1f}s/{src_dur:.1f}s ({dur_ratio * 100:.0f}%)" if not dur_ok: self.fail_count += 1 self.log_msg.emit(f"INTEGRITY FAIL: {file_path.name} — duration{dur_info}") output_f.unlink(missing_ok=True) self._cleanup_current_temps() continue # Mux subtitle if requested (needs source file intact) if self.subtitle_lang: self._mux_subtitle(file_path, output_f) self.success_count += 1 self.log_msg.emit( f"SUCCESS: {file_path.name} " f"({src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB, " f"{ratio * 100:.0f}%{dur_info})" ) # Defer source deletion until after final cleanup if self.delete_source: self._sources_to_delete.append(file_path) self._cleanup_current_temps() else: self.fail_count += 1 self._cleanup_current_temps() continue # ── av1an encode path (original) ── # Resolve encoder name with probe data enc = self.video_codec.av1an_encoder if enc in ("svt_av1", "svt") and "svt_name" in self.env.av1an_flags: enc = self.env.av1an_flags["svt_name"] # Build params via config table (no if/else) v_params = self.video_codec.params_fn(self.crf, self.preset_val) # Audio params: dual-pass normalization per file, or simple volume audio_parts = list(self.audio_profile.params) if abs(self.audio_level_db) > 0.01: per_file_gain = self._analyze_audio_loudness(file_path) if per_file_gain is not None and abs(per_file_gain) > 0.01: audio_parts.extend(["-af", f"volume={per_file_gain:+.1f}dB"]) else: # Fallback to knob's static value if analysis failed static_db = f"{self.audio_level_db:+.1f}".replace("+", "") audio_parts.extend(["-af", f"volume={static_db}dB"]) self.log_msg.emit(f" Audio: static gain {self.audio_level_db:+.1f} dB (analysis unavailable)") audio_str = " ".join(audio_parts) cmd = [ self.env.av1an_path, "-i", str(encode_input), self.env.av1an_flags.get("worker", "--workers"), str(worker_count), ] # Chunk method: let av1an auto-select. # The intermediate (when pre-scaling) is now HEVC lossless in MKV, # which all VS plugins handle correctly, so auto-select is safe. chunk_method = self.env.av1an_flags.get("chunk_method_override") if chunk_method: cmd.extend(["--chunk-method", chunk_method]) self.log_msg.emit(f" Chunking: {chunk_method or 'auto'} (av1an default if no override)") cmd.extend([ "--encoder", enc, self.env.av1an_flags.get("video_params", "--video-params"), v_params, self.env.av1an_flags.get("audio_params", "--audio-params"), audio_str, "--concat", self.env.av1an_flags.get("concat_method", "ffmpeg"), "-o", str(output_f), ]) # Log the full av1an command for debugging self.log_msg.emit(f" CMD: {' '.join(cmd)}") try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, env=_av1an_env()) if res.returncode == 0 and output_f.exists(): src_size = file_path.stat().st_size out_size = output_f.stat().st_size ratio = out_size / src_size if src_size > 0 else 0 # Integrity: output must be at least 5% of source if out_size > (src_size * 0.05): # Post-encode resolution verification if needs_scale and self.env.ffprobe_path: if not _verify_output_resolution( output_f, self.env.ffprobe_path, self.resolution.width, self.resolution.height, ): self.fail_count += 1 self.log_msg.emit( f" FAIL: Output resolution verification failed for {file_path.name}. " f"Expected {self.resolution.width}x{self.resolution.height}." ) output_f.unlink(missing_ok=True) self._cleanup_current_temps() continue # Duration integrity check (>= 95% of source) dur_ok = True dur_info = "" if self.env.ffprobe_path: src_dur = ffprobe_duration(file_path, self.env.ffprobe_path) out_dur = ffprobe_duration(output_f, self.env.ffprobe_path) if src_dur and out_dur: dur_ratio = out_dur / src_dur dur_ok = dur_ratio >= 0.95 dur_info = f", duration {out_dur:.1f}s/{src_dur:.1f}s ({dur_ratio * 100:.0f}%)" if not dur_ok: self.fail_count += 1 self.log_msg.emit(f"INTEGRITY FAIL: {file_path.name} — duration{dur_info}") output_f.unlink(missing_ok=True) self._cleanup_current_temps() continue # Mux subtitle if requested (needs source file intact) if self.subtitle_lang: self._mux_subtitle(file_path, output_f) self.success_count += 1 self.log_msg.emit( f"SUCCESS: {file_path.name} " f"({src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB, " f"{ratio * 100:.0f}%{dur_info})" ) # Defer source deletion until after final cleanup if self.delete_source: self._sources_to_delete.append(file_path) else: self.fail_count += 1 self.log_msg.emit( f"ERROR: Integrity check failed for {file_path.name} " f"(output only {ratio * 100:.1f}% of source)." ) # Remove corrupt output output_f.unlink(missing_ok=True) else: self.fail_count += 1 stderr_full = res.stderr or "" stderr_tail = stderr_full[-300:] self.log_msg.emit(f"FAIL: {file_path.name} (code {res.returncode}) | {stderr_tail}") # Detect known av1an crash patterns and provide actionable fixes if "Failed to get VSScript API" in stderr_full: self.log_msg.emit("") self.log_msg.emit("DIAGNOSIS: av1an cannot initialize VapourSynth.") self.log_msg.emit( " This means the av1an binary was compiled against a different " "VapourSynth version than what is currently installed." ) distro = self.env.distro if distro.family == "arch": self.log_msg.emit( " FIX (Arch): Rebuild av1an against current VapourSynth:" ) self.log_msg.emit(" yay -S av1an (AUR rebuild)") self.log_msg.emit(" or: cargo install av1an --force --locked") self.log_msg.emit( " VapourSynth R77+ changed the VSScript API. " "Your av1an binary needs to be recompiled." ) elif distro.family == "debian": self.log_msg.emit( " FIX (Debian/Ubuntu): Ensure matching versions:" ) self.log_msg.emit(" sudo apt install vapoursynth libvapoursynth-script-dev av1an") else: self.log_msg.emit( " FIX: Reinstall av1an to rebuild against your current " "VapourSynth. Check your distro's package manager." ) self.log_msg.emit("") # Stop the queue — all remaining files will hit the same crash self._stop = True self.log_msg.emit("STOP: Skipping remaining files (same VSScript issue).") elif "No usable encoder found" in stderr_full: self.log_msg.emit("") self.log_msg.emit("DIAGNOSIS: av1an cannot find the encoder binary.") self.log_msg.emit( " Verify the encoder is installed and in PATH." ) self._stop = True self.log_msg.emit("STOP: Skipping remaining files (same encoder issue).") elif "not a valid input file" in stderr_full or "could not open input" in stderr_full.lower(): self.log_msg.emit("") self.log_msg.emit("DIAGNOSIS: av1an cannot read this input file.") self.log_msg.emit(" Try playing the file with ffplay to verify it's not corrupt.") except subprocess.TimeoutExpired: self.fail_count += 1 self.log_msg.emit(f"TIMEOUT: {file_path.name} exceeded 2h limit.") except Exception as e: self.fail_count += 1 self.log_msg.emit(f"SYSTEM ERROR: {file_path.name} — {e}") finally: # Always clean this file's temps before moving to next self._cleanup_current_temps() # ── Final cleanup pass: residual sweep ── self._final_cleanup_sweep() # ── Deferred source deletion (only after all cleanup is done) ── if self._sources_to_delete: deleted = 0 for src in self._sources_to_delete: try: if src.exists(): src.unlink() deleted += 1 except Exception: pass self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s) after verified transcode.") self._sources_to_delete.clear() if self.use_ffmpeg_fallback: self.log_msg.emit( f"QUEUE COMPLETE (ffmpeg fallback). Success: {self.success_count}, Failed: {self.fail_count}." ) else: self.log_msg.emit(f"QUEUE COMPLETE. Success: {self.success_count}, Failed: {self.fail_count}.") self.finished_queue.emit(self.success_count, self.fail_count) def _cleanup_current_temps(self): """Remove all tracked temp files/dirs for the current file. Resilient: each removal is try/except'd individually so one bad path doesn't block the rest. Clears the tracking list when done. """ for tf in self._current_temps: try: if tf.is_dir(): shutil.rmtree(str(tf), ignore_errors=True) elif tf.exists(): tf.unlink() except Exception: pass self._current_temps.clear() def _final_cleanup_sweep(self): """Residual sweep to catch any orphaned temp files. Primary target: the app temp directory (~/.cache/OpenTranscode/tmp/). Since av1an --temp creates dirs named by input stem (no fixed suffix), we clean the ENTIRE temp directory contents as a nuclear option. Also scans in_dir/out_dir as a safety net for legacy temp files. """ swept = 0 # Nuclear cleanup of the app temp dir — remove everything in it. # This is safe because the dir ONLY contains our intermediates. if self._temp_dir.is_dir(): for hit in self._temp_dir.iterdir(): try: if hit.is_dir(): shutil.rmtree(str(hit), ignore_errors=True) else: hit.unlink(missing_ok=True) swept += 1 except Exception: pass # Safety-net sweep of user directories (for legacy temp files # written by older versions that placed temps next to source files) legacy_patterns = ["*.scaled_tmp.mkv", "*.av1an", "*_encodes", "*.*.av1an"] for search_dir in (self.in_dir, self.out_dir): if not search_dir.is_dir(): continue for pattern in legacy_patterns: for hit in search_dir.rglob(pattern): try: if hit.is_dir(): shutil.rmtree(str(hit), ignore_errors=True) else: hit.unlink(missing_ok=True) swept += 1 except Exception: pass # Also clean any orphans still in _current_temps (e.g. stop/crash mid-loop) self._cleanup_current_temps() if swept: self.log_msg.emit(f"CLEANUP: Swept {swept} residual temp file(s)/dir(s).") # ── Audio loudness analysis (dual-pass normalization) ── def _analyze_audio_loudness(self, file_path: Path) -> Optional[float]: """Dual-pass loudnorm analysis for a single file. Pass 1: Run loudnorm in analysis-only mode to measure the file's current integrated loudness (I) and true peak (TP). Returns the dB gain to apply, or None if analysis fails (falls back to the knob's static value). """ if not self.env.ffmpeg_path: return None if abs(self.audio_level_db) < 0.01: return None # knob is at 0 — no normalization requested target_lufs = self.audio_level_db # knob value IS the target LUFS try: # Pass 1: analyze current loudness analysis_cmd = [ self.env.ffmpeg_path, "-i", str(file_path), "-af", ( f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11:" f"print_format=json" ), "-f", "null", "-", ] res = subprocess.run( analysis_cmd, capture_output=True, text=True, timeout=120, ) # Parse the JSON stats from stderr (loudnorm prints to stderr) stderr = res.stderr or "" # Find the JSON block json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', stderr, re.DOTALL) if not json_match: return None import json stats = json.loads(json_match.group()) input_i = float(stats.get("input_i", "-99")) input_tp = float(stats.get("input_tp", "-99")) target_tp = float(stats.get("target_tp", "-1.5")) # If file is already silent or near-silent, skip if input_i <= -70: return None # Compute the gain loudnorm would apply gain_db = target_lufs - input_i # Pass 2 concept: check if applying this gain would push peaks # above our ceiling. The ceiling is target_tp (default -1.5 dBTP). # We want 15% headroom below that ceiling. headroom_db = abs(target_tp) * 0.15 peak_ceiling = target_tp + headroom_db # If the file's true peak + gain would exceed the ceiling, clamp projected_peak = input_tp + gain_db if projected_peak > peak_ceiling: gain_db = peak_ceiling - input_tp self.log_msg.emit( f" Audio: {input_i:.1f} LUFS -> {target_lufs:.1f} LUFS " f"(gain {gain_db:+.1f} dB, peak {input_tp:.1f} -> " f"{input_tp + gain_db:.1f} dBTP)" ) return gain_db except Exception as e: self.log_msg.emit(f" Audio: loudnorm analysis failed ({e}), using knob value") return None # ── Subtitle extraction & muxing ── def _find_subtitle_stream(self, source: Path, lang: str) -> tuple[Optional[int], str]: """Find subtitle stream in source matching language code. Prefers forced disposition tracks. Returns (stream_index, codec_name).""" if not self.env.ffprobe_path: return (None, "") info = ffprobe_validate(source, self.env.ffprobe_path) if not info: return (None, "") forced_match = None any_match = None for stream in info.get("streams", []): if stream.get("codec_type") != "subtitle": continue tags = stream.get("tags", {}) if tags.get("language", "").lower() != lang.lower(): continue idx = stream.get("index") codec = stream.get("codec_name", "") disposition = stream.get("disposition", {}) if disposition.get("forced") and forced_match is None: forced_match = (idx, codec) if any_match is None: any_match = (idx, codec) return forced_match if forced_match else (any_match or (None, "")) def _mux_subtitle(self, source: Path, output: Path): """Mux a subtitle track from source into the encoded output (soft sub). Uses stream copy for MKV; converts to WebVTT for WebM containers.""" sub_idx, sub_codec = self._find_subtitle_stream(source, self.subtitle_lang) if sub_idx is None: self.log_msg.emit(f" SUBS: No {self.subtitle_lang} subtitle found in {source.name}") return # WebM only supports WebVTT natively; MKV carries any subtitle codec is_webm = output.suffix.lower() == ".webm" sub_codec_flag = "copy" if not is_webm else "webvtt" tmp_out = output.with_suffix(output.suffix + ".submux_tmp") try: cmd = [ self.env.ffmpeg_path, "-i", str(output), # encoded output (video + audio) "-i", str(source), # original source (subtitle source) "-map", "0", # all streams from encoded output "-map", "-0:s", # strip any subtitle from output "-map", f"1:{sub_idx}", # subtitle from source "-c:v", "copy", "-c:a", "copy", "-c:s", sub_codec_flag, "-y", str(tmp_out), ] res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode == 0 and tmp_out.exists() and tmp_out.stat().st_size > 0: output.unlink() tmp_out.rename(output) self.log_msg.emit( f" SUBS: Muxed {self.subtitle_lang} sub ({sub_codec}) into {output.name}" ) else: tmp_out.unlink(missing_ok=True) tail = (res.stderr or "")[-200:] self.log_msg.emit(f" SUBS WARN: Remux failed for {output.name}: {tail}") except Exception as e: tmp_out.unlink(missing_ok=True) self.log_msg.emit(f" SUBS ERROR: {e}") def stop(self): self._stop = True # ────────────────────────────────────────────── # 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): super().__init__() self.build_vs = build_vs self.build_av1an = build_av1an self._stop = False 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) # 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 Exception 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: # ── Install build dependencies (may need one sudo prompt) ── self.log_msg.emit("") self.log_msg.emit("=== Installing build dependencies ===") all_deps = [ "meson", "ninja", "gcc", "pkg-config", "git", "nasm", "yasm", "cmake", "python", ] need_rust = self.build_av1an and not shutil.which("cargo") if need_rust: all_deps.append("rust") # Only invoke sudo if at least one dep is missing missing = [d for d in all_deps if not shutil.which(d)] if missing: self.log_msg.emit(f" Missing: {', '.join(missing)} — installing via pacman") rc, _ = self._sudo_cmd( ["pacman", "-S", "--needed", "--noconfirm"] + all_deps, timeout=300, label="pacman build-deps", ) if rc != 0: self.log_msg.emit(" (some deps may already be installed — continuing)") else: self.log_msg.emit(" All build dependencies already installed.") # Ensure cargo is in PATH after potential install os.environ["PATH"] = os.environ.get("PATH", "") + ":/usr/bin:/root/.cargo/bin:" + 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 # ── Build & install VapourSynth to ~/.local (NO sudo needed) ── if self.build_vs: self._build_vapoursynth() # ── Build av1an to ~/.cargo/bin (NO sudo needed) ── if self.build_av1an: self._build_av1an() # ── Ensure LD_LIBRARY_PATH includes local VS libs ── local_lib = str(Path.home() / ".local" / "lib") existing_ld = os.environ.get("LD_LIBRARY_PATH", "") if local_lib not in existing_ld: os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":") self.log_msg.emit(f" Set LD_LIBRARY_PATH to include {local_lib}") 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: self.log_msg.emit(f"BUILD FAILED: {e}") self.build_done.emit(False, str(e)) 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...") import shutil as _shutil _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 import shutil as _shutil _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(): os.environ["PATH"] = os.environ.get("PATH", "") + f":{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 stop(self): self._stop = True # ────────────────────────────────────────────── # MAIN WINDOW (merged UI from all 3) # ────────────────────────────────────────────── # ────────────────────────────────────────────── # RETRO-FUTURISTIC MEDIA CONSOLE THEME # ────────────────────────────────────────────── # Brushed aluminum, amber/green LED displays, # beveled metallic panels, VU meters, spectrum bars. # Modernized with: rounded corners, subtle glow, glassmorphism hints, # information-dense DAW-style layout. MMD3_QSS = """ /* ── Global ── */ QMainWindow, QWidget#central { background-color: #1a1a1e; } /* ── Group Boxes — brushed aluminum panels ── */ QGroupBox { font-family: 'Segoe UI', 'Ubuntu', sans-serif; font-size: 10px; font-weight: bold; color: #8a8a8a; border: 1px solid #3a3a40; border-radius: 8px; margin-top: 14px; padding: 14px 10px 10px 10px; background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2c2c32, stop:0.5 #27272c, stop:1 #222228); } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 2px 10px; color: #666; background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #2c2c32, stop:1 #222228); border-radius: 4px; } /* ── Labels ── */ QLabel { color: #999; font-size: 10px; font-family: 'Segoe UI', 'Ubuntu', sans-serif; } /* ── Line Edits — recessed aluminum wells ── */ QLineEdit { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #18181c, stop:1 #141418); border: 1px solid #333; border-radius: 4px; padding: 5px 8px; color: #d4aa50; /* amber LED */ font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace; font-size: 11px; selection-background-color: #d4aa50; selection-color: #000; } QLineEdit:focus { border-color: #d4aa50; } /* ── Combo Boxes ── */ QComboBox { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #1e1e24, stop:1 #1a1a20); border: 1px solid #3a3a40; border-radius: 4px; padding: 4px 8px; color: #c8c8c8; font-family: 'Segoe UI', 'Ubuntu', sans-serif; font-size: 11px; min-height: 24px; } QComboBox:hover { border-color: #555; } QComboBox:focus { border-color: #d4aa50; } QComboBox::drop-down { border: none; width: 22px; } QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 6px solid #888; margin-right: 6px; } QComboBox QAbstractItemView { background: #1e1e24; border: 1px solid #3a3a40; border-radius: 4px; color: #c8c8c8; selection-background-color: #3a3a48; selection-color: #d4aa50; padding: 4px; } QComboBox item { min-height: 22px; padding: 2px 8px; } /* ── Buttons — beveled metallic (MMD3 transport style) ── */ QPushButton { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #404048, stop:0.15 #38383f, stop:0.85 #2e2e35, stop:1 #28282e); border: 1px solid #4a4a52; border-bottom-color: #1a1a1e; border-radius: 5px; padding: 6px 16px; color: #d0d0d0; font-family: 'Segoe UI', 'Ubuntu', sans-serif; font-size: 11px; font-weight: bold; } QPushButton:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a4a54, stop:0.15 #424248, stop:0.85 #363640, stop:1 #303038); border-color: #5a5a64; color: #fff; } QPushButton:pressed { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #28282e, stop:1 #3a3a42); border-bottom-color: #4a4a52; border-top-color: #1a1a1e; } QPushButton:disabled { background: #222228; border-color: #2a2a30; color: #555; } /* Primary action button — amber glow */ QPushButton#btnRun { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3428, stop:0.15 #332e22, stop:0.85 #2a261c, stop:1 #221e16); border: 1px solid #5a4a30; border-bottom-color: #1a1608; color: #d4aa50; font-size: 13px; letter-spacing: 2px; } QPushButton#btnRun:hover { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a4030, stop:0.15 #423828, stop:0.85 #3a3020, stop:1 #322a1a); border-color: #d4aa50; color: #f0d080; } QPushButton#btnRun:disabled { background: #22201a; border-color: #2a2820; color: #5a4a30; } /* Stop button — red danger */ QPushButton#btnStop { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a2222, stop:0.15 #321c1c, stop:0.85 #2a1616, stop:1 #221010); border: 1px solid #5a3030; border-bottom-color: #1a0808; color: #e05050; font-size: 13px; letter-spacing: 2px; } QPushButton#btnStop:hover { border-color: #e05050; color: #ff7070; } QPushButton#btnStop:disabled { background: #221a1a; border-color: #2a2020; color: #5a3030; } /* Rebuild-from-git button — muted teal */ QPushButton#btnRebuild { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #1e2e2e, stop:0.15 #1a2a2a, stop:0.85 #162424, stop:1 #121e1e); border: 1px solid #2a5050; border-bottom-color: #0e1818; color: #50b0b0; font-size: 10px; letter-spacing: 1px; } QPushButton#btnRebuild:hover { border-color: #50b0b0; color: #70d0d0; } QPushButton#btnRebuild:disabled { background: #1a1e1e; border-color: #222828; color: #304040; } /* Browse buttons — small, subdued */ QPushButton#btnBrowse { font-size: 9px; padding: 4px 10px; letter-spacing: 1px; } /* ── Check Boxes ── */ QCheckBox { color: #999; font-size: 10px; spacing: 8px; font-family: 'Segoe UI', 'Ubuntu', sans-serif; } QCheckBox::indicator { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #444; background: #1a1a1e; } QCheckBox::indicator:checked { background: #d4aa50; border-color: #b8903a; } QCheckBox#dangerCheck { color: #c05050; font-weight: bold; } QCheckBox#dangerCheck::indicator:checked { background: #c04040; border-color: #a03030; } /* ── Text Edit (log) — LED terminal display ── */ QTextEdit#logBox { background: #0a0a0c; border: 2px solid #1e1e24; border-radius: 6px; color: #40d060; /* green phosphor LED */ font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace; font-size: 11px; padding: 8px; } /* ── Status Bar — LED readout strip ── */ QStatusBar { background: #0e0e12; border-top: 1px solid #2a2a30; font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace; font-size: 10px; color: #d4aa50; padding: 2px 8px; } QStatusBar QLabel { color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace; font-size: 10px; } /* ── Tooltips ── */ QToolTip { background: #2a2a30; color: #c8c8c8; border: 1px solid #444; border-radius: 4px; padding: 6px; font-size: 10px; } /* ── Scrollbars — thin, dark ── */ QScrollBar:vertical { background: #141418; width: 10px; border-radius: 5px; margin: 0; } QScrollBar::handle:vertical { background: #3a3a42; border-radius: 5px; min-height: 30px; } QScrollBar::handle:vertical:hover { background: #4a4a54; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QScrollBar:horizontal { background: #141418; height: 10px; border-radius: 5px; } QScrollBar::handle:horizontal { background: #3a3a42; border-radius: 5px; min-width: 30px; } QScrollBar::handle:horizontal:hover { background: #4a4a54; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } """ class OpenCodecMaster(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("OpenTranscode — dcos.net") self.resize(1100, 920) self.worker: Optional[EncoderWorker] = None self.env: Optional[EnvProbe] = None self._pending_deletes: list[Path] = [] self._apply_mmd3_theme() self._build_ui() # Probe environment after UI is up QTimer = __import__("PySide6.QtCore", fromlist=["QTimer"]).QTimer QTimer.singleShot(500, self._probe_and_init) # ── UI Construction ── def _build_ui(self): central = QWidget() central.setObjectName("central") self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(10, 6, 10, 4) root.setSpacing(4) # ── Header ── header = QWidget() header_lay = QVBoxLayout(header) header_lay.setContentsMargins(0, 0, 0, 0) header_lay.setSpacing(0) title = QLabel("OpenTranscode") title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold)) title.setAlignment(Qt.AlignmentFlag.AlignCenter) title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;") header_lay.addWidget(title) subtitle = QLabel('dcos.net // concurrent open-source transcoding') subtitle.setFont(QFont("Consolas", 8)) subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) subtitle.setStyleSheet("color: #555; letter-spacing: 2px;") header_lay.addWidget(subtitle) accent = QWidget() accent.setFixedHeight(1) accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0," "stop:0 transparent, stop:0.15 #d4aa5044," "stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);") header_lay.addWidget(accent) root.addWidget(header) # ── Paths ── path_grp = QGroupBox("Paths") path_lay = QVBoxLayout(path_grp) path_lay.setSpacing(2) path_lay.setContentsMargins(10, 14, 10, 8) self.in_path_edit = QLineEdit(str(Path.home() / "Videos" / "INCOMING")) self.out_path_edit = QLineEdit(str(Path.home() / "Videos" / "ARCHIVE")) for label_text, line_edit in [ ("IN:", self.in_path_edit), ("OUT:", self.out_path_edit), ]: row = QHBoxLayout() row.setSpacing(6) lbl = QLabel(label_text) lbl.setFixedWidth(28) lbl.setStyleSheet("color: #d4aa50; font-family: 'Consolas', monospace; font-weight: bold; font-size: 10px;") row.addWidget(lbl) row.addWidget(line_edit, 1) btn_browse = QPushButton("...") btn_browse.setObjectName("btnBrowse") btn_browse.setFixedSize(30, 22) btn_browse.setToolTip("Browse") btn_browse.clicked.connect( lambda checked, le=line_edit, is_dir=True: self._browse(le, is_dir) ) row.addWidget(btn_browse) path_lay.addLayout(row) root.addWidget(path_grp) # ── Encoder Chain ── codec_grp = QGroupBox("Encoder Chain") codec_lay = QHBoxLayout(codec_grp) codec_lay.setSpacing(8) codec_lay.setContentsMargins(10, 14, 10, 8) 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), ("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), ]): col = QVBoxLayout() col.setSpacing(1) lbl = QLabel(label) lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") col.addWidget(lbl) combo = QComboBox() combo.setFixedHeight(24) if combo_items: combo.addItems(combo_items) if slot: combo.currentIndexChanged.connect(slot) col.addWidget(combo) codec_lay.addLayout(col) if label == "VIDEO": self.codec_combo = combo elif label == "PRESET": self.preset_combo = combo self._populate_presets(0) self.preset_combo.setCurrentIndex(1) elif label == "AUDIO": self.audio_combo = combo elif label == "CONTAINER": self.container_combo = combo elif label == "RESOLUTION": self.resolution_combo = combo self._populate_resolution_combo() elif label == "SUBS": self.subs_combo = combo root.addWidget(codec_grp) # ── Side panel: compact knobs ── knobs_panel = QWidget() knobs_panel.setFixedWidth(170) knobs_lay = QVBoxLayout(knobs_panel) knobs_lay.setContentsMargins(6, 8, 6, 8) knobs_lay.setSpacing(6) knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) # CRF Knob — amber self.crf_knob = RadioKnob( min_val=18, max_val=52, default_val=32, label="Quality", unit="CRF", color=(212, 170, 80), num_ticks=18, tick_labels=["18", "28", "38", "52"], snap_ticks=True, compact=True, ) self.crf_knob.valueChanged.connect(self._on_crf_knob_changed) knobs_lay.addWidget(self.crf_knob, 0, Qt.AlignmentFlag.AlignHCenter) # Volume Knob — green (dual-pass loudnorm target) self.vol_knob = RadioKnob( min_val=-20.0, max_val=6.0, default_val=0.0, label="LUFS", unit="dB", color=(64, 208, 96), num_ticks=27, tick_labels=["-20", "-10", "0", "+6"], snap_ticks=True, compact=True, ) self.vol_knob.setToolTip( "Dual-pass audio normalization target (EBU R128 LUFS).\n" "0 = off (pass-through).\n" "Each file is analyzed individually: loudnorm measures its\n" "current LUFS and true peak, then computes the exact gain\n" "to hit this target. If the gain would push peaks above\n" "-1.5 dBTP, gain is reduced to keep 15%% headroom.\n" "Common targets: -14 (streaming), -16 (broadcast), -23 (cinema)." ) self.vol_knob.valueChanged.connect(self._on_vol_knob_changed) knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter) # ── Options row ── opt_row = QHBoxLayout() opt_row.setSpacing(8) opt_lbl = QLabel("FILTER") opt_lbl.setFixedWidth(44) opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") opt_row.addWidget(opt_lbl) self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS))) self.ext_edit.setFixedHeight(22) self.ext_edit.setToolTip("File extensions to process. Separate with commas.") opt_row.addWidget(self.ext_edit) self.del_check = QCheckBox("Delete source after verify") self.del_check.setObjectName("dangerCheck") self.del_check.setToolTip( "Sources are only deleted after all files finish and cleanup passes.\n" "If any file fails, you will be prompted before deletion." ) opt_row.addWidget(self.del_check) root.addLayout(opt_row) # ── Log + Knobs: horizontal split ── mid_split = QHBoxLayout() mid_split.setSpacing(6) # Log: LED terminal (takes remaining space) self.log_box = QTextEdit() self.log_box.setObjectName("logBox") self.log_box.setReadOnly(True) mid_split.addWidget(self.log_box, 1) # Knobs panel on the right mid_split.addWidget(knobs_panel) root.addLayout(mid_split, 1) # ── Status Bar: LED readout ── self.status = QStatusBar() self.setStatusBar(self.status) self.status_label = QLabel(" INITIALIZING...") self.status_label.setStyleSheet( "color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; font-size: 10px;" ) self.status.addWidget(self.status_label, 1) # ── Transport Buttons ── btn_lay = QHBoxLayout() btn_lay.setSpacing(8) self.btn_run = QPushButton(" > ENCODE") self.btn_run.setObjectName("btnRun") self.btn_run.setFixedHeight(40) self.btn_run.setEnabled(False) self.btn_run.clicked.connect(self._start_process) btn_lay.addWidget(self.btn_run) self.btn_stop = QPushButton(" [] STOP") self.btn_stop.setObjectName("btnStop") self.btn_stop.setFixedHeight(40) self.btn_stop.clicked.connect(self._stop_process) self.btn_stop.setEnabled(False) btn_lay.addWidget(self.btn_stop) self.btn_rebuild = QPushButton(" <> REBUILD FROM GIT") self.btn_rebuild.setObjectName("btnRebuild") self.btn_rebuild.setFixedHeight(40) self.btn_rebuild.setToolTip( "Compile VapourSynth + av1an from git source.\n" "Resolves ABI/version mismatch when package managers\n" "install incompatible versions." ) self.btn_rebuild.clicked.connect(self._manual_rebuild) self.btn_rebuild.setEnabled(False) btn_lay.addWidget(self.btn_rebuild) root.addLayout(btn_lay) # ── Footer ── footer = QWidget() footer_lay = QHBoxLayout(footer) footer_lay.setContentsMargins(6, 4, 6, 2) footer_lay.setSpacing(0) link_lbl = QLabel( 'Visit Homepage' ) link_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) link_lbl.setOpenExternalLinks(True) link_lbl.setStyleSheet("font-size: 8px;") footer_lay.addWidget(link_lbl) footer_lay.addStretch() copy_lbl = QLabel( 'AGPL-3.0 | Jeremy Anderson - dcos.net (c) 2026' ) copy_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) copy_lbl.setOpenExternalLinks(True) copy_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) copy_lbl.setStyleSheet("color: #555; font-size: 8px;") footer_lay.addWidget(copy_lbl) root.addWidget(footer) def _apply_mmd3_theme(self): self.setStyle(QStyleFactory.create("Fusion")) self.setStyleSheet(MMD3_QSS) # Palette as fallback for things QSS doesn't cover p = QPalette() p.setColor(QPalette.ColorRole.Window, QColor(26, 26, 30)) p.setColor(QPalette.ColorRole.WindowText, QColor(200, 200, 200)) p.setColor(QPalette.ColorRole.Base, QColor(20, 20, 24)) p.setColor(QPalette.ColorRole.AlternateBase, QColor(40, 40, 46)) p.setColor(QPalette.ColorRole.ToolTipBase, QColor(30, 30, 36)) p.setColor(QPalette.ColorRole.ToolTipText, QColor(200, 200, 200)) p.setColor(QPalette.ColorRole.Text, QColor(200, 200, 200)) p.setColor(QPalette.ColorRole.Button, QColor(40, 40, 46)) p.setColor(QPalette.ColorRole.ButtonText, QColor(200, 200, 200)) p.setColor(QPalette.ColorRole.Highlight, QColor(212, 170, 80)) p.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) QApplication.instance().setPalette(p) # ── Slots ── @Slot() def _on_codec_changed(self, idx: int): self._populate_presets(idx) profile = VIDEO_CODECS[idx] lo, hi = profile.crf_range 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 def _populate_presets(self, codec_idx: int): self.preset_combo.blockSignals(True) self.preset_combo.clear() if 0 <= codec_idx < len(VIDEO_CODECS): self.preset_combo.addItems(VIDEO_CODECS[codec_idx].presets) self.preset_combo.blockSignals(False) @Slot() def _on_container_changed(self, idx: int): if idx >= 0: ext = CONTAINER_PROFILES[idx].ext self.log(f"Container set to: {ext}") def _populate_resolution_combo(self): """Populate resolution dropdown with separator headers per category.""" # 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 combo_pos = 0 for i, rp in enumerate(RESOLUTION_PRESETS): if rp.category != last_cat: if last_cat 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 combo_pos += 1 def _get_current_resolution(self) -> ResolutionProfile: """Get the ResolutionProfile for the current combo selection, handling separators.""" combo_idx = self.resolution_combo.currentIndex() preset_i = self._res_preset_indices.get(combo_idx) if preset_i is not None: return RESOLUTION_PRESETS[preset_i] return RESOLUTION_PRESETS[0] @Slot() def _on_resolution_changed(self, idx: int): rp = self._get_current_resolution() if rp.width is not None: self._log( f"Resolution: {rp.width}x{rp.height} ({rp.aspect_label}) — " f"files will be pre-scaled with ffmpeg before encoding." ) else: self._log("Resolution: Original (no scaling).") @Slot(float) def _on_crf_knob_changed(self, val: float): direction = "higher quality" if val < 28 else ("balanced" if val < 38 else "smaller file") self._log(f"CRF: {val:.0f} ({direction})") @Slot(float) def _on_vol_knob_changed(self, val: float): if abs(val) < 0.01: self._log("Audio normalization: OFF (pass-through)") else: direction = "louder" if val > 0 else "quieter" self._log(f"Audio normalization: {val:+.1f} dB ({direction})") @Slot() def _browse(self, line_edit: QLineEdit, is_dir: bool = True): if is_dir: path = QFileDialog.getExistingDirectory(self, "Select Directory") if path: line_edit.setText(path) def _log(self, msg: str): self.log_box.append(f"> {msg}") sb = self.log_box.verticalScrollBar() sb.setValue(sb.maximum()) # ── Environment Probe ── def _probe_and_init(self): self.env = probe_environment() # --- Distro banner --- distro = self.env.distro self._log(f"Distro: {distro.name} (family={distro.family}, v{distro.version_id})") self._log(f"Package manager: {distro.pkg_manager}") # --- Warnings (info-level, not errors) --- for w in self.env.warnings: self._log(f"INFO: {w}") # --- Hard errors --- if not self.env.av1an_path: self._log("CRITICAL: 'av1an' not found in PATH or distro-specific paths.") if self.env.install_hint: self._log(f" TRY: {self.env.install_hint}") self.status_label.setText(f"NOT READY — missing av1an ({distro.family})") return if not self.env.ffmpeg_path: self._log("CRITICAL: 'ffmpeg' not found in PATH or distro-specific paths.") if self.env.install_hint: self._log(f" TRY: {self.env.install_hint}") self.status_label.setText(f"NOT READY — missing ffmpeg ({distro.family})") return if self.env.errors: for e in self.env.errors: self._log(f"ERROR: {e}") # If there are still errors after logging (e.g. missing runtime deps), block start if self.env.errors: dep_count = len(self.env.missing_dep_pkgs) if dep_count: self.status_label.setText( f"NOT READY — {dep_count} runtime dep(s) missing. See log." ) return # --- Probe results --- flag_info = ", ".join(f"{k}={v}" for k, v in self.env.av1an_flags.items() if k != "has_chunk_method" and k != "has_scenes") self._log(f"av1an: {self.env.av1an_path} (v{self.env.av1an_version or '?'})") if flag_info: self._log(f" Flags: {flag_info}") if self.env.ffmpeg_version: self._log(f"ffmpeg: {self.env.ffmpeg_path} (v{self.env.ffmpeg_version})") # --- FFmpeg encoder library summary (audio-relevant only for our purposes) --- available_libs = [name for name, present in self.env.ffmpeg_libs.items() if present] missing_audio = [name for name, present in self.env.ffmpeg_libs.items() if not present and name in ("libopus", "libvorbis", "flac")] if available_libs: self._log(f" FFmpeg encoders available: {', '.join(available_libs)}") if missing_audio: self._log(f" FFmpeg audio encoders MISSING: {', '.join(missing_audio)}") self._log(f" Some audio codec options may fail. Check distro package: {distro.ffmpeg_pkg}") # --- Disable unavailable codec options in UI --- self._disable_unavailable_codecs() worker_count = max(1, self.env.cpu.physical_cores - 1) cpu = self.env.cpu self._log( f"Chunk-parallel mode: {worker_count} av1an workers " f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, " f"{cpu.threads_per_core}T/core)" ) self.btn_run.setEnabled(True) self.btn_run.setText("START PROCESSING") self.btn_rebuild.setEnabled(True) # available after successful probe vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" # Show ffmpeg video encoder availability (for fallback) fb_encs = [] for vc in VIDEO_CODECS: lib_key = {"libsvtav1": "libsvtav1", "libvpx-vp9": "libvpx", "libx265": "libx265"}.get(vc.ffmpeg_encoder, vc.ffmpeg_encoder) if self.env.ffmpeg_libs.get(lib_key, False): fb_encs.append(vc.ffmpeg_encoder) fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" self.status_label.setText( f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" ) 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. """ 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." ) if self.audio_combo.currentIndex() == idx: self.audio_combo.setCurrentIndex(0) # Fallback to Opus (96k) # ── Process Control ── def _parse_extensions(self) -> set[str]: raw = self.ext_edit.text() exts = set() for part in raw.split(","): part = part.strip().lower() if not part.startswith("."): part = "." + part if part: exts.add(part) return exts or DEFAULT_INPUT_EXTENSIONS @Slot() def _start_process(self): in_dir = Path(self.in_path_edit.text()) out_dir = Path(self.out_path_edit.text()) if not in_dir.is_dir(): self._log(f"ERROR: Source directory does not exist: {in_dir}") return if in_dir == out_dir: self._log("ERROR: Source and output directories must be different.") return # If delete is enabled, collect files first for batch confirmation if self.del_check.isChecked(): extensions = self._parse_extensions() candidates = [f for f in in_dir.rglob("*") if f.is_file() and f.suffix.lower() in extensions and not f.name.endswith(".scaled_tmp.mkv")] if candidates: total_size = sum(f.stat().st_size for f in candidates) reply = QMessageBox.question( self, "Confirm Batch Delete", f"This will delete {len(candidates)} source file(s) after successful transcode.\n" f"Total size: {total_size / 1_073_741_824:.2f} GB\n\n" f"Proceed?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: self._log("Cancelled: Delete not confirmed.") return # ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ── use_ffmpeg_fallback = False skip_encode = False if self.env.av1an_path and self.env.ffmpeg_path: self._log("Pre-flight: testing av1an + VapourSynth compatibility...") QApplication.processEvents() # keep UI responsive svt_name = self.env.av1an_flags.get("svt_name", "svt_av1") ok, detail = _av1an_vsscript_smoke_test( self.env.av1an_path, self.env.ffmpeg_path, self.env.av1an_flags, svt_name=svt_name, ) if not ok and "VSScript_API_INCOMPAT" in detail: # VSScript ABI mismatch detected — offer rebuild or fallback use_ffmpeg_fallback = self._handle_vs_incompat() if not use_ffmpeg_fallback: # User chose rebuild or cancel — don't start encoding return elif not ok and "INVALID_ENCODER" in detail: # Encoder name detection was wrong — re-probe and retry once self._log(f" WARN: Encoder name probe was incorrect. Re-detecting...") QApplication.processEvents() new_name = _detect_av1an_svt_encoder(self.env.av1an_path) if new_name: self.env.av1an_flags["svt_name"] = new_name self._log(f" Re-detected SVT-AV1 encoder name: '{new_name}'") # Retry smoke test with corrected name ok2, detail2 = _av1an_vsscript_smoke_test( self.env.av1an_path, self.env.ffmpeg_path, self.env.av1an_flags, svt_name=new_name, ) if ok2: self._log(" OK: av1an + VapourSynth working correctly.") else: self._log(f" FAIL: Still failing after re-detect: {detail2}") return else: self._log(" FAIL: Could not determine valid encoder name. Check av1an --help manually.") return elif ok: self._log(" OK: av1an + VapourSynth working correctly.") else: # Non-VSScript error (e.g. missing encoder binary) — let per-file loop handle it self._log(f" WARN: smoke test issue (non-fatal): {detail}") codec_idx = self.codec_combo.currentIndex() audio_idx = self.audio_combo.currentIndex() container_idx = self.container_combo.currentIndex() # Safety: clamp codec_idx to valid range if not (0 <= codec_idx < len(VIDEO_CODECS)): self._log(f"ERROR: Invalid codec index {codec_idx}. Resetting to AV1 (SVT-AV1).") codec_idx = 0 self.codec_combo.blockSignals(True) self.codec_combo.setCurrentIndex(0) self.codec_combo.blockSignals(False) selected_codec = VIDEO_CODECS[codec_idx] self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})") self.worker = EncoderWorker( in_dir=in_dir, out_dir=out_dir, video_codec=selected_codec, audio_profile=AUDIO_PROFILES[audio_idx], container=CONTAINER_PROFILES[container_idx], crf=self.crf_knob.intValue(), preset_label=self.preset_combo.currentText(), delete_source=self.del_check.isChecked(), env=self.env, extensions=self._parse_extensions(), resolution=self._get_current_resolution(), audio_level_db=self.vol_knob.value(), use_ffmpeg_fallback=use_ffmpeg_fallback, subtitle_lang=SUBTITLE_OPTIONS[self.subs_combo.currentIndex()][1], ) self.worker.log_msg.connect(self._log) self.worker.progress_msg.connect(self._on_progress) self.worker.finished_queue.connect(self._on_finished) self.btn_run.setEnabled(False) self.btn_run.setText("RUNNING...") self.btn_stop.setEnabled(True) self.btn_rebuild.setEnabled(False) self.worker.start() def _handle_vs_incompat(self) -> bool: """Handle detected VSScript ABI incompatibility. Shows a dialog with options: 1. Rebuild VapourSynth + av1an from git (resolves root cause) 2. Use ffmpeg fallback (works now, no chunk-parallel) 3. Cancel Returns True if we should use ffmpeg fallback (option 2), False if user cancelled or chose to rebuild (rebuild starts async and does NOT return here — the user will click ENCODE again after it completes). """ self._log(" FAIL: av1an cannot initialize VSScript API.") self._log(" The av1an binary was compiled against a different VapourSynth version.") # Check ffmpeg fallback availability codec_idx = self.codec_combo.currentIndex() video_codec = VIDEO_CODECS[codec_idx] ffmpeg_enc = video_codec.ffmpeg_encoder ffmpeg_lib_key = { "libsvtav1": "libsvtav1", "libaom-av1": "libaom", "libvpx-vp9": "libvpx", "libx265": "libx265", }.get(ffmpeg_enc, ffmpeg_enc) fallback_possible = self.env.ffmpeg_libs.get(ffmpeg_lib_key, False) if fallback_possible: btn_rebuild = QPushButton(" Rebuild from Git ") btn_rebuild.setObjectName("btnRebuild") btn_fallback = QPushButton(" Use ffmpeg Fallback ") btn_fallback.setObjectName("btnRun") btn_cancel = QPushButton(" Cancel ") btn_cancel.setObjectName("btnStop") dlg = QMessageBox(self) dlg.setWindowTitle("av1an + VapourSynth Version Mismatch") dlg.setText( "av1an cannot initialize VapourSynth — the installed versions\n" "have an ABI incompatibility (common with distro packages).\n\n" f"Choose how to proceed:" ) dlg.setInformativeText( "• Rebuild from Git — compiles both from source (~10-30 min).\n" " Fixes the root cause. Requires sudo for install.\n" f"• ffmpeg Fallback — encode with ffmpeg ({ffmpeg_enc}) now.\n" " No chunk-parallel mode but output quality is identical." ) dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole) dlg.addButton(btn_fallback, QMessageBox.ButtonRole.YesRole) dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) dlg.exec() clicked = dlg.clickedButton() if clicked == btn_rebuild: self._log("") self._log("User chose: Rebuild VapourSynth + av1an from git.") self._start_git_rebuild() return False # don't start encoding — user will retry after build elif clicked == btn_fallback: self._log("") self._log(f"FALLBACK: Switching to pure ffmpeg ({ffmpeg_enc}) encoding.") self._log( " Note: ffmpeg single-pass mode (no chunk-parallel). " "Slower for large files but produces identical output." ) self._log(" Use the REBUILD FROM GIT button to fix av1an for chunk-parallel mode.") self._log("") return True else: # Cancel self._log("Cancelled by user.") return False else: # No ffmpeg fallback available — offer rebuild or hard cancel btn_rebuild = QPushButton(" Rebuild from Git ") btn_rebuild.setObjectName("btnRebuild") btn_cancel = QPushButton(" Cancel ") btn_cancel.setObjectName("btnStop") dlg = QMessageBox(self) dlg.setWindowTitle("av1an + VapourSynth Version Mismatch") dlg.setText( "av1an cannot initialize VapourSynth — ABI incompatibility.\n\n" f"ffmpeg also lacks '{ffmpeg_enc}' — no fallback possible.\n" "You must rebuild to proceed." ) dlg.setIcon(QMessageBox.Icon.Critical) dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole) dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) dlg.exec() clicked = dlg.clickedButton() if clicked == btn_rebuild: self._log("") self._log("User chose: Rebuild VapourSynth + av1an from git (no fallback available).") self._start_git_rebuild() else: self._log("Cancelled by user.") return False def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True): """Start the SourceBuildWorker thread.""" self._log("Starting source build (VapourSynth + av1an from git)...") self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.") 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.log_msg.connect(self._log) self._build_worker.build_done.connect(self._on_build_done) self._build_worker.start() @Slot(bool, str) def _on_build_done(self, success: bool, message: str): """Called when SourceBuildWorker finishes.""" self._log("") if success: self._log(f"BUILD SUCCESS: {message}") self._log("Re-probing environment to pick up new binaries...") QApplication.processEvents() # Ensure LD_LIBRARY_PATH is set in the main process too # (SourceBuildWorker sets it in its thread, but we need it here) local_lib = str(Path.home() / ".local" / "lib") existing_ld = os.environ.get("LD_LIBRARY_PATH", "") if local_lib not in existing_ld: os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":") # Re-probe environment with fresh data self.env = probe_environment() # Run smoke test again to verify the fix if self.env.av1an_path and self.env.ffmpeg_path: svt_name = self.env.av1an_flags.get("svt_name", "svt_av1") ok, detail = _av1an_vsscript_smoke_test( self.env.av1an_path, self.env.ffmpeg_path, self.env.av1an_flags, svt_name=svt_name, ) if ok: self._log("VERIFIED: av1an + VapourSynth now working correctly!") self._log("Click START PROCESSING to encode.") elif "INVALID_ENCODER" in detail: # Re-probe encoder name with the fresh binary self._log(" Re-detecting encoder name from fresh build...") new_name = _detect_av1an_svt_encoder(self.env.av1an_path) if new_name and new_name != svt_name: self.env.av1an_flags["svt_name"] = new_name self._log(f" Corrected encoder name: '{svt_name}' -> '{new_name}'") ok2, detail2 = _av1an_vsscript_smoke_test( self.env.av1an_path, self.env.ffmpeg_path, self.env.av1an_flags, svt_name=new_name, ) if ok2: self._log("VERIFIED: av1an + VapourSynth now working correctly!") self._log("Click START PROCESSING to encode.") else: self._log(f"WARNING: Smoke test still fails: {detail2}") else: self._log(f"WARNING: Could not auto-fix encoder name. Smoke test: {detail}") else: self._log(f"WARNING: Build completed but smoke test still fails: {detail}") self._log("You may need to log out/in or restart the app for library changes to take effect.") # Update status bar distro = self.env.distro cpu = self.env.cpu vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" fb_encs = [] for vc in VIDEO_CODECS: lib_key = {"libsvtav1": "libsvtav1", "libvpx-vp9": "libvpx", "libx265": "libx265"}.get(vc.ffmpeg_encoder, vc.ffmpeg_encoder) if self.env.ffmpeg_libs.get(lib_key, False): fb_encs.append(vc.ffmpeg_encoder) fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" self.status_label.setText( f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" ) else: self._log(f"BUILD FAILED: {message}") self._log("Try running the build manually in a terminal, or use ffmpeg fallback.") self.status_label.setText("Build failed — check log") self.btn_run.setEnabled(True) self.btn_rebuild.setEnabled(True) @Slot() def _manual_rebuild(self): """Handle the REBUILD FROM GIT button click (manual trigger).""" btn_vs_av1an = QPushButton(" VapourSynth + av1an ") btn_vs_av1an.setObjectName("btnRebuild") btn_vs_only = QPushButton(" VapourSynth only ") btn_vs_only.setObjectName("btnRebuild") btn_av1an_only = QPushButton(" av1an only ") btn_av1an_only.setObjectName("btnRebuild") btn_cancel = QPushButton(" Cancel ") btn_cancel.setObjectName("btnStop") dlg = QMessageBox(self) 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" ) 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_cancel, QMessageBox.ButtonRole.RejectRole) dlg.exec() clicked = dlg.clickedButton() if clicked == btn_vs_av1an: self._start_git_rebuild(build_vs=True, build_av1an=True) elif clicked == btn_vs_only: 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) @Slot(str, int, int) def _on_progress(self, filename: str, current: int, total: int): self.status_label.setText(f"Processing {current}/{total}: {filename}") @Slot(int, int) def _on_finished(self, ok: int, fail: int): self.btn_run.setEnabled(True) self.btn_run.setText("START PROCESSING") self.btn_stop.setEnabled(False) self.status_label.setText(f"Done — {ok} succeeded, {fail} failed") if fail > 0: self._log(f"WARNING: {fail} file(s) failed. Check log above for details.") if ok > 0: self._log(f"All {ok} file(s) archived successfully.") @Slot() def _stop_process(self): if self.worker and self.worker.isRunning(): self._log("STOP: Exiting queue after current file finishes...") self.worker.stop() self.btn_stop.setEnabled(False) # ────────────────────────────────────────────── # ENTRY POINT # ────────────────────────────────────────────── if __name__ == "__main__": app = QApplication(sys.argv) window = OpenCodecMaster() window.show() sys.exit(app.exec())