From 244ac2d2f2936da5946d178efcbcdb546e33c487 Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sat, 11 Jul 2026 03:01:37 -0400 Subject: [PATCH] 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. --- .gitignore | 54 + LICENSE | 21 + OpenTranscode.png | Bin 0 -> 121788 bytes README.md | 183 +++ open-transcode.py | 3967 +++++++++++++++++++++++++++++++++++++++++++++ quickstart.md | 153 ++ 6 files changed, 4378 insertions(+) create mode 100644 .gitignore create mode 100755 LICENSE create mode 100644 OpenTranscode.png create mode 100755 README.md create mode 100644 open-transcode.py create mode 100644 quickstart.md 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/OpenTranscode.png b/OpenTranscode.png new file mode 100644 index 0000000000000000000000000000000000000000..65c2cb41679698cafea9bd8f661bd07ccf137765 GIT binary patch literal 121788 zcmb5WWmuJM(>AOU3W#)1uXDO_3}8%lc!)G z5&lndPAPi}&)x9Wubv;K)2J@Gf&+#2#4FGgW?UhYy#0Qlk6xH>_0iuJ!l;OT|0wn@ z=Ci@_@S>}4Y*R`{IT}Y88ib3i|9a`9FN#dHKgz8C9MUhuU!Q#;2}y;dxh`J(pzn!Q zyq$kZ{6}zCM!W9`bsjzpTyGRmd1$A-*JVFnbbS{U9rcO-k3cDusC=E7QL+_seSUvF zvr5^Taql=8+d>a%S@KY-*LxrEM|$cAeV#&^F1RTj_kS!Ax^t4s!5SFqYxk}Qy#71j zH1VlRiq)-kKzO)fx_wk}h1o1aR!-KJzkSS)Z`-{NV2z<@WEqRBxVV2dV#jk72acn^AH9PE2 z=kNQ3nWKi7mK@W?o$c>v6$9fZ6C%H2?)nt=0Z(=dE=wiRVqm{ z7B@T83Xk$zB;IP52>NdEMnoZJcM4pq4ZU=nrJi~rCj6GST0b%L?^Khd<+SvzY>BS$ zaWK7wEZJUpm1&QY+a)eLRz6M>R~=D8;Z0#T?cqmnBg3V1q zPDqvo&MSJdF8+34m1+24m+0`T{j8(fgV;a<*ECC`-ihG+QLd0*(;j`L$uJ{ZY)6#F z*z~mj7kbqepLbOJ{7fzy_bsf4eR!5{D5wx9h#aYR@Nkbhk1eGe^{MWUyBm;q*Ycgb zY;H$|bXuC?b;dUamFf%#Ud>n%p-UwDg`CQThV7!Dd znE^8>U_F^d4GnmfjW>kEB*a$J5D!y?jE?T^l|e@yX=Uo?`z=HzjrW2ncJuG^=Hm7p32lu#wtD9?a%CZWrMGdyoR;UR#Lrtr9wCoTer55AL^Euz=2lae6 zHP}QRl!?q|0fB)(M7r@q(W-5nuhSM6HHzKne%@ddYBAwP#&cj`J*=sruWc4o@SNNR z�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 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..173f344 --- /dev/null +++ b/open-transcode.py @@ -0,0 +1,3967 @@ +#!/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 ffprobe_duration(filepath: Path, ffprobe_bin: str) -> Optional[float]: + """Return media duration in seconds via ffprobe, or None on failure. + + Used by the EncoderWorker post-encode integrity check to compare source + and output durations. Modeled after :func:`ffprobe_validate` — every + failure path returns ``None`` so the caller can treat unverifiable + durations as "skip the check" rather than crashing the worker thread. + """ + try: + res = subprocess.run( + [ffprobe_bin, "-v", "quiet", "-print_format", "json", + "-show_format", "-show_entries", "format=duration", + str(filepath)], + capture_output=True, text=True, timeout=10, + ) + if res.returncode != 0 or not res.stdout: + return None + import json + data = json.loads(res.stdout) + dur_str = (data.get("format") or {}).get("duration") + if dur_str is None: + return None + return float(dur_str) + except 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