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