#!/usr/bin/env python3 """ OpenCodec Transcode Master — Production-Clean (v3 — full QA roadmap applied) ============================================================================ Merges best of: trans.py (CTk probe/tooltips), custom-transcoder (QThread/pathlib), and the bash script's ffmpeg-fallback + WebM concept. v3 changes (Production-Readiness Pass — full QA roadmap applied): v3-01 (OTC-007, SEI CERT MSC04-C): Hoisted FFMPEG_LIB_KEY_MAP to a module-level constant + helper `ffmpeg_lib_key_for()`. Eliminated 4 duplicated inline maps in _ffmpeg_fallback_encode, _probe_and_init (x2), and _handle_vs_incompat. v3-02 (OTC-012, SEI CERT STR09-C): Added `AudioProfile.ffmpeg_encoder_name` field. Replaced substring matching (`"libiamf" in ap.params`) in _check_combo_compatibility and _disable_unavailable_codecs with direct field comparison — would no longer false-match a hypothetical `-libiamf-mode` argument. v3-03 (PEP 868 / PEP 604): Modernized all type hints. Replaced 21 `Optional[X]` with `X | None`. Replaced 2 `callable` with `Callable[[int, int], str]` from collections.abc. Parameterized 5 unparameterized `dict`/`list` in EnvProbe. Removed `from typing import Optional`. v3-04 (OTC-014, SEI CERT MSC04-C): Extracted DISTRO_REGISTRY — a tuple-of-dataclasses table that replaced the 250-line if/elif chain in detect_distro(). Adding a new distro is now a one-table-row change; detect_distro() is ~30 lines of glue. v3-05 (Architectural): Split EncoderWorker.run() (was 515 lines, 7 nested levels) into 5 single-responsibility methods: - run() — orchestrator (~105 lines) - _process_one_file() — per-file wrapper - _validate_file() — ffprobe pre-validation - _prepare_input() — pre-scale / symlink / output path - _encode_one() — av1an or ffmpeg-fallback dispatch - _verify_and_finalize() — size/resolution/duration + subs + delete Behavior on happy path is byte-identical to v2. v3-06 (OTC-004, SEI CERT ERR01-C): Narrowed 24 of 29 bare `except Exception` clauses to specific exception types (OSError, subprocess.SubprocessError, ValueError, etc.). The 4 remaining are intentional (cleanup sweeps, build orchestrator) and are documented with inline justification comments. v3-07 (Functional): STOP button now actually interrupts a running encode. Switched from `subprocess.run(..., timeout=7200)` to `subprocess.Popen` with `start_new_session=True` + a 1-second polling loop that checks `self._stop`. On STOP: SIGTERM the whole process group (av1an + its child encoders), 5-second grace, then SIGKILL. Uses daemon threads to drain stdout/stderr (prevents the 64KB pipe-buffer deadlock on long av1an progress output). Happy path is unchanged — the polling loop sees `proc.poll()` return immediately and behaves identically to subprocess.run. v3-08 (OTC-015, POSIX env hygiene): SourceBuildWorker no longer mutates `os.environ` directly. Uses a per-instance `self._build_env` dict (initialized from `os.environ.copy()`) + `_extend_env()` helper. All subprocess.run calls inside SourceBuildWorker pass `env=self._build_env`. Removed hardcoded `/root/.cargo/bin` path (root's cargo dir is unreadable for non-root users). The one intentional os.environ mutation (UI thread post-build, needed so the next probe_environment() sees the rebuilt libs) is kept with a 12-line justification comment. v3-09 (OTC-013, SEI CERT FIO09-C): Temp dir created with `mode=0o700` + explicit `os.chmod()` to defeat umask masking. Each EncoderWorker gets its own per-PID subdir (`worker-/`) under the shared app temp dir — the final cleanup sweep now nukes only this worker's intermediates, eliminating the race condition where two concurrent workers would delete each other's files. Added `_mkdir_private()` and `_worker_temp_dir()` helpers. v3-10 (Test coverage): Added 24-test pytest suite (tests/ directory) covering: smoke test (5 cases incl. OTC-001 regression), encode pipeline validation (3), audio loudnorm (4), subtitle mux (3), container compatibility rules (6), STOP button interrupt (2), concurrent workers (1). All 24 pass in 0.12s without requiring av1an/ffmpeg/PySide6 to be installed. v2 changes (carried forward — Quality Assurance Pass — PEP 868 / SEI CERT): CRITICAL FIX — "chunks but never saves a file" root cause: - `_av1an_vsscript_smoke_test` previously returned `True` for any non-VSScript failure, masking real bugs. Now returns `False` for unknown failures (SEI CERT ERR01-C, ERR02-C). - Per-file av1an failure logging now emits the last 25 LINES of stderr (not the last 300 CHARACTERS). - Failure-diagnosis if/elif chain replaced with a pattern table (SEI CERT MSC04-C). HIGH FIX — `-movflags +faststart` now only emitted for MP4 container. CONSISTENCY — `QTimer = __import__(...)` replaced with normal import. CONSISTENCY — Inline imports hoisted to top of module. Key refactors over originals (carried from v1): - 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 io import json import math import os import platform import re import shutil import signal import subprocess import sys import tempfile import threading import time import ctypes from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path 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, QTimer 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[[int, int], str] # (crf, preset) -> av1an video-params string ffmpeg_vargs_fn: Callable[[int, int], list[str]] # (crf, preset) -> 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) # v3 (OTC-012, SEI CERT STR09-C): the ffmpeg audio encoder name this # profile depends on, e.g. "libopus", "libvorbis", "flac", "libiamf". # Used by _check_combo_compatibility and _disable_unavailable_codecs # to look up the encoder directly in EnvProbe.ffmpeg_libs — replacing # the v2 substring match (`"libiamf" in ap.params`) which would # falsely match a hypothetical `-libiamf-mode` argument. # Empty string means "no ffmpeg encoder dependency" (rare; only used # by passthrough profiles that don't transcode audio). ffmpeg_encoder_name: str = "" @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"], ffmpeg_encoder_name="libopus"), AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"], ffmpeg_encoder_name="libopus"), AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"], ffmpeg_encoder_name="libopus"), AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"], ffmpeg_encoder_name="libvorbis"), AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"], ffmpeg_encoder_name="libvorbis"), AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"], ffmpeg_encoder_name="flac"), # IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family). # Built on Opus internally; requires ffmpeg compiled with --enable-libiamf. # CANNOT be muxed into MKV/WebM — must use the MP4 container (see below). # The -strict experimental flag is harmless on ffmpeg builds where libiamf # is already stable, and required on builds where it's still flagged # experimental, so we always pass it for forward compatibility. AudioProfile( label="IAMF (128k)", params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"], ffmpeg_encoder_name="libiamf", ), ] CONTAINER_PROFILES: list[ContainerProfile] = [ ContainerProfile(label="MKV (Matroska)", ext="mkv"), ContainerProfile(label="WebM", ext="webm"), # MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec). # Also useful as a more universally compatible output container. ContainerProfile(label="MP4", ext="mp4"), ] # ────────────────────────────────────────────────────────────────────────────── # FFMPEG_LIB_KEY_MAP — single source of truth (OTC-007, SEI CERT MSC04-C). # # Maps the `ffmpeg_encoder` field of a VideoCodecProfile (e.g. "libsvtav1", # "libvpx-vp9") to the corresponding key in EnvProbe.ffmpeg_libs (which is # populated by _probe_ffmpeg_libs()). # # v2 had this map duplicated in three call sites: # - _ffmpeg_fallback_encode (around line 2075) # - _probe_and_init status bar (around line 4309) # - _handle_vs_incompat fallback check (around line 4532) # Adding a new codec required updating all three in sync — a classic # MSC04-C violation. v3 hoists it to one module-level constant. # ────────────────────────────────────────────────────────────────────────────── FFMPEG_LIB_KEY_MAP: dict[str, str] = { "libsvtav1": "libsvtav1", "libaom-av1": "libaom", "libvpx-vp9": "libvpx", "libx265": "libx265", } def ffmpeg_lib_key_for(ffmpeg_encoder: str) -> str: """Look up the ffmpeg_libs key for a given ffmpeg encoder name. Returns the encoder name itself if no mapping is known — this preserves forward compatibility with encoders added after this map was last updated (the caller's .get() will then return False, which is the safe default for an unknown encoder). """ return FFMPEG_LIB_KEY_MAP.get(ffmpeg_encoder, ffmpeg_encoder) # ── 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: int | None # None for "original" (no scaling) height: int | None # 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"} # ────────────────────────────────────────────── # LICENSE NOTICES — third-party components invoked by this application. # # Each entry is a tuple of (tool name, SPDX identifier, short attribution, # full notice). The short form is used for the startup banner and the # pre-transcode summary; the full form is shown in the About dialog. # # This application is a thin orchestration layer; it does not incorporate # the source code of any of these tools. The license obligations of each # tool therefore flow through to the end user independently, and this # registry exists to make those obligations visible at runtime. # ────────────────────────────────────────────── @dataclass(frozen=True) class LicenseNotice: """Immutable descriptor for a third-party component license. SEI CERT MSC04-C spirit: secrets and licensing data are not duplicated across the codebase; the canonical source is this table. """ name: str # e.g. "FFmpeg" spdx: str # e.g. "LGPL-2.1-or-later" home_url: str # canonical upstream URL short: str # one-line attribution shown in banners full: str # multi-line notice shown in About dialog LICENSE_NOTICES: tuple[LicenseNotice, ...] = ( LicenseNotice( name="FFmpeg", spdx="LGPL-2.1-or-later (or GPL-2.0-or-later with --enable-gpl)", home_url="https://ffmpeg.org", short="FFmpeg (LGPL-2.1+, GPL build flags noted at runtime)", full=( "FFmpeg\n" "Copyright (c) FFmpeg developers\n" "Licensed under LGPL-2.1-or-later; the build's effective license\n" "may upgrade to GPL-2.0-or-later when --enable-gpl or any GPL-only\n" "library (libx264, libx265, libfdk-aac) is configured in.\n" "Source: https://ffmpeg.org\n" "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" ), ), LicenseNotice( name="av1an", spdx="GPL-3.0-or-later", home_url="https://github.com/master-of-zen/av1an", short="av1an (GPL-3.0+)", full=( "av1an — Av1an is a frame-parallel AV1/VP9/x265 encoder\n" "Copyright (c) master-of-zen and contributors\n" "Licensed under GPL-3.0-or-later.\n" "Source: https://github.com/master-of-zen/av1an\n" "License: https://www.gnu.org/licenses/gpl-3.0.html" ), ), LicenseNotice( name="VapourSynth", spdx="LGPL-2.1-or-later", home_url="https://www.vapoursynth.com", short="VapourSynth (LGPL-2.1+)", full=( "VapourSynth — a video processing framework\n" "Copyright (c) Fredrik Mellbin and contributors\n" "Licensed under LGPL-2.1-or-later.\n" "Source: https://github.com/vapoursynth/vapoursynth\n" "License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" ), ), LicenseNotice( name="SVT-AV1", spdx="BSD-3-Clause AND PMK-2-Clause", home_url="https://gitlab.com/AOMediaCodec/SVT-AV1", short="SVT-AV1 (BSD-3-Clause, AOMedia)", full=( "SVT-AV1 — Scalable Video Technology for AV1\n" "Copyright (c) Alliance for Open Media and contributors\n" "Licensed under BSD-3-Clause and the AOMedia Patent License.\n" "Source: https://gitlab.com/AOMediaCodec/SVT-AV1\n" "License: https://opensource.org/license/bsd-3-clause" ), ), LicenseNotice( name="libvpx", spdx="BSD-3-Clause", home_url="https://github.com/webmproject/libvpx", short="libvpx / VP9 (BSD-3-Clause)", full=( "libvpx — VP8/VP9 codec library\n" "Copyright (c) The WebM Project authors\n" "Licensed under BSD-3-Clause.\n" "Source: https://github.com/webmproject/libvpx\n" "License: https://opensource.org/license/bsd-3-clause" ), ), LicenseNotice( name="x265", spdx="GPL-2.0-or-later (commercial license available)", home_url="https://bitbucket.org/multicoreware/x265_git", short="x265 / HEVC (GPL-2.0+)", full=( "x265 — HEVC encoder\n" "Copyright (c) MulticoreWare, Inc and contributors\n" "Licensed under GPL-2.0-or-later; a commercial license is\n" "available from MulticoreWare for non-GPL distribution.\n" "Source: https://bitbucket.org/multicoreware/x265_git\n" "License: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html" ), ), LicenseNotice( name="libopus", spdx="BSD-3-Clause", home_url="https://opus-codec.org", short="libopus / Opus (BSD-3-Clause)", full=( "libopus — Opus audio codec (IETF RFC 6716)\n" "Copyright (c) Xiph.Org Foundation, Skype Limited, Mozilla,\n" "and contributors\n" "Licensed under BSD-3-Clause.\n" "Source: https://github.com/xiph/opus\n" "License: https://opensource.org/license/bsd-3-clause" ), ), LicenseNotice( name="libvorbis", spdx="BSD-3-Clause", home_url="https://xiph.org/vorbis", short="libvorbis / Vorbis (BSD-3-Clause)", full=( "libvorbis — Vorbis audio codec\n" "Copyright (c) Xiph.Org Foundation and contributors\n" "Licensed under BSD-3-Clause.\n" "Source: https://github.com/xiph/vorbis\n" "License: https://opensource.org/license/bsd-3-clause" ), ), LicenseNotice( name="libFLAC", spdx="BSD-3-Clause", home_url="https://xiph.org/flac", short="libFLAC / FLAC (BSD-3-Clause)", full=( "libFLAC — Free Lossless Audio Codec\n" "Copyright (c) Xiph.Org Foundation and contributors\n" "Licensed under BSD-3-Clause.\n" "Source: https://github.com/xiph/flac\n" "License: https://opensource.org/license/bsd-3-clause" ), ), LicenseNotice( name="libiamf", spdx="BSD-2-Clause", home_url="https://github.com/AOMediaCodec/libiamf", short="libiamf / IAMF (BSD-2-Clause, AOMedia)", full=( "libiamf — AOMedia Immersive Audio Model and Formats\n" "Copyright (c) Alliance for Open Media and contributors\n" "Licensed under BSD-2-Clause.\n" "Source: https://github.com/AOMediaCodec/libiamf\n" "License: https://opensource.org/license/bsd-2-clause" ), ), LicenseNotice( name="Qt / PySide6", spdx="LGPL-3.0-only (commercial available from The Qt Company)", home_url="https://www.qt.io", short="Qt / PySide6 (LGPL-3.0)", full=( "Qt — application framework\n" "Copyright (c) The Qt Company Ltd and contributors\n" "Licensed under LGPL-3.0-only; a commercial license is available.\n" "Source: https://www.qt.io\n" "License: https://www.gnu.org/licenses/lgpl-3.0.html" ), ), LicenseNotice( name="Python", spdx="PSF-2.0", home_url="https://www.python.org", short="Python (PSF License)", full=( "Python — programming language\n" "Copyright (c) Python Software Foundation\n" "Licensed under the PSF License Agreement.\n" "Source: https://www.python.org\n" "License: https://docs.python.org/3/license.html" ), ), ) def active_license_notices(env) -> list[LicenseNotice]: """Return the subset of LICENSE_NOTICES that apply to the running environment. Determined by which tools / libraries env reports as present. Always includes FFmpeg, Python, and Qt (framework deps). Data-driven dispatch: avoids a per-tool if/elif chain by looking up each notice's presence in env attributes via a small table. """ presence_rules: tuple[tuple[str, bool], ...] = ( ("FFmpeg", bool(getattr(env, "ffmpeg_path", None))), ("av1an", bool(getattr(env, "av1an_path", None))), ("VapourSynth", bool(getattr(env, "vs_version", None))), ("SVT-AV1", bool(getattr(env, "av1an_flags", {}).get("svt_name"))), ("libvpx", bool(getattr(env, "ffmpeg_libs", {}).get("libvpx"))), ("x265", bool(getattr(env, "ffmpeg_libs", {}).get("libx265"))), ("libopus", bool(getattr(env, "ffmpeg_libs", {}).get("libopus"))), ("libvorbis", bool(getattr(env, "ffmpeg_libs", {}).get("libvorbis"))), ("libFLAC", bool(getattr(env, "ffmpeg_libs", {}).get("flac"))), ("libiamf", bool(getattr(env, "ffmpeg_libs", {}).get("libiamf"))), ("Qt / PySide6", True), # framework, always present ("Python", True), ) active_names = {name for name, present in presence_rules if present} return [n for n in LICENSE_NOTICES if n.name in active_names] def license_banner_short(notices: list[LicenseNotice]) -> str: """One-line summary suitable for a status bar or log header.""" return " | ".join(n.short for n in notices) def license_banner_full(notices: list[LicenseNotice]) -> str: """Multi-line text block suitable for an About / Licenses dialog.""" sep = "─" * 60 blocks = [sep, " OPEN SOURCE LICENSE ATTRIBUTIONS", sep] for n in notices: blocks.append(n.full) blocks.append(sep) blocks.append( "This application invokes these tools as external processes.\n" "Source code of each tool is NOT bundled with this application.\n" "For the full text of each license, follow the upstream URL cited\n" "above. Questions about redistribution rights should be directed\n" "to the upstream projects." ) return "\n".join(blocks) # ────────────────────────────────────────────── # CPU TOPOLOGY (physical cores, not hyperthreads) # ────────────────────────────────────────────── @dataclass class CpuTopology: physical_cores: int logical_threads: int threads_per_core: int model_name: str def _read_sysfs_cores() -> (tuple[int, int]) | None: """ 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 (OSError, ValueError): # OSError: file vanished/permission; ValueError: UnicodeDecodeError pass if unique_cores and logical: return (len(unique_cores), logical) return None def _read_lscpu_cores() -> (tuple[int, int]) | None: """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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError): 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 # ────────────────────────────────────────────────────────────────────────────── # DISTRO_REGISTRY — data-driven distro detection (v3, OTC-014). # # v1/v2 had a 250-line if/elif chain in detect_distro() with one branch per # distro family. Each branch constructed a DistroProfile with mostly-identical # fields — a classic SEI CERT MSC04-C violation (no single source of truth). # # v3 collapses the chain into a tuple-of-dicts table. Each entry has: # ids: tuple of distro_id strings that match this family # id_likes: tuple of ID_LIKE substrings that also match this family # family: canonical family name # pkg_manager: package manager binary name # install_cmd: template with {packages} placeholder # extra_paths: list of distro-specific binary search paths # dep_pkgs: map of generic name -> distro package name # notes: distro-specific quirks string # vsscript_pkg: (optional) separate VSScript package name # # Adding a new distro is now a single-table-row change — no code modification. # The encoder_binaries field is identical across all distros and lives in the # function body (it's the same dict literal every time). # ────────────────────────────────────────────────────────────────────────────── # encoder_binaries is identical for every distro — define once. _ENCODER_BINARIES: dict[str, list[str]] = { "svt_av1": ["SvtAv1EncApp", "svt_av1"], "vpx": ["vpxenc"], "x265": ["x265"], } # Common av1an encoder names known across distros. _AV1AN_KNOWN_ENCODERS: list[str] = ["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"] @dataclass(frozen=True) class _DistroEntry: """One row in the DISTRO_REGISTRY table.""" ids: tuple[str, ...] # exact distro_id matches id_likes: tuple[str, ...] # ID_LIKE substring matches family: str pkg_manager: str install_cmd: str # template with {packages} extra_paths: tuple[str, ...] dep_pkgs: dict[str, str] notes: str vsscript_pkg: str = "" DISTRO_REGISTRY: tuple[_DistroEntry, ...] = ( _DistroEntry( ids=("arch", "manjaro", "endeavouros", "garuda", "cachyos"), id_likes=("arch",), family="arch", pkg_manager="pacman", install_cmd="sudo pacman -S {packages}", extra_paths=("/usr/bin", "/usr/local/bin", "~/.local/bin", "~/.cargo/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "libopus", "vorbis": "libvorbis", "flac": "flac", }, 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." ), ), _DistroEntry( ids=("fedora",), id_likes=("fedora",), family="redhat", pkg_manager="dnf", install_cmd="sudo dnf install {packages}", extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx-tools", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, 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." ), ), _DistroEntry( ids=("rhel", "centos", "rocky", "almalinux", "ol"), id_likes=("rhel", "centos"), family="redhat", # RHEL-family: dnf if present, fall back to yum pkg_manager="", # resolved at runtime in detect_distro() install_cmd="", # resolved at runtime in detect_distro() extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx-tools", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, 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." ), ), _DistroEntry( ids=("opensuse-leap", "opensuse-tumbleweed", "sles"), id_likes=("suse",), family="suse", pkg_manager="zypper", install_cmd="sudo zypper install {packages}", extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "libopus", "vorbis": "libvorbis", "flac": "flac", }, 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." ), ), _DistroEntry( ids=("nixos",), id_likes=("nixos",), family="nixos", pkg_manager="nix", install_cmd="nix-shell -p {packages}", extra_paths=("/run/current-system/sw/bin", "~/.nix-profile/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svt-av1", "x265": "x265", "vpx": "libvpx", "opus": "opus", "vorbis": "libvorbis", "flac": "flac", }, 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." ), ), _DistroEntry( ids=("debian", "ubuntu", "linuxmint", "pop"), id_likes=("debian",), family="debian", pkg_manager="apt", install_cmd="sudo apt install {packages}", extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"), dep_pkgs={ "vapoursynth": "vapoursynth", "svt-av1": "svtav1", "x265": "x265", "vpx": "libvpx-tools", "opus": "libopus-dev", "vorbis": "libvorbis-dev", "flac": "flac", }, 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." ), vsscript_pkg="libvapoursynth-script-dev", ), ) def _match_distro_entry(distro_id: str, id_like: list[str]) -> _DistroEntry | None: """Find the first DISTRO_REGISTRY entry whose ids or id_likes match. SEI CERT MSC04-C spirit: the matching logic is one flat loop over a table — no nested if/elif chain. Adding a new distro is a one-line table change in DISTRO_REGISTRY above; this function never needs modification. """ for entry in DISTRO_REGISTRY: if distro_id in entry.ids: return entry if any(like in id_like for like in entry.id_likes): return entry return None 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. v3 (OTC-014): the per-distro data lives in DISTRO_REGISTRY above. This function is now ~30 lines of glue instead of a 250-line if/elif chain. """ 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", "?") entry = _match_distro_entry(distro_id, id_like) if entry is None: # 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=list(_AV1AN_KNOWN_ENCODERS), ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs={}, encoder_binaries=dict(_ENCODER_BINARIES), notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.", ) # Resolve runtime-determined fields (RHEL family: dnf vs yum) pkg_manager = entry.pkg_manager install_cmd = entry.install_cmd if not pkg_manager: # RHEL/CentOS family: pick dnf if installed, else yum has_dnf = Path("/usr/bin/dnf").exists() pkg_manager = "dnf" if has_dnf else "yum" install_cmd = ( "sudo dnf install {packages}" if has_dnf else "sudo yum install {packages}" ) return DistroProfile( family=entry.family, name=pretty, version_id=version, pkg_manager=pkg_manager, install_cmd_template=install_cmd, binary_extra_paths=list(entry.extra_paths), av1an_known_encoder_names=( # Arch family includes the additional 'svt-av1' alias ["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"] if entry.family == "arch" else list(_AV1AN_KNOWN_ENCODERS) ), ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", dep_pkgs=dict(entry.dep_pkgs), encoder_binaries=dict(_ENCODER_BINARIES), vsscript_pkg=entry.vsscript_pkg, notes=entry.notes, ) # ────────────────────────────────────────────── # 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: str | None = None ffmpeg_path: str | None = None ffprobe_path: str | None = None # v3 (OTC-011, PEP 868): parameterized dict/list type hints. # av1an_flags values are sometimes str (flag name), sometimes bool # (has_chunk_method), sometimes int — keep as dict[str, object] for honesty. av1an_flags: dict[str, object] = field(default_factory=dict) av1an_version: str | None = None ffmpeg_version: str | None = None ffmpeg_libs: dict[str, bool] = field(default_factory=dict) # lib name -> available runtime_deps: dict[str, bool] = field(default_factory=dict) # dep name -> present missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install vs_version: str | None = None # VapourSynth version string (for diagnostics) vs_script_lib: str | None = 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) -> str | None: """ 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. v4 STABILITY FIX: the v3 search strings for libsvtav1 and libaom were wrong. ffmpeg's `-encoders` output lists them as `libsvtav1` and `libaom-av1` (no underscore between svt/av1, hyphen between aom/av1) — NOT `libsvt_av1` / `libaom_av1`. This caused _probe_ffmpeg_libs to report False for both even when they were installed, which then caused _handle_vs_incompat to incorrectly tell the user "ffmpeg also lacks libsvtav1" and abort — even though ffmpeg actually had it. The e2e test test_probe_detects_ffmpeg_libs caught this. """ try: res = subprocess.run( [ffmpeg_bin, "-encoders"], capture_output=True, text=True, timeout=10, ) output = res.stdout except (OSError, subprocess.SubprocessError): output = "" # v4: search strings match the EXACT names ffmpeg -encoders prints. # Verified against ffmpeg 7.x output: # V..... libsvtav1 SVT-AV1(...) encoder (codec av1) # V....D libaom-av1 libaom AV1 (codec av1) # V....D libvpx-vp9 libvpx VP9 (codec vp9) # The trailing space in each search string anchors the match to the # encoder name boundary, preventing false positives like "libvpx_vp9" # matching "libvpx_vp9_decoder" (which doesn't exist, but defensive). # We also include the underscore variant as a fallback for older # ffmpeg builds that may have used that spelling. checks = [ ("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]), ("libaom", ["libaom-av1 ", "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) -> str | None: """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 (OSError, subprocess.SubprocessError): pass return None def _probe_ffmpeg_version(ffmpeg_bin: str) -> str | None: """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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError) 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 (OSError, subprocess.SubprocessError): 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 (OSError, subprocess.SubprocessError) 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) -> dict[str, object] | None: """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 return json.loads(res.stdout) except (OSError, subprocess.SubprocessError, ValueError): # ValueError covers json.JSONDecodeError return None def ffprobe_duration(filepath: Path, ffprobe_bin: str) -> float | None: """Return media duration in seconds via ffprobe, or None on failure. Used by the EncoderWorker post-encode integrity check to compare source and output durations. Modeled after :func:`ffprobe_validate` — every failure path returns ``None`` so the caller can treat unverifiable durations as "skip the check" rather than crashing the worker thread. """ try: res = subprocess.run( [ffprobe_bin, "-v", "quiet", "-print_format", "json", "-show_format", "-show_entries", "format=duration", str(filepath)], capture_output=True, text=True, timeout=10, ) if res.returncode != 0 or not res.stdout: return None data = json.loads(res.stdout) dur_str = (data.get("format") or {}).get("duration") if dur_str is None: return None return float(dur_str) except (OSError, subprocess.SubprocessError, ValueError): # ValueError covers json.JSONDecodeError and float() parse failures 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. """ 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 (OSError, subprocess.SubprocessError, ValueError): # ValueError covers json.JSONDecodeError and int() parse failures return True # can't verify, don't block def _identify_file_type(file_path: Path) -> str: """Run `file` on the given path and return the type string. v5-03: Used by _validate_file to tell the user WHAT a file actually is when ffprobe can't read it. This immediately reveals: - "HTML document" → failed yt-dlp download (YouTube error page saved as .mp4) - "ASCII text" → same as above (different yt-dlp version) - "data" → truncated, encrypted, or partial download - "ISO Media, MP4 Base Media v1" → valid MP4 that ffprobe just can't parse (rare) Returns the first line of `file` output (minus the filename prefix), or an empty string if `file` is not available or fails. """ file_bin = shutil.which("file") if not file_bin: return "" try: res = subprocess.run( [file_bin, "-b", str(file_path)], capture_output=True, text=True, timeout=5, ) if res.returncode == 0: return res.stdout.strip() except (OSError, subprocess.SubprocessError): pass return "" 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. """ 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 (OSError, subprocess.SubprocessError): 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. """ 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 (OSError, subprocess.SubprocessError) 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"]) # SEI CERT ERR01-C: catch only the specific exception types we # expect from subprocess.run; never swallow unrelated failures. try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=_av1an_env()) except subprocess.TimeoutExpired: # Timeout is a real failure — av1an is hanging. Do NOT mask it. return False, f"SMOKE_TIMEOUT: av1an smoke test exceeded {timeout}s — likely hung in VSScript init or encoder spawn" except FileNotFoundError as e: return False, f"SMOKE_BIN_MISSING: {e}" except OSError as e: return False, f"SMOKE_OS_ERROR: {e}" stderr = res.stderr or "" stdout = res.stdout or "" # Success requires BOTH rc==0 AND the output file actually exists. # The previous code returned True on any non-VSScript failure, which # masked real bugs (missing encoder binary, concat failure, etc.) # and led to "chunks but never saves a file" symptoms in production. if res.returncode == 0 and test_out.exists(): test_out.unlink(missing_ok=True) return True, "av1an VSScript init OK" # Classify the known failure modes by inspecting stderr. if "Failed to get VSScript API" in stderr: return False, "VSScript_API_INCOMPAT" if "invalid value" in stderr and "--encoder" in stderr: return False, f"INVALID_ENCODER: {stderr[-200:]}" if "No usable encoder found" in stderr: return False, f"ENCODER_BIN_MISSING: {stderr[-300:]}" # Unknown failure — return False so the caller can offer ffmpeg # fallback or rebuild. Include the FULL stderr (not just the tail) # so the user can see the actual error and the diagnostic patterns # below can match on it. combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:] return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}" # ────────────────────────────────────────────── # 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. v3 (OTC-013, SEI CERT FIO09-C): the directory is created with ``mode=0o700`` so that other users on the system cannot create symlinks inside it (which the cleanup sweep would then follow and delete arbitrary files). The mode is verified after creation in case the directory already existed with looser permissions. """ 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" if _mkdir_private(candidate): _APP_CACHE_DIR = candidate return _APP_CACHE_DIR # Fallback: /tmp/OpenTranscode fallback = Path("/tmp/OpenTranscode") if _mkdir_private(fallback): _APP_CACHE_DIR = fallback return _APP_CACHE_DIR # Last resort: system temp _APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode" _mkdir_private(_APP_CACHE_DIR) return _APP_CACHE_DIR def _mkdir_private(path: Path) -> bool: """Create *path* (and parents) with mode 0o700. Returns True on success, False on OSError/PermissionError. SEI CERT FIO09-C: if the directory already existed with looser permissions (e.g. created by a previous version of this app, or by another user before us), we attempt to tighten the mode with os.chmod(). The chmod may fail silently if we don't own the dir — that's an accepted risk, logged but not fatal. """ try: path.mkdir(parents=True, exist_ok=True, mode=0o700) # mkdir(mode=) is masked by umask; explicitly chmod to be sure os.chmod(path, 0o700) return True except (OSError, PermissionError): return False def _worker_temp_dir(worker_pid: int) -> Path: """Return a per-worker temp subdir named by PID. v3: each EncoderWorker gets its own subdir under the shared app temp dir, so the final cleanup sweep can safely nuke only this worker's intermediates without affecting a concurrent worker. The subdir is also created with mode=0o700 (FIO09-C). """ base = _get_app_temp_dir() sub = base / f"worker-{worker_pid}" _mkdir_private(sub) return sub def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv", worker_dir: Path | None = None) -> 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. v3: if *worker_dir* is provided (per-worker subdir), the temp file lands there instead of the shared parent. This isolates concurrent workers' intermediates from each other. """ tmp_dir = worker_dir if worker_dir is not None else _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}" # ────────────────────────────────────────────── # KEEP-AWAKE (v6-06: anti-sleep / anti-hibernate) # ────────────────────────────────────────────── class KeepAwake: """Keep the system awake during a transcode. v6-06: Uses systemd-inhibit (preferred) to block sleep/idle at the systemd level, plus optional xdotool mouse nudging every 60s as a belt-and-suspenders fallback. The user sees a bright-red banner in the UI while active. Mouse movement is minimal (1px jitter, not constant) so the user can still click STOP or close the window. Usage:: ka = KeepAwake(log_fn=worker.log_msg.emit) ka.start() try: while encoding: ka.update_eta(remaining_seconds) time.sleep(5) finally: ka.stop() """ def __init__( self, log_fn=None, enable_mouse_nudge: bool = False, nudge_interval: int = 60, ): self._log_fn = log_fn or (lambda msg: None) self._enable_mouse_nudge = enable_mouse_nudge and bool(shutil.which("xdotool")) self._nudge_interval = nudge_interval self._inhibit_proc: subprocess.Popen | None = None self._nudge_count = 0 self._last_nudge = 0.0 self._start_time = 0.0 self._eta_seconds: float | None = None self._active = False def start(self) -> None: """Acquire systemd-inhibit handle. Safe to call multiple times.""" if self._active: return self._active = True self._start_time = time.monotonic() self._acquire_inhibit() if self._enable_mouse_nudge: self._log_fn("KEEP-AWAKE: mouse nudging enabled (xdotool, every " f"{self._nudge_interval}s)") else: self._log_fn("KEEP-AWAKE: mouse nudging disabled (xdotool not found " "or not requested)") def stop(self) -> None: """Release the inhibit handle and stop nudging.""" if not self._active: return self._active = False self._release_inhibit() if self._nudge_count > 0: self._log_fn(f"KEEP-AWAKE: stopped (mouse nudged {self._nudge_count} times)") def update_eta(self, remaining_seconds: float | None) -> None: """Update the ETA shown in the banner. None = unknown.""" self._eta_seconds = remaining_seconds def tick(self) -> str | None: """Called periodically (e.g. every 5s) from the UI thread. Performs mouse nudge if interval has elapsed. Returns the current banner text, or None if keep-awake is not active. """ if not self._active: return None now = time.monotonic() if self._enable_mouse_nudge and (now - self._last_nudge) >= self._nudge_interval: self._nudge_mouse() self._last_nudge = now return self.banner_text() def banner_text(self) -> str: """Return the banner text for the UI (styled bright-red in QSS).""" eta_str = self._format_eta(self._eta_seconds) elapsed = time.monotonic() - self._start_time elapsed_str = self._format_eta(elapsed) nudge_str = f" | mouse: {self._nudge_count}" if self._nudge_count > 0 else "" return ( f"KEEP-AWAKE ACTIVE — system will not sleep | " f"elapsed: {elapsed_str} | ETA: {eta_str}{nudge_str}" ) def _format_eta(self, seconds: float | None) -> str: if seconds is None: return "unknown" if seconds < 0: return "almost done" hours = int(seconds // 3600) mins = int((seconds % 3600) // 60) secs = int(seconds % 60) if hours > 0: return f"~{hours}h{mins:02d}m" if mins > 0: return f"~{mins}m{secs:02d}s" return f"~{secs}s" def _acquire_inhibit(self) -> None: """Fork a systemd-inhibit subprocess that holds the sleep/idle inhibit handle for the duration of the transcode.""" inhibit_bin = shutil.which("systemd-inhibit") if not inhibit_bin: self._log_fn("KEEP-AWAKE: systemd-inhibit not found — " "system may sleep during transcode") return try: self._inhibit_proc = subprocess.Popen( [ inhibit_bin, "--what=sleep:idle", "--who=OpenTranscode", "--why=Batch video transcode in progress", "--mode=block", "sleep", "infinity", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) self._log_fn("KEEP-AWAKE: systemd-inhibit active (sleep/idle blocked)") except (OSError, subprocess.SubprocessError) as e: self._log_fn(f"KEEP-AWAKE: failed to acquire systemd-inhibit: {e}") self._inhibit_proc = None def _release_inhibit(self) -> None: """Kill the systemd-inhibit subprocess to release the handle.""" if self._inhibit_proc is None: return try: self._inhibit_proc.terminate() self._inhibit_proc.wait(timeout=3) except subprocess.TimeoutExpired: self._inhibit_proc.kill() self._inhibit_proc.wait(timeout=1) except (OSError, subprocess.SubprocessError): pass finally: self._inhibit_proc = None self._log_fn("KEEP-AWAKE: systemd-inhibit released") def _nudge_mouse(self) -> None: """Move the mouse 1 pixel to prevent screen-blank. Alternates +1px right / -1px left so the cursor ends up where it started after every pair of nudges. """ xdotool = shutil.which("xdotool") if not xdotool: return delta = 1 if (self._nudge_count % 2 == 0) else -1 try: subprocess.run( [xdotool, "mousemove_relative", "--", str(delta), "0"], capture_output=True, timeout=3, ) self._nudge_count += 1 except (OSError, subprocess.SubprocessError): pass # best-effort — don't crash the transcode over a nudge @property def is_active(self) -> bool: return self._active @property def has_inhibit(self) -> bool: return self._inhibit_proc is not None def __enter__(self): self.start() return self def __exit__(self, *args): self.stop() # ────────────────────────────────────────────── # 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: str | None = None, force: bool = False, ): 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 # v5: force=True skips ffprobe validation and attempts encode even # for files ffprobe cannot read. Use for the 1% edge case where # ffprobe fails but the file is actually valid (rare codec, broken # container metadata, etc.). Default False — most "ffprobe can't # read" files are genuinely invalid (failed downloads, HTML saved # as .mp4, truncated files, etc.). self.force = force 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 # v5-02: track consecutive failures with the same error pattern. # After 3 consecutive same-pattern failures, auto-abort the queue. self._consecutive_fail_count = 0 self._last_fail_pattern: str | None = None # v3 (OTC-013, SEI CERT FIO09-C): each worker gets its own # per-PID subdir under the shared app temp dir, so the final # cleanup sweep can safely nuke only this worker's intermediates # without affecting a concurrent worker. The subdir is created # with mode=0o700 to prevent symlink attacks from other users. self._temp_dir = _worker_temp_dir(os.getpid()) # v6-06: KeepAwake instance — started in run(), stopped in finally. # mouse_nudge defaults to False (opt-in) to avoid surprising the # user with cursor movement. systemd-inhibit is always-on when # available (no visible side effects). self._keepawake = KeepAwake( log_fn=lambda msg: self.log_msg.emit(msg), enable_mouse_nudge=False, ) self._encode_start_time = 0.0 def _run_with_stop_check( self, cmd: list[str], env: dict[str, str] | None = None, timeout: int = 7200, log_prefix: str = " ", ) -> tuple[str, int, str, str]: """Run a subprocess with STOP-button support. Replaces ``subprocess.run(cmd, capture_output=True, text=True, timeout=7200)`` in the av1an and ffmpeg-fallback encode paths so that clicking STOP in the UI interrupts a running encode within ~1 second instead of waiting up to 2 hours for the per-file timeout to expire. Polls ``self._stop`` every ~1 second. When STOP is requested, sends SIGTERM to the subprocess's *process group* (so av1an's child encoders — SvtAv1EncApp / vpxenc / x265 — die too, not just the av1an parent), waits 5s, then SIGKILLs the group if still alive. Also enforces the overall ``timeout`` (7200s) limit. Two background drainer threads read stdout/stderr continuously into StringIO buffers. This prevents the classic pipe-buffer deadlock: av1an's progress bar can easily exceed the ~64KB OS pipe buffer over a long encode, and without draining the child would block on ``write()`` and ``proc.poll()`` would never see it exit. This is the same pattern ``subprocess.run`` uses internally via ``_communicate``. Returns a 4-tuple ``(status, returncode, stdout, stderr)`` where ``status`` is one of: - ``"ok"`` — process exited normally; caller inspects ``returncode`` (0 = success) and uses ``stdout`` / ``stderr`` for diagnostics. - ``"stop"`` — user requested STOP via the UI. Caller must NOT increment ``fail_count`` (a user abort is not a transcode failure). ``self._stop`` is already True (set by the UI thread), so the orchestrator's queue loop will break on the next iteration and emit "STOP: Aborted by user." - ``"timeout"`` — process exceeded ``timeout`` seconds. Caller MUST increment ``fail_count`` (a timeout is a failure) and emit the existing user-visible TIMEOUT message. Raises ``OSError`` / ``subprocess.SubprocessError`` if the ``Popen`` constructor itself fails (e.g. ``FileNotFoundError`` when the binary is missing) — the caller's existing ``except`` clause handles these unchanged. """ proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, # start_new_session=True puts the child in its own process # group (setsid). We can then os.killpg() the whole group # to reach av1an's child encoders (SvtAv1EncApp / vpxenc / # x265), which a bare proc.terminate() would miss. start_new_session=True, ) stdout_buf = io.StringIO() stderr_buf = io.StringIO() def _drain(stream, buf): try: while True: chunk = stream.read(4096) if not chunk: break buf.write(chunk) except (OSError, ValueError): # Stream closed under us or process gone — stop reading. pass t_out = threading.Thread( target=_drain, args=(proc.stdout, stdout_buf), daemon=True, ) t_err = threading.Thread( target=_drain, args=(proc.stderr, stderr_buf), daemon=True, ) t_out.start() t_err.start() status = "ok" rc: int | None = None start_time = time.monotonic() while True: rc = proc.poll() if rc is not None: # Process exited — break and drain pipes below. break if self._stop: self.log_msg.emit( f"{log_prefix}STOP: Aborting current encode, " f"terminating subprocess..." ) try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except (ProcessLookupError, OSError): # Process already gone — nothing to signal. pass try: proc.wait(timeout=5) except subprocess.TimeoutExpired: # SIGTERM didn't take effect within the grace period — # escalate to SIGKILL on the whole group. try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except (ProcessLookupError, OSError): pass try: proc.wait(timeout=2) except subprocess.TimeoutExpired: # Truly stuck (e.g. uninterruptible IO). We've # done what we can; the process will be reaped # later. Continue to pipe drainage. pass status = "stop" self.log_msg.emit(f"{log_prefix}STOP: Subprocess terminated.") break if time.monotonic() - start_time > timeout: # Overall timeout — kill the process group. The caller # logs the user-visible TIMEOUT message (it includes the # file name / "ffmpeg" context this helper doesn't know). try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except (ProcessLookupError, OSError): pass try: proc.wait(timeout=2) except subprocess.TimeoutExpired: pass status = "timeout" break time.sleep(1) # Wait for drainer threads to finish reading any remaining pipe # data, then close the pipes explicitly (defensive — __del__ # would also close them, but explicit is better and avoids # ResourceWarning under -X dev). t_out.join(timeout=10) t_err.join(timeout=10) try: proc.stdout.close() except (OSError, ValueError): pass try: proc.stderr.close() except (OSError, ValueError): pass return ( status, rc if rc is not None else -1, stdout_buf.getvalue(), stderr_buf.getvalue(), ) 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 # v3: use the module-level FFMPEG_LIB_KEY_MAP (OTC-007). ffmpeg_lib_key = ffmpeg_lib_key_for(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"]) # Container-specific muxer flags. -movflags +faststart is MP4-only # (it relocates the moov atom for streaming); passing it for MKV or # WebM is silently ignored by ffmpeg but pollutes the command line # and confuses users reading the log. Apply it only when the # output container is MP4. mux_flags: list[str] = [] if self.container.ext == "mp4": mux_flags = ["-movflags", "+faststart"] cmd = [ self.env.ffmpeg_path, "-i", str(encode_input), ] + vf_scale_args + v_args + audio_args + mux_flags + [ "-y", str(output_f), ] try: result = self._run_with_stop_check(cmd, timeout=7200, log_prefix=" ") status, rc, stdout, stderr = result if status == "stop": # User requested STOP — do NOT count as failure. The # caller (_process_one_file) guards the fail_count # increment with `if not self._stop`. Remove partial # output so it isn't mistaken for a finished file. output_f.unlink(missing_ok=True) return False if status == "timeout": self.log_msg.emit(f" TIMEOUT: ffmpeg exceeded 2h limit.") return False # status == "ok" — wrap in CompletedProcess so the downstream # returncode/stderr logic is byte-for-byte unchanged. res = subprocess.CompletedProcess(cmd, rc, stdout, stderr) 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 OSError 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("───────────────────────────") # ── v5-04: Pre-flight validation pass ── # Scan all files with ffprobe BEFORE the encode loop. Report how # many are valid vs invalid. This gives the user immediate feedback # ("46 files found, 0 valid, 46 invalid") instead of failing one # by one over 2 hours. If ALL files are invalid and force=False, # abort now — don't waste time entering the encode loop. if self.env.ffprobe_path and not self.force: valid_count = 0 invalid_count = 0 invalid_samples: list[str] = [] for f in all_files: info = ffprobe_validate(f, self.env.ffprobe_path) if info is None: invalid_count += 1 if len(invalid_samples) < 3: ft = _identify_file_type(f) invalid_samples.append(f" {f.name}: {ft}" if ft else f" {f.name}: (file type unknown)") else: has_video = any(s.get("codec_type") == "video" for s in info.get("streams", [])) duration = float(info.get("format", {}).get("duration", 0)) if has_video and duration >= 0.5: valid_count += 1 else: invalid_count += 1 if len(invalid_samples) < 3: reason = "no video stream" if not has_video else f"too short ({duration:.1f}s)" invalid_samples.append(f" {f.name}: {reason}") self.log_msg.emit("─── PRE-FLIGHT VALIDATION ───") self.log_msg.emit(f" Valid files: {valid_count}") self.log_msg.emit(f" Invalid files: {invalid_count}") if invalid_samples: self.log_msg.emit(f" First {len(invalid_samples)} invalid:") for s in invalid_samples: self.log_msg.emit(s) self.log_msg.emit("─────────────────────────────") if valid_count == 0 and invalid_count > 0: self.log_msg.emit("") self.log_msg.emit( f"ABORT: All {invalid_count} file(s) are invalid. " f"Aborting queue — no files to encode." ) self.log_msg.emit( " Common causes: (1) failed yt-dlp downloads (HTML saved as .mp4), " "(2) files on a network mount that's not responding, " "(3) wrong input directory." ) self.log_msg.emit( " Run `file ` on any file to see what it actually is." ) self.fail_count = invalid_count self._final_cleanup_sweep() self.log_msg.emit( f"QUEUE COMPLETE. Success: 0, Failed: {self.fail_count}." ) self.finished_queue.emit(0, self.fail_count) return elif invalid_count > 0: self.log_msg.emit( f" {invalid_count} invalid file(s) will be skipped during encoding." ) 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 "" # v6-06: Start keep-awake (systemd-inhibit + optional mouse nudge) self._encode_start_time = time.monotonic() self._keepawake.start() try: for idx, file_path in enumerate(all_files, 1): if self._stop: self.log_msg.emit("STOP: Aborted by user.") break prev_success = self.success_count prev_fail = self.fail_count # v6-06: Update keep-awake ETA before each file. # ETA = (avg time per file so far) × (remaining files) processed = idx - 1 if processed > 0: elapsed = time.monotonic() - self._encode_start_time avg_per_file = elapsed / processed remaining = total - processed self._keepawake.update_eta(avg_per_file * remaining) else: self._keepawake.update_eta(None) # unknown for first file self._process_one_file(file_path, idx, total, worker_count, needs_scale, scale_filter) # v5-02: track consecutive failures with the same error pattern. # After 3 consecutive same-pattern failures, auto-abort the queue. if self.fail_count > prev_fail: pass # pattern tracking is handled in _process_one_file elif self.success_count > prev_success: # Success resets the consecutive failure counter. self._consecutive_fail_count = 0 self._last_fail_pattern = None # ── 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 OSError: 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) finally: # v6-06: Always stop keep-awake, even if the encode loop crashed. self._keepawake.stop() def _process_one_file(self, file_path, idx, total, worker_count, needs_scale, scale_filter): """Process a single file end-to-end (validate -> prepare -> encode -> verify). Extracted from run() so the per-file control flow is readable. All `continue` statements from the original loop become early `return`s here. The caller (run) simply iterates and re-checks `self._stop` at the top of each iteration. """ self.progress_msg.emit(file_path.name, idx, total) self.log_msg.emit(f"[{idx}/{total}] Encoding: {file_path.name}") # --- ffprobe pre-validation --- skip, info, src_w, src_h = self._validate_file(file_path) if skip: # v5-02: a skip is a failure for consecutive-failure tracking. self._check_consecutive_failures(file_path, accepted=False) return # _validate_file already logged SKIP + incremented fail_count # --- 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 / symlink + build output path --- prepared = self._prepare_input(file_path, src_w, src_h, needs_scale, scale_filter) if prepared is None: # v5-02: prepare failure counts for consecutive-failure tracking. self._check_consecutive_failures(file_path, accepted=False) return # _prepare_input already logged + cleaned up + incremented fail_count encode_input, output_f = prepared # --- Encode --- encode_ok = self._encode_one(file_path, encode_input, output_f, worker_count) if not encode_ok: # ffmpeg fallback path: _ffmpeg_fallback_encode does NOT touch # _current_temps or fail_count, so we do both here to match the # original `else: self.fail_count += 1; self._cleanup_current_temps()`. # av1an path: _encode_one's `finally` already cleaned temps and # fail_count was incremented inside _encode_one. # # STOP exception: when the user clicked STOP mid-encode, # _run_with_stop_check returned "stop" and _ffmpeg_fallback_encode # returned False WITHOUT incrementing fail_count (a user abort is # not a transcode failure). Honor that here by skipping the # fail_count increment when self._stop is set — temp cleanup # still runs so we don't leak intermediate files. if self.use_ffmpeg_fallback: if not self._stop: self.fail_count += 1 self._cleanup_current_temps() # v5-02: encode failure counts for consecutive-failure tracking # (but only if not a user STOP — a STOP is not a failure). if not self._stop: self._check_consecutive_failures(file_path, accepted=False) return # --- Post-encode verification + finalize --- accepted = self._verify_and_finalize(file_path, output_f, encode_input, needs_scale) if self.use_ffmpeg_fallback: # ffmpeg path always cleans up explicitly at every exit # (av1an path already cleaned up via _encode_one's `finally`). self._cleanup_current_temps() # accepted=True -> success_count already incremented in _verify_and_finalize. # accepted=False -> fail_count already incremented + output unlinked there. # v5-02: check for consecutive failures with the same pattern. self._check_consecutive_failures(file_path, accepted) def _check_consecutive_failures(self, file_path: Path, accepted: bool): """v5-02: Track consecutive failures and auto-abort after 3. After 3 consecutive failures (regardless of pattern — if 3 files in a row fail, something is systematically wrong), auto-abort the queue with a clear message. The user can still click STOP to abort earlier. This prevents the scenario from the user's log: 46 files, all failing identically, processed one by one over ~2 hours. With this fix, the queue aborts after file 3. """ if accepted: self._consecutive_fail_count = 0 return self._consecutive_fail_count += 1 if self._consecutive_fail_count >= 3 and not self._stop: self.log_msg.emit("") self.log_msg.emit( f"ABORT: {self._consecutive_fail_count} consecutive failures. " f"Auto-aborting queue — something is systematically wrong." ) self.log_msg.emit( " The remaining files will likely fail the same way. " "Fix the root cause (check the diagnostics above) and retry." ) self.log_msg.emit( " Common root causes: (1) all files are invalid (failed downloads), " "(2) av1an/encoder binary is broken, (3) out of disk space, " "(4) network mount is down." ) self._stop = True def _validate_file(self, file_path): """ffprobe pre-validation. Returns (skip, info, src_w, src_h). skip=True signals the caller to abandon this file — the SKIP log line and fail_count increment have already happened here. v5-01: If ffprobe cannot read the file, SKIP it instead of "attempting encode anyway". The v1-v4 behavior was to log a WARN and proceed — but when ffprobe fails, the encode fails ~99% of the time (the file is a failed download, HTML saved as .mp4, truncated, etc.). Wasting 2 hours on the per-file timeout for each invalid file is unacceptable. The `force=True` constructor flag overrides this for the 1% edge case (rare codec, broken container metadata where ffprobe fails but ffmpeg can still decode). """ # 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: if self.force: self.log_msg.emit( f"WARN: ffprobe could not read {file_path.name} — " f"attempting encode anyway (force=True)." ) else: # v5-01: run `file` to tell the user WHAT the file actually # is. This immediately reveals "HTML document" (failed # yt-dlp download) vs "data" (truncated/encrypted) vs # "ISO Media" (valid MP4 that ffprobe just can't parse). file_type = _identify_file_type(file_path) self.log_msg.emit( f"SKIP: {file_path.name} is not a valid video file " f"(ffprobe could not read it)." ) if file_type: self.log_msg.emit(f" File type: {file_type}") if "HTML" in file_type or "ASCII" in file_type or "text" in file_type: self.log_msg.emit( " This looks like a text/HTML file, not a video. " "Common cause: failed yt-dlp download (region-locked, " "age-restricted, or removed video). Re-download the file." ) elif "data" in file_type: self.log_msg.emit( " File type is 'data' — possibly truncated, encrypted, " "or a partial download. Verify the file plays in mpv/VLC." ) self.log_msg.emit( " (Use the Force checkbox to attempt encode anyway.)" ) self.fail_count += 1 return (True, None, None, None) 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 return (True, None, None, None) if duration < 0.5: self.log_msg.emit(f"SKIP: {file_path.name} is too short ({duration:.1f}s).") self.fail_count += 1 return (True, None, None, None) # 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 return (False, info, src_w, src_h) def _prepare_input(self, file_path, src_w, src_h, needs_scale, scale_filter): """Pre-scale (if needed) and ensure the av1an work dir lands in temp. Returns (encode_input, output_f) on success, or None on failure (after logging + cleaning up current temps + incrementing fail_count). """ # --- 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 if needs_scale: try: temp_scaled = _temp_path_for(file_path, ".scaled_tmp.mkv", worker_dir=self._temp_dir) self._current_temps.append(temp_scaled) # Use libx265 lossless for the intermediate — NOT ffv1. # ffv1 is not supported by VapourSynth source plugins (bestource, # 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 return None except (OSError, subprocess.SubprocessError) 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 return None # --- 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, worker_dir=self._temp_dir) 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}" return (encode_input, output_f) def _can_ffmpeg_fallback(self) -> bool: """v6-01: Check if ffmpeg has the encoder for this codec. Returns True if ffmpeg can encode with this codec's ffmpeg_encoder (e.g. libsvtav1, libvpx-vp9, libx265), False otherwise. Used to decide whether to retry a failed av1an encode with ffmpeg. """ ffmpeg_enc = self.video_codec.ffmpeg_encoder lib_key = ffmpeg_lib_key_for(ffmpeg_enc) return bool(self.env.ffmpeg_libs.get(lib_key, False)) def _encode_one(self, file_path, encode_input, output_f, worker_count): """Dispatch to ffmpeg fallback or av1an. Returns True if encode succeeded. ffmpeg fallback: delegates to _ffmpeg_fallback_encode (which itself performs the size >=5% integrity check and unlinks bad output). No temp cleanup or fail_count increment happens here for this path — _process_one_file handles both at the call site, matching the original. av1an: builds and runs the av1an command, performs the size >=5% check inline, and wraps everything in try/except/finally so temps are always cleaned up via _cleanup_current_temps() — matching the original. On every failure path here, fail_count is incremented inside this method. """ # ── 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})") return self._ffmpeg_fallback_encode( file_path, encode_input, output_f, ) # ── 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: result = self._run_with_stop_check( cmd, env=_av1an_env(), timeout=7200, log_prefix=" ", ) status, rc, stdout, stderr = result if status == "stop": # User requested STOP — do NOT increment fail_count (the # user explicitly chose to abort, it isn't a transcode # failure). Remove partial output. self._stop is already # True (set by the UI thread), so the orchestrator's # queue loop will break on the next iteration and emit # "STOP: Aborted by user." output_f.unlink(missing_ok=True) return False if status == "timeout": self.fail_count += 1 self.log_msg.emit(f"TIMEOUT: {file_path.name} exceeded 2h limit.") return False # status == "ok" — wrap in CompletedProcess so the downstream # returncode check, diagnostic dump, and pattern matching are # byte-for-byte unchanged. res = subprocess.CompletedProcess(cmd, rc, stdout, stderr) 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): # Success — resolution/duration/subtitle/finalize happen # in _verify_and_finalize (called by _process_one_file). return True 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) return False else: stderr_full = res.stderr or "" # v6: Don't increment fail_count yet — we may retry with # ffmpeg fallback below. Only increment if the retry also # fails (or no retry is possible). # Diagnostic dump — line-truncated, not character-truncated. self.log_msg.emit( f"FAIL: {file_path.name} (av1an exit code {res.returncode})" ) self.log_msg.emit(" ─── av1an stderr (last 25 lines) ───") stderr_lines = stderr_full.splitlines() for line in stderr_lines[-25:]: self.log_msg.emit(f" {line}") self.log_msg.emit(" ────────────────────────────────────") # Detect known av1an crash patterns and provide actionable fixes. # Pattern table — add new patterns here, no nested ifs below. # SEI CERT MSC04-C spirit: single source of truth for diagnostics. # # v5-03: Added "missing field `streams`" pattern — this is # the error av1an emits when its internal ffprobe call # returns JSON without a streams field, i.e. the input file # is not a valid video. Also added `file` command output # to the diagnostic so the user immediately sees "HTML # document" (failed yt-dlp download) instead of guessing. error_patterns: tuple[tuple[str, str, tuple[str, ...], bool], ...] = ( ( "Failed to get VSScript API", "av1an cannot initialize VapourSynth — the binary was " "compiled against a different VapourSynth version than " "what is currently installed.", ( " FIX (Arch): yay -S av1an OR cargo install av1an --force --locked", " FIX (Debian): sudo apt install vapoursynth libvapoursynth-script-dev av1an", " FIX (other): rebuild av1an against current VapourSynth", " VapourSynth R77+ changed the VSScript API; av1an must be recompiled.", ), True, # stop queue — every file will hit the same crash ), ( "No usable encoder found", "av1an cannot find the encoder binary (SvtAv1EncApp / vpxenc / x265).", ( " Verify the encoder is installed and in PATH.", " Arch: pacman -S svt-av1 libvpx-tools x265", " Debian: apt install svt-av1 libvpx-tools x265", ), True, ), # v6-02: av1an scene-detection panic — per-file, not systematic. ( "split scores is not empty", "av1an panicked during scene detection (known av1an bug). " "This is a per-file issue — the video content triggered a " "Rust panic in av1an's split module. Will retry with ffmpeg.", ( " This is an av1an internal bug, not a file corruption issue.", " The file is a valid video — ffmpeg can encode it directly.", ), False, # don't stop queue — retry with ffmpeg fallback ), ( "missing field `streams`", "av1an's internal ffprobe call could not parse this file — " "the file is not a valid video container. This is NOT an " "av1an or ffmpeg bug; the input file itself is invalid.", ( " The file is likely a failed yt-dlp download (HTML error", " page saved as .mp4), a truncated download, or not a video", " at all. Run `file ` to confirm.", ), False, # don't stop queue — other files may be valid ), ( "Invalid data found when processing input", "ffmpeg cannot read this input file — the file is corrupt, " "truncated, or not a valid video container.", ( " Run `file ` to see what the file actually is.", " If it's 'HTML document' or 'ASCII text', it's a failed", " yt-dlp download — re-download the source video.", " If it's 'data', the file may be truncated or encrypted.", ), False, ), ( "Error: End of file", "av1an hit EOF during scene detection — usually a VapourSynth " "source plugin issue with the intermediate file.", ( " Try a different --chunk-method (override via env probe).", " If pre-scaling, ensure the intermediate is libx265 CRF 0 (not ffv1).", ), False, ), ( "could not open input", "av1an cannot read this input file — possibly corrupt or " "an unsupported codec for the VapourSynth source plugin.", ( " Try playing the file with ffplay to verify it's not corrupt.", " Run: ffmpeg -i -f null - to see the decode error.", ), False, ), ) diagnosis_emitted = False for marker, summary, fixes, stop_queue in error_patterns: if marker.lower() in stderr_full.lower(): self.log_msg.emit("") self.log_msg.emit(f"DIAGNOSIS: {summary}") for fix in fixes: self.log_msg.emit(fix) # v5-03: run `file` on the input to tell the user # what the file actually is. This is especially # useful for "missing field streams" and "Invalid # data found" — the user immediately sees "HTML # document" instead of guessing. if marker in ("missing field `streams`", "Invalid data found when processing input", "could not open input"): file_type = _identify_file_type(file_path) if file_type: self.log_msg.emit(f" File type: {file_type}") if "HTML" in file_type or "ASCII" in file_type or "text" in file_type: self.log_msg.emit( " → This is a TEXT file, not a video. " "Failed yt-dlp download — re-download the source." ) elif "data" in file_type and "ISO Media" not in file_type: self.log_msg.emit( " → File type is 'data' — truncated, encrypted, " "or partial download." ) if stop_queue: self._stop = True self.log_msg.emit( f"STOP: Skipping remaining files (same {marker} issue)." ) diagnosis_emitted = True break if not diagnosis_emitted: # No known pattern matched — show the user where to look. # v6-03: detect "encoder SUMMARY in stderr + non-zero exit" # — the encoder succeeded but av1an failed to produce output. # This is the "chunks but never saves a file" pattern caused # by av1an's concat step failing. if "SUMMARY" in stderr_full and "Average Speed" in stderr_full: self.log_msg.emit("") self.log_msg.emit( "DIAGNOSIS: SVT-AV1 encoder completed successfully (SUMMARY" " block found in stderr), but av1an failed to produce the" " output file. This is an av1an concat failure — the encoder" " did its job but av1an's post-encode merge step crashed." ) self.log_msg.emit( " This is a known av1an bug on short videos (1-2 scenes)" " where concat of a single chunk fails. Will retry with" " ffmpeg fallback." ) else: self.log_msg.emit("") self.log_msg.emit( "DIAGNOSIS: Unknown av1an failure. Inspect the full stderr above." ) # v5-03: run `file` on the input as a fallback diagnostic. file_type = _identify_file_type(file_path) if file_type: self.log_msg.emit(f" File type: {file_type}") self.log_msg.emit( " Common causes: (1) out of disk space in temp dir, " "(2) AV1 concat failed silently — try installing mkvtoolnix, " "(3) av1an version too old for --concat flag — check av1an --help, " "(4) input file is not a valid video (run `file `)." ) # ── v6-01: Per-file av1an→ffmpeg fallback ── # If av1an failed for this file AND it's NOT a systematic issue # (VSScript API, missing encoder — those set self._stop=True), # AND ffmpeg has the encoder for this codec, retry with ffmpeg. # This handles: # - av1an concat failures (encoder succeeded but no output) # - av1an scene-detection panics ("split scores is not empty") # - Any other per-file av1an internal failure # # NOTE: Do NOT clean up _current_temps before the retry — # encode_input (symlink or pre-scaled file) is in _current_temps # and _ffmpeg_fallback_encode needs it. The finally block below # will clean up everything after the retry completes. if not self._stop and self._can_ffmpeg_fallback(): self.log_msg.emit("") self.log_msg.emit( f" RETRY: Attempting ffmpeg fallback for {file_path.name} " f"({self.video_codec.ffmpeg_encoder})..." ) # Remove any partial output av1an may have left output_f.unlink(missing_ok=True) # Retry with ffmpeg — _ffmpeg_fallback_encode does NOT # increment fail_count on failure (the caller does that). # If it succeeds, we return True WITHOUT incrementing # fail_count — the file was saved, just via a different path. fb_ok = self._ffmpeg_fallback_encode( file_path, encode_input, output_f, ) if fb_ok: self.log_msg.emit( f" RETRY OK: ffmpeg fallback succeeded for {file_path.name}" ) return True else: self.fail_count += 1 self.log_msg.emit( f" RETRY FAIL: ffmpeg fallback also failed for {file_path.name}" ) return False else: # No retry possible — this is a systematic issue (stop_queue # was set) or ffmpeg lacks the encoder. self.fail_count += 1 return False except (OSError, subprocess.SubprocessError) as e: self.fail_count += 1 self.log_msg.emit(f"SYSTEM ERROR: {file_path.name} — {e}") return False finally: # Always clean this file's temps before moving to next self._cleanup_current_temps() def _verify_and_finalize(self, file_path, output_f, encode_input, needs_scale): """Post-encode verification + subtitle mux + source deletion deferral. Runs after a successful _encode_one. Performs: - output resolution verification (if scaling was requested) - duration integrity check (>= 95% of source) - subtitle mux (if requested) - success_count increment + SUCCESS log - source deletion deferral (if delete_source is set) Returns True if the file was accepted, False if any check failed. On failure, fail_count is incremented and output_f is unlinked before returning False. Temp cleanup is the caller's responsibility — it differs between the av1an path (already done in _encode_one's finally) and the ffmpeg fallback path (done explicitly in _process_one_file). """ # 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) return False 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) return False # 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) return True 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. v3 (OTC-013): primary target is now the per-worker subdir (``~/.cache/OpenTranscode/tmp/worker-/``), NOT the shared app temp dir. This is safe because the subdir ONLY contains this worker's intermediates — a concurrent worker has its own subdir. The previous "nuclear" sweep of the entire app temp dir was a race-condition risk that this eliminates. Also scans in_dir/out_dir as a safety net for legacy temp files written by older versions that placed temps next to source files. """ swept = 0 # v3: sweep ONLY this worker's per-PID subdir, not the shared parent. # This is safe — the subdir contains only this worker's 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 OSError: # SEI CERT ERR01-C: narrow to OSError (file ops). # Best-effort sweep must not crash on a single bad path. 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 OSError: pass # Also clean any orphans still in _current_temps (e.g. stop/crash mid-loop) self._cleanup_current_temps() # v3: remove the now-empty per-worker subdir itself. try: self._temp_dir.rmdir() except OSError: pass # not empty / not ours — leave it 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) -> float | None: """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 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 (OSError, subprocess.SubprocessError, ValueError) as e: # ValueError covers json.JSONDecodeError and float() parse failures 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[int | None, 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 (OSError, subprocess.SubprocessError) 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, build_ffmpeg_iamf: bool = False): super().__init__() self.build_vs = build_vs self.build_av1an = build_av1an self.build_ffmpeg_iamf = build_ffmpeg_iamf self._stop = False # Private per-worker environment snapshot. Mutating os.environ is # process-global and leaks across threads/subsequent subprocesses; # _build_env is local to this worker and passed via env= to every # subprocess.run call below (see _run_cmd). self._build_env: dict[str, str] = os.environ.copy() def _extend_env(self, var: str, value: str, prepend: bool = False): """Add ``value`` to ``self._build_env[var]`` (NOT ``os.environ``). ``prepend=True`` places ``value`` first so it shadows any existing entry (e.g. ~/.local/bin must shadow /usr/bin, libiamf's PKG_CONFIG_PATH must shadow the system pkgconfig dir); default appends (e.g. extending PATH with ~/.cargo/bin). Caller is responsible for any idempotency check (matches the original per-site ``if x not in existing:`` pattern). rstrip(":") on prepend avoids a trailing colon when ``var`` was previously unset. """ existing = self._build_env.get(var, "") if prepend: self._build_env[var] = f"{value}:{existing}".rstrip(":") else: self._build_env[var] = f"{existing}:{value}" if existing else value def _run_cmd(self, cmd, cwd=None, timeout=600, label=""): """Run a command, log output, return (returncode, combined_output).""" self.log_msg.emit(f" $ {' '.join(cmd[:6])}{'...' if len(cmd)>6 else ''}") try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=self._build_env) # Log last few lines of stderr for diagnostics if r.stderr: for line in r.stderr.strip().splitlines()[-5:]: self.log_msg.emit(f" {line}") if r.returncode != 0 and r.stdout: for line in r.stdout.strip().splitlines()[-3:]: self.log_msg.emit(f" {line}") return r.returncode, (r.stdout or "") + (r.stderr or "") except subprocess.TimeoutExpired: self.log_msg.emit(f" TIMEOUT ({timeout}s) running: {label or cmd[0]}") return -1, f"timeout after {timeout}s" except (OSError, subprocess.SubprocessError) as e: self.log_msg.emit(f" ERROR: {e}") return -1, str(e) def _sudo_cmd(self, cmd, timeout=120, label=""): """Run a command with sudo (or pkexec as graphical fallback).""" # Try pkexec first (graphical polkit prompt — works in desktop sessions) pkexec = shutil.which("pkexec") if pkexec: return self._run_cmd([pkexec] + cmd, timeout=timeout, label=label or cmd[0]) # Fall back to sudo (needs a terminal; may fail silently) return self._run_cmd(["sudo"] + cmd, timeout=timeout, label=label or cmd[0]) def run(self): try: # ── 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", "make", ] 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. # NOTE: /root/.cargo/bin was dropped (OTC-015/v3-08) — root's # cargo dir is not readable by a non-root user. ~/.cargo/bin # covers the user's rustup install; /usr/bin is already in the # default PATH and is appended here only to match the original # mutation's intent (cargo from pacman lives there). self._extend_env("PATH", "/usr/bin") self._extend_env("PATH", str(Path.home() / ".cargo" / "bin")) if not shutil.which("cargo") and self.build_av1an: self.log_msg.emit(" FATAL: cargo not found after deps install. Aborting.") self.build_done.emit(False, "Rust/cargo not available") return # ── Optional: ffmpeg build deps (libopus, libvorbis dev pkgs) ── if self.build_ffmpeg_iamf: self._install_ffmpeg_build_deps() # ── Build & install VapourSynth to ~/.local (NO sudo needed) ── if self.build_vs: self._build_vapoursynth() # ── Build av1an to ~/.cargo/bin (NO sudo needed) ── if self.build_av1an: self._build_av1an() # ── Build libiamf + ffmpeg with --enable-libiamf to ~/.local ── if self.build_ffmpeg_iamf: self._build_libiamf() self._build_ffmpeg_with_iamf() # ── Ensure LD_LIBRARY_PATH includes local VS libs ── local_lib = str(Path.home() / ".local" / "lib") existing_ld = self._build_env.get("LD_LIBRARY_PATH", "") if local_lib not in existing_ld: self._extend_env("LD_LIBRARY_PATH", local_lib, prepend=True) 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: # SEI CERT ERR01-C: justified — this method orchestrates a long # multi-step build (git clone, meson, ninja, cargo install) whose # helper methods signal failure by `raise Exception(msg)` (15 # sites). Catching Exception here converts any of those into a # user-facing build_done(False, ...) signal instead of crashing # the QThread. Narrowing would require refactoring all `raise # Exception(...)` call sites — out of scope for ERR01-C pass. self.log_msg.emit(f"BUILD FAILED: {e}") self.build_done.emit(False, str(e)) def _build_vapoursynth(self): """Clone, build, and install VapourSynth to ~/.local/ (no sudo needed).""" self.log_msg.emit("") self.log_msg.emit("=== Building VapourSynth from git ===") self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)") build_dir = Path("/tmp/vapoursynth-git-build") local_prefix = str(Path.home() / ".local") if build_dir.exists(): self.log_msg.emit(f" Cleaning old build directory...") shutil.rmtree(build_dir, ignore_errors=True) # Clone (shallow — faster) rc, out = self._run_cmd( ["git", "clone", "--depth", "1", "https://github.com/vapoursynth/vapoursynth.git", str(build_dir)], timeout=120, label="git clone vapoursynth", ) if rc != 0: raise Exception(f"git clone VapourSynth failed: {out[-300:]}") # Meson setup — install to ~/.local so it doesn't touch system dirs self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...") rc, out = self._run_cmd( ["meson", "setup", "build", f"--prefix={local_prefix}", "--libdir=lib"], cwd=str(build_dir), timeout=120, label="meson setup", ) if rc != 0: raise Exception(f"meson setup failed: {out[-500:]}") # Build self.log_msg.emit(" Compiling VapourSynth (this may take a few minutes)...") rc, out = self._run_cmd( ["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))], cwd=str(build_dir), timeout=900, label="ninja build", ) if rc != 0: raise Exception(f"ninja build failed: {out[-500:]}") # Install to ~/.local/ — NO sudo needed (user owns this directory) self.log_msg.emit(" Installing VapourSynth to ~/.local/ ...") rc, out = self._run_cmd( ["ninja", "-C", "build", "install"], cwd=str(build_dir), timeout=120, label="ninja install", ) if rc != 0: raise Exception(f"ninja install failed: {out[-500:]}") self.log_msg.emit(f" VapourSynth installed to {local_prefix}/ (libs in {local_prefix}/lib/)") # Cleanup build directory shutil.rmtree(build_dir, ignore_errors=True) def _build_av1an(self): """Clone and build av1an from git. Installs to ~/.cargo/bin/ (no sudo needed).""" self.log_msg.emit("") self.log_msg.emit("=== Building av1an from git ===") self.log_msg.emit(" Install target: ~/.cargo/bin/ (no system-wide changes)") # Ensure cargo is in PATH cargo_bin = shutil.which("cargo") if not cargo_bin: # Common locations for p in [Path.home() / ".cargo" / "bin" / "cargo", "/usr/bin/cargo"]: if p.exists(): self._extend_env("PATH", str(p.parent)) cargo_bin = str(p) break if not cargo_bin: raise Exception("cargo not found — cannot build av1an") self.log_msg.emit(f" Using cargo at: {cargo_bin}") self.log_msg.emit(" Compiling av1an (this may take 10-30 minutes)...") rc, out = self._run_cmd( ["cargo", "install", "av1an", "--git", "https://github.com/master-of-zen/av1an", "--force", "--root", str(Path.home() / ".cargo")], timeout=3600, label="cargo install av1an", ) if rc != 0: raise Exception(f"cargo install av1an failed: {out[-500:]}") new_av1an = Path.home() / ".cargo" / "bin" / "av1an" if new_av1an.exists(): self.log_msg.emit(f" av1an installed: {new_av1an}") else: self.log_msg.emit(" WARNING: av1an binary not found at expected path after build.") def _install_ffmpeg_build_deps(self): """Install ffmpeg build deps (libopus, libvorbis dev packages). Uses pkg-config to detect missing libraries, then installs the corresponding Arch/pacman packages. On other distros the user must install these manually; the log will name them. """ self.log_msg.emit("") self.log_msg.emit("=== Checking ffmpeg build dependencies ===") # (pkg-config name, Arch package name, Debian package name) pkg_checks = [ ("opus", "opus", "libopus-dev"), ("vorbis", "libvorbis", "libvorbis-dev"), ("ogg", "libogg", "libogg-dev"), ] missing_arch = [] missing_debian = [] for pc_name, arch_pkg, debian_pkg in pkg_checks: rc, _ = self._run_cmd( ["pkg-config", "--exists", pc_name], timeout=10, label=f"pkg-config {pc_name}", ) if rc != 0: missing_arch.append(arch_pkg) missing_debian.append(debian_pkg) self.log_msg.emit(f" Missing: {arch_pkg} (pkg-config {pc_name})") else: self.log_msg.emit(f" OK: {pc_name}") if not missing_arch: self.log_msg.emit(" All ffmpeg build deps satisfied.") return # Try pacman (Arch) first since the rest of this app assumes Arch if shutil.which("pacman"): self.log_msg.emit(f" Installing via pacman: {', '.join(missing_arch)}") rc, _ = self._sudo_cmd( ["pacman", "-S", "--needed", "--noconfirm"] + missing_arch, timeout=300, label="pacman ffmpeg-deps", ) if rc != 0: self.log_msg.emit(" WARNING: pacman install failed — configure may fail.") elif shutil.which("apt-get"): self.log_msg.emit(f" Installing via apt: {', '.join(missing_debian)}") rc, _ = self._sudo_cmd( ["apt-get", "install", "-y"] + missing_debian, timeout=300, label="apt ffmpeg-deps", ) if rc != 0: self.log_msg.emit(" WARNING: apt install failed — configure may fail.") else: self.log_msg.emit( f" No supported package manager found. Install manually: " f"{', '.join(missing_arch)} (Arch) or {', '.join(missing_debian)} (Debian)." ) def _build_libiamf(self): """Clone, build, and install libiamf to ~/.local/ (no sudo needed). libiamf is the AOMedia Immersive Audio Model and Formats reference library. ffmpeg links against it via --enable-libiamf. """ self.log_msg.emit("") self.log_msg.emit("=== Building libiamf from git ===") self.log_msg.emit(" Source: https://github.com/AOMediaCodec/libiamf") self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)") build_dir = Path("/tmp/libiamf-git-build") local_prefix = Path.home() / ".local" if build_dir.exists(): shutil.rmtree(build_dir, ignore_errors=True) # Clone (shallow) self.log_msg.emit(" Cloning libiamf source (shallow)...") rc, out = self._run_cmd( ["git", "clone", "--depth", "1", "https://github.com/AOMediaCodec/libiamf.git", str(build_dir)], timeout=120, label="git clone libiamf", ) if rc != 0: raise Exception(f"git clone libiamf failed: {out[-300:]}") # CMake configure cmake_build = build_dir / "build" cmake_build.mkdir(exist_ok=True) self.log_msg.emit(f" Configuring with cmake (--prefix={local_prefix})...") rc, out = self._run_cmd( ["cmake", "-S", str(build_dir), "-B", str(cmake_build), f"-DCMAKE_INSTALL_PREFIX={local_prefix}", "-DCMAKE_BUILD_TYPE=Release", "-DBUILD_SHARED_LIBS=ON"], timeout=120, label="cmake configure libiamf", ) if rc != 0: raise Exception(f"cmake configure libiamf failed:\n{out[-500:]}") # Build self.log_msg.emit(" Compiling libiamf...") rc, out = self._run_cmd( ["cmake", "--build", str(cmake_build), "-j", str(max(1, os.cpu_count() or 2))], timeout=600, label="cmake build libiamf", ) if rc != 0: raise Exception(f"cmake build libiamf failed:\n{out[-500:]}") # Install self.log_msg.emit(f" Installing libiamf to {local_prefix}/ ...") rc, out = self._run_cmd( ["cmake", "--install", str(cmake_build)], timeout=120, label="cmake install libiamf", ) if rc != 0: raise Exception(f"cmake install libiamf failed:\n{out[-500:]}") # Make libiamf discoverable: PKG_CONFIG_PATH and LD_LIBRARY_PATH pc_dir = local_prefix / "lib" / "pkgconfig" if pc_dir.exists(): existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "") if str(pc_dir) not in existing_pkgs: self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True) self.log_msg.emit(f" Added {pc_dir} to PKG_CONFIG_PATH") lib_dir = local_prefix / "lib" existing_ld = self._build_env.get("LD_LIBRARY_PATH", "") if str(lib_dir) not in existing_ld: self._extend_env("LD_LIBRARY_PATH", str(lib_dir), prepend=True) self.log_msg.emit(f" libiamf installed to {local_prefix}/") # Cleanup shutil.rmtree(build_dir, ignore_errors=True) def _build_ffmpeg_with_iamf(self): """Rebuild ffmpeg from source with libiamf (and IAMF's Opus dep). Strategy: detect the current ffmpeg's --enable-* configure flags, reuse them, and append --enable-libiamf. This preserves all existing functionality (libsvtav1, libvpx, libx265, etc.) while adding IAMF support. Installs to ~/.local/bin/ffmpeg so it shadows the system ffmpeg without overwriting it. The user must restart the app for the new ffmpeg to take effect (probe_environment re-runs on launch). """ self.log_msg.emit("") self.log_msg.emit("=== Building ffmpeg from git with IAMF ===") self.log_msg.emit(" Install target: ~/.local/bin/ (shadows system ffmpeg)") # 1. Detect current ffmpeg configure flags ffmpeg_bin = shutil.which("ffmpeg") or "/usr/bin/ffmpeg" self.log_msg.emit(f" Probing current ffmpeg config: {ffmpeg_bin}") rc, out = self._run_cmd( [ffmpeg_bin, "-buildconf"], timeout=30, label="ffmpeg -buildconf", ) if rc != 0: raise Exception(f"ffmpeg -buildconf failed:\n{out[-300:]}") # Parse --enable-* flags from output (one per line, sometimes with leading whitespace) enables = re.findall(r"--enable-[a-z0-9_-]+", out) # Dedupe while preserving order seen = set() enable_flags = [] for e in enables: if e not in seen: seen.add(e) enable_flags.append(e) # Make sure libiamf and libopus are in the list (core requirements) if "--enable-libiamf" not in enable_flags: enable_flags.append("--enable-libiamf") if "--enable-libopus" not in enable_flags: enable_flags.append("--enable-libopus") self.log_msg.emit(f" Configure flags ({len(enable_flags)}):") for f in enable_flags: self.log_msg.emit(f" {f}") # 2. Clone ffmpeg source build_dir = Path("/tmp/ffmpeg-git-build") if build_dir.exists(): shutil.rmtree(build_dir, ignore_errors=True) self.log_msg.emit(" Cloning ffmpeg source (shallow)...") rc, out = self._run_cmd( ["git", "clone", "--depth", "1", "https://git.ffmpeg.org/ffmpeg.git", str(build_dir)], timeout=300, label="git clone ffmpeg", ) if rc != 0: # Fall back to GitHub mirror self.log_msg.emit(" Primary mirror failed, trying github mirror...") rc, out = self._run_cmd( ["git", "clone", "--depth", "1", "https://github.com/FFmpeg/FFmpeg.git", str(build_dir)], timeout=300, label="git clone ffmpeg (github)", ) if rc != 0: raise Exception(f"git clone ffmpeg failed:\n{out[-300:]}") local_prefix = Path.home() / ".local" # Make sure pkg-config finds the freshly-built libiamf pc_dir = local_prefix / "lib" / "pkgconfig" existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "") if str(pc_dir) not in existing_pkgs: self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True) # 3. Configure self.log_msg.emit(" Running ./configure (this may take a minute)...") configure_cmd = [ "./configure", f"--prefix={local_prefix}", "--enable-shared", "--enable-pic", "--enable-version3", ] + enable_flags rc, out = self._run_cmd( configure_cmd, cwd=str(build_dir), timeout=300, label="ffmpeg configure", ) if rc != 0: # Show the actual error — usually a missing -dev package raise Exception( "ffmpeg configure failed. This usually means a dev library\n" "is missing. Install the corresponding -dev package and retry.\n" f"Output:\n{out[-800:]}" ) # 4. Build self.log_msg.emit(" Compiling ffmpeg (this may take 10-20 minutes)...") rc, out = self._run_cmd( ["make", "-j", str(max(1, os.cpu_count() or 2))], cwd=str(build_dir), timeout=2400, label="make ffmpeg", ) if rc != 0: raise Exception(f"ffmpeg make failed:\n{out[-500:]}") # 5. Install to ~/.local self.log_msg.emit(f" Installing ffmpeg to {local_prefix}/ ...") rc, out = self._run_cmd( ["make", "install"], cwd=str(build_dir), timeout=300, label="make install ffmpeg", ) if rc != 0: raise Exception(f"make install ffmpeg failed:\n{out[-500:]}") # 6. Ensure ~/.local/bin is in PATH so new ffmpeg shadows system one local_bin = local_prefix / "bin" existing_path = self._build_env.get("PATH", "") if str(local_bin) not in existing_path: self._extend_env("PATH", str(local_bin), prepend=True) self.log_msg.emit(f" Prepended {local_bin} to PATH (shadows system ffmpeg)") new_ffmpeg = local_bin / "ffmpeg" if new_ffmpeg.exists(): self.log_msg.emit(f" ffmpeg installed: {new_ffmpeg}") self.log_msg.emit( " IMPORTANT: Restart the app for the new ffmpeg (with libiamf)\n" " to be detected and used. The IAMF audio entry will then\n" " be selectable (not greyed out)." ) else: self.log_msg.emit(" WARNING: ffmpeg binary not found at expected path after build.") # Cleanup build dir (keep source for re-runs? No — disk is cheap, time isn't, but # a clean clone is more reliable than a stale tree.) shutil.rmtree(build_dir, ignore_errors=True) def stop(self): self._stop = True # ────────────────────────────────────────────── # 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: EncoderWorker | None = None self.env: EnvProbe | None = None self._pending_deletes: list[Path] = [] self._apply_mmd3_theme() self._build_ui() # Probe environment after UI is up 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], self._on_audio_changed), ("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed), ("RESOLUTION", [], self._on_resolution_changed), ("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None), ]): 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) # v5-01: Force checkbox — skip ffprobe validation and attempt encode # even for files ffprobe cannot read. Use for the rare edge case where # ffprobe fails but the file is actually valid. Default OFF — most # "ffprobe can't read" files are genuinely invalid (failed downloads, # HTML saved as .mp4, truncated files, etc.). self.force_check = QCheckBox("Force (skip validation)") self.force_check.setToolTip( "Skip ffprobe pre-validation and attempt encode even for files\n" "ffprobe cannot read. Useful for the rare case where ffprobe\n" "fails but the file is actually valid (rare codec, broken\n" "container metadata). WARNING: with this enabled, invalid files\n" "(failed downloads, HTML, truncated) will waste the full\n" "per-file timeout before failing." ) opt_row.addWidget(self.force_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) self.btn_about = QPushButton(" ? ABOUT / LICENSES") self.btn_about.setObjectName("btnAbout") self.btn_about.setFixedHeight(40) self.btn_about.setToolTip( "Show open-source license attributions for all\n" "third-party components invoked by this application." ) self.btn_about.clicked.connect(self._show_license_dialog) btn_lay.addWidget(self.btn_about) root.addLayout(btn_lay) # ── Footer ── 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 via index lookup — no for-loop, no break. # next(..., None) returns the first match or None; the if guards the # block so we only touch container_combo when a match was found. match = next( (i for i, cp in enumerate(CONTAINER_PROFILES) if cp.ext == profile.container), None, ) if match is not None: self.container_combo.blockSignals(True) self.container_combo.setCurrentIndex(match) self.container_combo.blockSignals(False) # Re-evaluate compatibility after auto-container change. self._check_combo_compatibility() def _populate_presets(self, codec_idx: int): self.preset_combo.blockSignals(True) 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}") self._check_combo_compatibility() @Slot() def _on_audio_changed(self, idx: int): if idx >= 0: self._log(f"Audio set to: {AUDIO_PROFILES[idx].label}") self._check_combo_compatibility() def _check_combo_compatibility(self) -> list[str]: """Check current video/audio/container combination for known incompatibilities. Logs every warning and returns the full list (empty if clean). Hard incompatibilities (which would fail at encode/mux time) are prefixed ``INCOMPATIBLE:`` and also block the Start button via _start_process. Soft warnings are prefixed ``WARNING:`` and only appear in the log. Safe to call during __init__ — every attribute is guarded. Refactored to table-driven dispatch: every rule is a tuple of (predicate, severity, message-fn), evaluated by a single loop. Adding a new rule is a one-line table change; no nested ifs. SEI CERT STR09-C spirit: predicates return plain bool, never None; messages are produced only when their predicate fires, so the severity prefix is always consistent with the predicate outcome. """ # Resolve current selection with full defensive validation. # All four early returns return the same value ([]), so this # block reads as a flat guard rather than a nested decision tree. if not all(hasattr(self, attr) for attr in ("codec_combo", "audio_combo", "container_combo")): return [] codec_idx = self.codec_combo.currentIndex() audio_idx = self.audio_combo.currentIndex() container_idx = self.container_combo.currentIndex() if min(codec_idx, audio_idx, container_idx) < 0: return [] if not (codec_idx < len(VIDEO_CODECS) and audio_idx < len(AUDIO_PROFILES) and container_idx < len(CONTAINER_PROFILES)): return [] video_codec = VIDEO_CODECS[codec_idx] audio_profile = AUDIO_PROFILES[audio_idx] container = CONTAINER_PROFILES[container_idx] # ── Compatibility rule table ── # Each rule: (predicate, severity, message) # predicate: callable(video_codec, audio_profile, container) -> bool # severity: "INCOMPATIBLE" or "WARNING" # message: str (already-formatted) # # To add a new rule, append a tuple here. No code below changes. def _is_hevc(vc, _ap, c) -> bool: return vc.ffmpeg_encoder == "libx265" and c.ext == "webm" # v3 (OTC-012, SEI CERT STR09-C): compare against the # AudioProfile.ffmpeg_encoder_name field directly, not via # substring match on params (which could false-match a # hypothetical `-libiamf-mode` argument). def _is_iamf_non_mp4(_vc, ap, c) -> bool: return ap.ffmpeg_encoder_name == "libiamf" and c.ext != "mp4" def _is_vorbis_in_mp4(_vc, ap, c) -> bool: return ap.ffmpeg_encoder_name == "libvorbis" and c.ext == "mp4" def _is_flac_in_webm(_vc, ap, c) -> bool: return ap.ffmpeg_encoder_name == "flac" and c.ext == "webm" def _is_vp9_in_mp4(vc, _ap, c) -> bool: return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4" rules: tuple[tuple, ...] = ( (_is_hevc, "INCOMPATIBLE", "x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."), (_is_iamf_non_mp4, "INCOMPATIBLE", f"IAMF audio requires the MP4 container — cannot mux into " f"{container.ext.upper()}. Switch container to MP4."), (_is_vorbis_in_mp4, "WARNING", "Vorbis in MP4 has limited player support. Consider Opus or MKV/WebM."), (_is_flac_in_webm, "WARNING", "FLAC in WebM is rarely supported by players. Consider MKV instead."), (_is_vp9_in_mp4, "WARNING", "VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."), ) # Single-pass evaluation: build the warnings list by filtering # the rule table through each predicate. No nested if/elif. warnings: list[str] = [ f"{severity}: {message}" for predicate, severity, message in rules if predicate(video_codec, audio_profile, container) ] for w in warnings: self._log(w) return warnings def _populate_resolution_combo(self): """Populate resolution dropdown with separator headers per category. Refactored with PEP 634/868 structural pattern matching: the category-transition decision is expressed as a single match statement instead of nested ifs. The match value is a 2-tuple of (current_category, previous_category); each case is a flat pattern, no nesting. """ # Maps combo box position -> RESOLUTION_PRESETS index. # Separators occupy combo positions too, so we must track them. self._res_preset_indices: dict[int, int] = {} last_cat: str | None = None combo_pos = 0 for i, rp in enumerate(RESOLUTION_PRESETS): # Single-level decision: insert separator only when transitioning # to a new category AND we are not on the first category. match (rp.category, last_cat): case (cat, prev) if cat != prev and prev is not None: self.resolution_combo.insertSeparator(combo_pos) combo_pos += 1 # separator takes a slot last_cat = rp.category self.resolution_combo.addItem(rp.label) self._res_preset_indices[combo_pos] = i 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): # Guard against signals (combo currentIndexChanged, knob valueChanged, # etc.) firing during __init__ before self.log_box has been # constructed. Without this, the first addItem() on any combo # triggers its slot, which calls _log(), which dereferences # self.log_box while it is still None -> AttributeError -> crashes # the app on launch. Also buffer messages so they aren't lost. if not hasattr(self, "log_box") or self.log_box is None: buffered = getattr(self, "_log_buffer", None) if buffered is None: buffered = self._log_buffer = [] buffered.append(msg) return # Flush any messages that arrived before log_box existed. buffered = getattr(self, "_log_buffer", None) if buffered: for m in buffered: self.log_box.append(f"> {m}") self._log_buffer = [] 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 = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 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}" ) # --- License attribution banner (shown once after successful probe) --- # POSIX-friendly: log plain text, no escape codes, no decorative box chars # that might confuse terminals. Each tool is named with its SPDX id so # the user can audit obligations at a glance. self._show_license_banner() def _show_license_banner(self) -> None: """Log the active-component license summary once at startup. SEI CERT MSC04-C: license text lives in exactly one canonical location (LICENSE_NOTICES); this method only formats it. """ notices = active_license_notices(self.env) self._log("") self._log("=== Open Source License Attribution ===") self._log("This application invokes the following third-party tools.") self._log("Source code of these tools is NOT bundled; licenses flow") self._log("through from upstream. See About > Licenses for full text.") self._log("") for n in notices: self._log(f" • {n.name} — {n.spdx}") self._log(f" {n.home_url}") self._log("") self._log("End of license summary.") self._log("") def _show_license_dialog(self) -> None: """Open a modal dialog with the full license text. Triggered from the menu / button so the user can review the complete attribution text at any time. """ notices = active_license_notices(self.env) text = license_banner_full(notices) dlg = QMessageBox(self) dlg.setWindowTitle("About — Open Source Licenses") dlg.setText("This application invokes the following open-source tools:") dlg.setInformativeText(text) dlg.setStandardButtons(QMessageBox.StandardButton.Ok) dlg.exec() def _show_pre_transcode_license_summary(self) -> None: """One-line license reminder logged at the start of each batch. Keeps the legal notice adjacent to the act of transcode, which is where redistribution-relevant output is produced. """ notices = active_license_notices(self.env) self._log(f"LICENSES: {license_banner_short(notices)}") def _disable_unavailable_codecs(self): """Grey out AUDIO codec combos whose FFmpeg library is missing. Video codecs are NOT disabled here because av1an uses its own encoder binaries (svt_av1, vpx, x265) — it does not rely on ffmpeg's encoder list for video. v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now carries its ffmpeg encoder name as the `ffmpeg_encoder_name` field (e.g. "libopus"). We look up that name in env.ffmpeg_libs directly. This replaces the v2 approach of indexing into `params[1]`, which assumed a fixed params layout and would silently break if a profile ever used a different argument order. SEI CERT MSC04-C spirit: the source of truth for which library each profile needs is the profile itself, not a parallel table. """ libs = self.env.ffmpeg_libs for idx, profile in enumerate(AUDIO_PROFILES): if idx >= self.audio_combo.count(): break # combo not yet populated, defensive # v3: use the dedicated field instead of indexing into params. lib_name = profile.ffmpeg_encoder_name if not lib_name: continue # passthrough profile, no encoder dependency if not libs.get(lib_name, False): item = self.audio_combo.model().item(idx) if item is not None: item.setEnabled(False) item.setToolTip( f"DISABLED: FFmpeg missing {lib_name} encoder. " f"Use Rebuild from Git > ffmpeg + IAMF to enable." ) # If the currently-selected item is the one we disabled, # fall back to the first enabled entry. if self.audio_combo.currentIndex() == idx: self.audio_combo.setCurrentIndex(0) # ── Process Control ── 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 # ── Pre-flight: codec/container/audio compatibility check ── # Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode. warnings = self._check_combo_compatibility() hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")] if hard_blocks: self._log("ERROR: Aborting — incompatible combination selected.") QMessageBox.critical( self, "Incompatible Codec Combination", "The selected video/audio/container combination cannot be encoded:\n\n" + "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks) + "\n\nFix the selection and try again." ) return # Pre-transcode license reminder — adjacent to the act of transcode # so obligations are visible at the moment redistribution-relevant # output is produced. self._show_pre_transcode_license_summary() # If delete is enabled, collect files first for batch confirmation if self.del_check.isChecked(): extensions = self._parse_extensions() 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: # Smoke test failed for an unexpected reason (encoder binary # missing, concat method unsupported, av1an panicked, etc.). # Previously this was logged as "non-fatal" and the encode # proceeded anyway — which produced the "chunks but never # saves a file" symptom because every file then failed at # the same point. Now we treat unknown smoke failures as # hard blocks and offer the user ffmpeg fallback if the # selected codec is available, otherwise abort. self._log(f" FAIL: av1an smoke test failed:") for line in detail.splitlines()[:12]: self._log(f" {line}") # If ffmpeg has the matching encoder, offer fallback; # otherwise abort with an actionable message. codec_idx_pre = self.codec_combo.currentIndex() if 0 <= codec_idx_pre < len(VIDEO_CODECS): vc = VIDEO_CODECS[codec_idx_pre] lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 if self.env.ffmpeg_libs.get(lib_key, False): self._log(f" FFmpeg has {vc.ffmpeg_encoder} — offering fallback.") use_ffmpeg_fallback = self._handle_vs_incompat() if not use_ffmpeg_fallback: return else: self._log( f" ABORT: ffmpeg also lacks {vc.ffmpeg_encoder}. " f"Install the encoder binary (e.g. SvtAv1EncApp, vpxenc, x265) " f"or use the REBUILD FROM GIT button." ) return else: self._log(" ABORT: invalid codec selection.") return 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], force=self.force_check.isChecked(), # v5-01 ) 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 = ffmpeg_lib_key_for(ffmpeg_enc) # v3: OTC-007 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, build_ffmpeg_iamf: bool = False): """Start the SourceBuildWorker thread.""" components = [] if build_vs: components.append("VapourSynth") if build_av1an: components.append("av1an") if build_ffmpeg_iamf: components.append("ffmpeg+libiamf") self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...") self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.") if build_ffmpeg_iamf: self._log(" NOTE: ffmpeg build takes 10-20 min. App must be restarted after.") self.btn_run.setEnabled(False) self.btn_rebuild.setEnabled(False) self.btn_stop.setEnabled(False) self.status_label.setText("Building from git... (see log)") self._build_worker = SourceBuildWorker( build_vs=build_vs, build_av1an=build_av1an, build_ffmpeg_iamf=build_ffmpeg_iamf, ) self._build_worker.log_msg.connect(self._log) self._build_worker.build_done.connect(self._on_build_done) self._build_worker.start() @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. # # INTENTIONAL os.environ mutation (the ONE kept after the # v3-08 refactor). SourceBuildWorker no longer mutates # os.environ — it accumulates env changes in its private # self._build_env dict and passes that to subprocess.run. # But that dict dies with the worker thread. The UI thread # must update its OWN os.environ so the next # probe_environment() call — which spawns ffmpeg/av1an # subprocesses that inherit os.environ — can dlopen the # freshly-built VapourSynth / libiamf shared libraries from # ~/.local/lib. Without this, the rebuilt binaries would # fail to load their dependent 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(":") # 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 = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 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_ffmpeg_iamf = QPushButton(" ffmpeg + IAMF ") btn_ffmpeg_iamf.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 ~/.local (needs sudo for build deps)\n" "• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n" "• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n" " installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n" " Required to use the IAMF audio codec. ~10-20 min build time.\n\n" "Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min" ) dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole) dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole) dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole) dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole) dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) dlg.exec() 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) elif clicked == btn_ffmpeg_iamf: self._start_git_rebuild(build_vs=False, build_av1an=False, build_ffmpeg_iamf=True) @Slot(str, int, int) def _on_progress(self, filename: str, current: int, total: int): 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())