3940 lines
162 KiB
Python
3940 lines
162 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OpenCodec Transcode Master — Merged & Refactored
|
|
=================================================
|
|
Merges best of: trans.py (CTk probe/tooltips), custom-transcoder (QThread/pathlib),
|
|
and the bash script's ffmpeg-fallback + WebM concept.
|
|
|
|
Key refactors over originals:
|
|
- Config-driven codec/audio/container profiles (no more nested if/else chains)
|
|
- Distro-aware: Arch, Fedora, RHEL/CentOS/Rocky/Alma, openSUSE, NixOS, Debian/Ubuntu
|
|
- Per-distro binary search paths, package manager, install hints, encoder name quirks
|
|
- FFmpeg encoder library availability probe (greys out unavailable codecs in UI)
|
|
- ffprobe pre-validation before encoding
|
|
- QThread worker (thread-safe UI, proper signal/slot)
|
|
- Runtime av1an flag + version probing
|
|
- Dynamic worker count (os.cpu_count - 2)
|
|
- pathlib throughout
|
|
- Per-file progress tracking
|
|
- Container format selection (MKV / WebM)
|
|
- AV1/VP9/x265 preset selection
|
|
- Configurable input extensions
|
|
- Batch delete with summary prompt (not per-file)
|
|
"""
|
|
|
|
import hashlib
|
|
import math
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import ctypes
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
|
|
QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox,
|
|
QStyleFactory,
|
|
)
|
|
from PySide6.QtCore import Qt, QThread, Signal, Slot, QPointF, QRectF
|
|
from PySide6.QtGui import (
|
|
QFont, QPalette, QColor, QPainter, QPen, QBrush,
|
|
QRadialGradient, QFontMetrics,
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# RADIO KNOB WIDGET (oldschool rotary control)
|
|
# ──────────────────────────────────────────────
|
|
|
|
class RadioKnob(QWidget):
|
|
"""
|
|
A retro radio-style rotary knob widget.
|
|
Supports arc range, tick marks, and a glowing indicator dot.
|
|
|
|
Rotation: 7 o'clock (min) to 5 o'clock (max) = 300 degrees.
|
|
"""
|
|
valueChanged = Signal(float)
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
min_val: float = 0.0,
|
|
max_val: float = 100.0,
|
|
default_val: float = 50.0,
|
|
label: str = "",
|
|
unit: str = "",
|
|
color: tuple = (42, 130, 218),
|
|
num_ticks: int = 17,
|
|
tick_labels: list[str] | None = None,
|
|
snap_ticks: bool = False,
|
|
compact: bool = False,
|
|
):
|
|
super().__init__(parent)
|
|
self.min_val = min_val
|
|
self.max_val = max_val
|
|
self._value = default_val
|
|
self.label = label
|
|
self.unit = unit
|
|
self.color = QColor(*color)
|
|
self.num_ticks = num_ticks
|
|
self.tick_labels = tick_labels
|
|
self.snap_ticks = snap_ticks
|
|
self._dragging = False
|
|
self.compact = compact
|
|
|
|
# Arc geometry: 300-degree sweep, centered at 12 o'clock
|
|
self._arc_start = 210.0 # degrees (7 o'clock)
|
|
self._arc_span = -300.0 # negative = clockwise
|
|
|
|
# Scaling factor for compact mode (~70% of full size)
|
|
s = 0.70 if compact else 1.0
|
|
self._s = s
|
|
self.setFixedSize(int(180 * s), int(210 * s))
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
# --- Public API ---
|
|
|
|
def value(self) -> float:
|
|
return self._value
|
|
|
|
def setValue(self, v: float):
|
|
v = max(self.min_val, min(self.max_val, v))
|
|
if self.snap_ticks:
|
|
v = self._snap(v)
|
|
if v != self._value:
|
|
self._value = v
|
|
self.update()
|
|
self.valueChanged.emit(v)
|
|
|
|
def intValue(self) -> int:
|
|
return int(round(self._value))
|
|
|
|
def _snap(self, v: float) -> float:
|
|
"""Snap to nearest tick."""
|
|
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
|
|
return round((v - self.min_val) / step) * step + self.min_val
|
|
|
|
def _val_to_angle(self, v: float) -> float:
|
|
"""Map value to angle in degrees (matching the conical gradient)."""
|
|
ratio = (v - self.min_val) / (self.max_val - self.min_val) if self.max_val != self.min_val else 0
|
|
return self._arc_start + ratio * self._arc_span # goes from 210 -> -90
|
|
|
|
def _angle_to_val(self, angle_deg: float) -> float:
|
|
"""Map angle back to value."""
|
|
# Normalize angle relative to arc start
|
|
ratio = (angle_deg - self._arc_start) / self._arc_span
|
|
ratio = max(0.0, min(1.0, ratio))
|
|
v = self.min_val + ratio * (self.max_val - self.min_val)
|
|
if self.snap_ticks:
|
|
v = self._snap(v)
|
|
return v
|
|
|
|
# --- Painting ---
|
|
|
|
def paintEvent(self, event):
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
w, h = self.width(), self.height()
|
|
s = self._s # scale factor (0.7 for compact, 1.0 for full)
|
|
|
|
cx = w / 2
|
|
cy = h / 2 - 4 * s
|
|
outer_r = 70 * s
|
|
knob_r = 40 * s
|
|
arc_w = max(1, int(8 * s))
|
|
tick_w = max(1, 1.5 * s)
|
|
bezel_pad = 6 * s
|
|
|
|
# --- Outer bezel ring ---
|
|
bezel_grad = QRadialGradient(cx, cy, outer_r + bezel_pad)
|
|
bezel_grad.setColorAt(0.85, QColor(48, 48, 52))
|
|
bezel_grad.setColorAt(1.0, QColor(26, 26, 30))
|
|
p.setBrush(QBrush(bezel_grad))
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.drawEllipse(QPointF(cx, cy), outer_r + bezel_pad, outer_r + bezel_pad)
|
|
|
|
# --- Inactive arc (dark track) ---
|
|
p.setPen(QPen(QColor(50, 50, 56), arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
|
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
|
|
int(self._arc_start * 16), int(self._arc_span * 16))
|
|
|
|
# --- Active arc (colored fill up to current value) ---
|
|
val_angle = self._val_to_angle(self._value)
|
|
active_span = val_angle - self._arc_start
|
|
if abs(active_span) > 0.5:
|
|
arc_color = QColor(self.color)
|
|
p.setPen(QPen(arc_color, arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
|
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
|
|
int(self._arc_start * 16), int(active_span * 16))
|
|
|
|
# --- Tick marks ---
|
|
for i in range(self.num_ticks):
|
|
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
|
|
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
|
|
tick_rad = tick_angle * math.pi / 180.0
|
|
ox = cx + (outer_r + 12 * s) * (-1) * math.sin(tick_rad)
|
|
oy = cy + (outer_r + 12 * s) * (-1) * (-math.cos(tick_rad))
|
|
ix_ = cx + (outer_r + 3 * s) * (-1) * math.sin(tick_rad)
|
|
iy_ = cy + (outer_r + 3 * s) * (-1) * (-math.cos(tick_rad))
|
|
p.setPen(QPen(QColor(130, 130, 130), tick_w))
|
|
p.drawLine(QPointF(ix_, iy_), QPointF(ox, oy))
|
|
|
|
# Tick labels (if provided)
|
|
if self.tick_labels:
|
|
p.setFont(QFont("Sans", max(5, int(7 * s))))
|
|
p.setPen(QColor(160, 160, 160))
|
|
step = max(1, self.num_ticks // len(self.tick_labels))
|
|
label_idx = 0
|
|
for i in range(0, self.num_ticks, step):
|
|
if label_idx >= len(self.tick_labels):
|
|
break
|
|
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
|
|
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
|
|
tick_rad = tick_angle * math.pi / 180.0
|
|
lx = cx + (outer_r + 24 * s) * (-1) * math.sin(tick_rad)
|
|
ly = cy + (outer_r + 24 * s) * (-1) * (-math.cos(tick_rad))
|
|
txt = self.tick_labels[label_idx]
|
|
fm = QFontMetrics(p.font())
|
|
tw = fm.horizontalAdvance(txt)
|
|
p.drawText(QPointF(lx - tw / 2, ly + 2 * s), txt)
|
|
label_idx += 1
|
|
|
|
# --- Knob body (dark brushed aluminum) ---
|
|
knob_grad = QRadialGradient(cx - 6 * s, cy - 6 * s, knob_r * 1.3)
|
|
knob_grad.setColorAt(0.0, QColor(72, 72, 78))
|
|
knob_grad.setColorAt(0.5, QColor(50, 50, 55))
|
|
knob_grad.setColorAt(1.0, QColor(34, 34, 38))
|
|
p.setBrush(QBrush(knob_grad))
|
|
p.setPen(QPen(QColor(26, 26, 30), max(1, 1.5 * s)))
|
|
p.drawEllipse(QPointF(cx, cy), knob_r, knob_r)
|
|
|
|
# --- Inner shadow ring ---
|
|
inner_shadow = QRadialGradient(cx, cy, knob_r - 2)
|
|
inner_shadow.setColorAt(0.85, QColor(0, 0, 0, 0))
|
|
inner_shadow.setColorAt(1.0, QColor(0, 0, 0, 60))
|
|
p.setBrush(QBrush(inner_shadow))
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.drawEllipse(QPointF(cx, cy), knob_r - 1, knob_r - 1)
|
|
|
|
# --- Indicator line (pointer) ---
|
|
ptr_angle = self._val_to_angle(self._value)
|
|
ptr_rad = ptr_angle * 3.14159265 / 180.0
|
|
ptr_len = knob_r - 8 * s
|
|
px = cx + ptr_len * (-1) * math.sin(ptr_rad)
|
|
py = cy + ptr_len * (-1) * (-math.cos(ptr_rad))
|
|
p.setPen(QPen(QColor(255, 255, 255, 220), max(1, 2.5 * s),
|
|
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
|
p.drawLine(QPointF(cx, cy), QPointF(px, py))
|
|
|
|
# --- Center cap dot ---
|
|
cap_r = max(2, 5 * s)
|
|
cap_grad = QRadialGradient(cx, cy, cap_r)
|
|
cap_grad.setColorAt(0.0, QColor(60, 60, 65))
|
|
cap_grad.setColorAt(1.0, QColor(30, 30, 34))
|
|
p.setBrush(QBrush(cap_grad))
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.drawEllipse(QPointF(cx, cy), cap_r, cap_r)
|
|
|
|
# --- Glow dot at arc tip ---
|
|
glow_r = max(3, 10 * s)
|
|
glow_x = cx + outer_r * (-1) * math.sin(ptr_rad)
|
|
glow_y = cy + outer_r * (-1) * (-math.cos(ptr_rad))
|
|
glow = QRadialGradient(glow_x, glow_y, glow_r * 1.2)
|
|
glow.setColorAt(0.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 200))
|
|
glow.setColorAt(1.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 0))
|
|
p.setBrush(QBrush(glow))
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.drawEllipse(QPointF(glow_x, glow_y), glow_r, glow_r)
|
|
|
|
p.end()
|
|
|
|
# --- Label + value text below knob ---
|
|
p2 = QPainter(self)
|
|
p2.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
|
|
# Value line (e.g. "32.0 CRF")
|
|
val_font_sz = max(6, int(13 * s))
|
|
p2.setFont(QFont("Consolas", val_font_sz, QFont.Weight.Bold))
|
|
val_color = QColor(self.color.red(), self.color.green(), self.color.blue())
|
|
p2.setPen(val_color)
|
|
val_text = f"{self._value:.0f} {self.unit}" if self.unit else f"{self._value:.0f}"
|
|
p2.drawText(QRectF(0, h - 38 * s, w, 20 * s), Qt.AlignmentFlag.AlignCenter, val_text)
|
|
|
|
# Label line (e.g. "Quality")
|
|
lbl_font_sz = max(5, int(9 * s))
|
|
p2.setFont(QFont("Consolas", lbl_font_sz, QFont.Weight.Bold))
|
|
p2.setPen(QColor(160, 160, 160))
|
|
p2.drawText(QRectF(0, h - 18 * s, w, 16 * s), Qt.AlignmentFlag.AlignCenter, self.label)
|
|
p2.end()
|
|
|
|
# --- Input handling ---
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self._dragging = True
|
|
self._update_from_mouse(event.position())
|
|
|
|
def mouseMoveEvent(self, event):
|
|
if self._dragging:
|
|
self._update_from_mouse(event.position())
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self._dragging = False
|
|
|
|
def wheelEvent(self, event):
|
|
delta = event.angleDelta().y()
|
|
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
|
|
if delta > 0:
|
|
self.setValue(self._value + step)
|
|
elif delta < 0:
|
|
self.setValue(self._value - step)
|
|
|
|
def _update_from_mouse(self, pos: QPointF):
|
|
cx = self.width() / 2
|
|
cy = self.height() / 2 - 4 * self._s
|
|
dx = pos.x() - cx
|
|
dy = pos.y() - cy
|
|
angle = math.degrees(math.atan2(dx, -dy)) # 0=north, CW positive
|
|
if angle < 0:
|
|
angle += 360
|
|
# Clamp to arc range: 210..510 (which is 210..360 and 0..150)
|
|
# Our arc: 210 degrees to -90 (=270) degrees clockwise
|
|
if angle < 210 and angle > 150:
|
|
# Dead zone at bottom (between 150 and 210)
|
|
# Push to nearest end
|
|
angle = 210 if abs(angle - 210) < abs(angle - 510) else 510
|
|
if angle > 360:
|
|
angle -= 360 # normalize back to 0..360
|
|
self.setValue(self._angle_to_val(angle))
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# CONFIG-DRIVEN PROFILES (replaces all if/else chains)
|
|
# ──────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class VideoCodecProfile:
|
|
label: str # Display name in combo box
|
|
av1an_encoder: str # Encoder name passed to --encoder
|
|
ffmpeg_encoder: str # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1")
|
|
container: str # Default container extension (mkv or webm)
|
|
crf_range: tuple[int, int] # (min, max) valid CRF values
|
|
default_crf: int
|
|
params_fn: callable # (crf: int, preset: int) -> str (av1an format)
|
|
ffmpeg_vargs_fn: callable # (crf: int, preset: int) -> list[str] (ffmpeg -c:v args)
|
|
presets: list[str] # Human-readable preset labels
|
|
preset_map: dict[str, int] # label -> internal preset value
|
|
|
|
@dataclass
|
|
class AudioProfile:
|
|
label: str
|
|
params: list[str] # Tokens passed to --audio-params (joined with space)
|
|
|
|
@dataclass
|
|
class ContainerProfile:
|
|
label: str
|
|
ext: str # e.g. "mkv", "webm"
|
|
|
|
|
|
def _av1_params(crf: int, preset: int) -> str:
|
|
"""SVT-AV1 encoder params for av1an's --video-params.
|
|
|
|
av1an splits the --video-params value by whitespace (``split_whitespace()``)
|
|
and passes each resulting token as a separate argument to SvtAv1EncApp.
|
|
Therefore the string must contain space-separated ``--flag value`` pairs
|
|
that SvtAv1EncApp can parse natively.
|
|
|
|
Colon-separated ``key=value:key=value`` does NOT work because there are
|
|
no whitespace boundaries for av1an to split on — the entire string reaches
|
|
SvtAv1EncApp as one opaque argument, producing:
|
|
``Maybe missing spacing between tokens``.
|
|
"""
|
|
return f"--preset {preset} --crf {crf} --keyint 240"
|
|
|
|
|
|
def _vp9_params(crf: int, preset: int) -> str:
|
|
"""VP9 encoder params for av1an's --video-params.
|
|
|
|
av1an splits by whitespace, so we use space-separated --flag=value tokens
|
|
that vpxenc parses natively.
|
|
"""
|
|
cpu_used = max(0, 8 - preset)
|
|
return f"--end-usage=q --cq-level={crf} --cpu-used={cpu_used}"
|
|
|
|
|
|
def _x265_params(crf: int, preset: int) -> str:
|
|
"""x265 encoder params for av1an's --video-params.
|
|
|
|
av1an splits by whitespace, so we use space-separated --flag value tokens
|
|
that x265 parses natively.
|
|
"""
|
|
return f"--crf {crf} --preset {preset}"
|
|
|
|
def _svtav1_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
|
"""FFmpeg args for SVT-AV1 (maps av1an preset=0..8 → svtav1 -preset 0..13)."""
|
|
# av1an preset range 0-8 maps to SVT-AV1 preset range 0-13
|
|
# Scale roughly: 8→0, 6→4, 4→7, 2→10
|
|
svt_preset = max(0, min(13, round((8 - preset) * 13 / 8)))
|
|
return ["-c:v", "libsvtav1", "-preset", str(svt_preset), "-crf", str(crf),
|
|
"-pix_fmt", "yuv420p10le", "-g", "240"]
|
|
|
|
def _vp9_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
|
"""FFmpeg args for VP9 (maps av1an cpu-used 0..8 → -cpu-used 0..8)."""
|
|
cpu_used = max(0, min(8, preset))
|
|
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0",
|
|
"-cpu-used", str(cpu_used), "-pix_fmt", "yuv420p", "-g", "240",
|
|
"-row-mt", "1", "-tiles", "2x2"]
|
|
|
|
def _x265_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
|
"""FFmpeg args for x265 (maps av1an preset 5..10 → x265 -preset)."""
|
|
# av1an x265 preset range 5-10 maps to x265 preset names
|
|
preset_names = {5: "slow", 7: "medium", 9: "fast", 10: "faster"}
|
|
p = preset_names.get(preset, "medium")
|
|
return ["-c:v", "libx265", "-preset", p, "-crf", str(crf),
|
|
"-pix_fmt", "yuv420p10le", "-g", "240"]
|
|
|
|
|
|
VIDEO_CODECS: list[VideoCodecProfile] = [
|
|
VideoCodecProfile(
|
|
label="AV1 (SVT-AV1)",
|
|
av1an_encoder="svt_av1",
|
|
ffmpeg_encoder="libsvtav1",
|
|
container="mkv",
|
|
crf_range=(18, 52),
|
|
default_crf=32,
|
|
params_fn=_av1_params,
|
|
ffmpeg_vargs_fn=_svtav1_ffmpeg_args,
|
|
presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"],
|
|
preset_map={"Slow (8)": 8, "Medium (6)": 6, "Fast (4)": 4, "Faster (2)": 2},
|
|
),
|
|
VideoCodecProfile(
|
|
label="VP9",
|
|
av1an_encoder="vpx",
|
|
ffmpeg_encoder="libvpx-vp9",
|
|
container="webm",
|
|
crf_range=(18, 52),
|
|
default_crf=32,
|
|
params_fn=_vp9_params,
|
|
ffmpeg_vargs_fn=_vp9_ffmpeg_args,
|
|
presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"],
|
|
preset_map={"Slow (0)": 0, "Medium (2)": 2, "Fast (4)": 4, "Faster (6)": 6},
|
|
),
|
|
VideoCodecProfile(
|
|
label="x265 (HEVC)",
|
|
av1an_encoder="x265",
|
|
ffmpeg_encoder="libx265",
|
|
container="mkv",
|
|
crf_range=(18, 40),
|
|
default_crf=28,
|
|
params_fn=_x265_params,
|
|
ffmpeg_vargs_fn=_x265_ffmpeg_args,
|
|
presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"],
|
|
preset_map={"Slow (5)": 5, "Medium (7)": 7, "Fast (9)": 9, "Faster (10)": 10},
|
|
),
|
|
]
|
|
|
|
AUDIO_PROFILES: list[AudioProfile] = [
|
|
AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"]),
|
|
AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"]),
|
|
AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"]),
|
|
AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"]),
|
|
AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"]),
|
|
AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"]),
|
|
]
|
|
|
|
CONTAINER_PROFILES: list[ContainerProfile] = [
|
|
ContainerProfile(label="MKV (Matroska)", ext="mkv"),
|
|
ContainerProfile(label="WebM", ext="webm"),
|
|
]
|
|
|
|
|
|
# ── Resolution presets ──
|
|
# Aspect ratios:
|
|
# Standard 16:9 -> w/h = 1.778
|
|
# Wide 21:9 -> w/h = 2.333
|
|
# Ultrawide 32:9 -> w/h = 3.556
|
|
|
|
@dataclass
|
|
class ResolutionProfile:
|
|
label: str # Display label in dropdown, e.g. "1080p Wide (2560x1080)"
|
|
category: str # Grouping key: "standard", "wide", "ultrawide", "original"
|
|
width: Optional[int] # None for "original" (no scaling)
|
|
height: Optional[int] # None for "original"
|
|
aspect_label: str # "16:9", "21:9", "32:9", "Source"
|
|
|
|
|
|
RESOLUTION_PRESETS: list[ResolutionProfile] = [
|
|
# ── Original (no scaling) ──
|
|
ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"),
|
|
|
|
# ── Standard 16:9 ──
|
|
ResolutionProfile("480p ( 854x 480)", "standard", 854, 480, "16:9"),
|
|
ResolutionProfile("720p (1280x 720)", "standard", 1280, 720, "16:9"),
|
|
ResolutionProfile("1080p (1920x1080)", "standard", 1920, 1080, "16:9"),
|
|
ResolutionProfile("2K (2560x1440)", "standard", 2560, 1440, "16:9"),
|
|
ResolutionProfile("4K (3840x2160)", "standard", 3840, 2160, "16:9"),
|
|
|
|
# ── Wide 21:9 ──
|
|
ResolutionProfile("480p Wide ( 854x 366)", "wide", 854, 366, "21:9"),
|
|
ResolutionProfile("720p Wide (1280x 549)", "wide", 1280, 549, "21:9"),
|
|
ResolutionProfile("1080p Wide (2560x1080)", "wide", 2560, 1080, "21:9"),
|
|
ResolutionProfile("2K Wide (3440x1440)", "wide", 3440, 1440, "21:9"),
|
|
ResolutionProfile("4K Wide (5120x2160)", "wide", 5120, 2160, "21:9"),
|
|
|
|
# ── Ultrawide 32:9 ──
|
|
ResolutionProfile("480p UW (1706x 480)", "ultrawide", 1706, 480, "32:9"),
|
|
ResolutionProfile("1080p UW (3840x1080)", "ultrawide", 3840, 1080, "32:9"),
|
|
ResolutionProfile("2K UW (5120x1440)", "ultrawide", 5120, 1440, "32:9"),
|
|
ResolutionProfile("4K UW (7680x2160)", "ultrawide", 7680, 2160, "32:9"),
|
|
]
|
|
|
|
|
|
SUBTITLE_OPTIONS = [
|
|
("None", None),
|
|
("English", "eng"),
|
|
]
|
|
|
|
|
|
DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"}
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# CPU TOPOLOGY (physical cores, not hyperthreads)
|
|
# ──────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class CpuTopology:
|
|
physical_cores: int
|
|
logical_threads: int
|
|
threads_per_core: int
|
|
model_name: str
|
|
|
|
|
|
def _read_sysfs_cores() -> Optional[tuple[int, int]]:
|
|
"""
|
|
Read /sys/devices/system/cpu/cpu*/topology/ to count unique
|
|
(physical_package_id, core_id) pairs — i.e. physical cores.
|
|
Returns (physical_cores, logical_threads) or None.
|
|
"""
|
|
cpu_base = Path("/sys/devices/system/cpu")
|
|
if not cpu_base.exists():
|
|
return None
|
|
|
|
unique_cores: set[tuple[str, str]] = set()
|
|
logical = 0
|
|
for cpu_dir in sorted(cpu_base.glob("cpu[0-9]*")):
|
|
core_id_file = cpu_dir / "topology" / "core_id"
|
|
pkg_id_file = cpu_dir / "topology" / "physical_package_id"
|
|
if core_id_file.exists() and pkg_id_file.exists():
|
|
try:
|
|
pkg = pkg_id_file.read_text().strip()
|
|
core = core_id_file.read_text().strip()
|
|
unique_cores.add((pkg, core))
|
|
logical += 1
|
|
except Exception:
|
|
pass
|
|
if unique_cores and logical:
|
|
return (len(unique_cores), logical)
|
|
return None
|
|
|
|
|
|
def _read_lscpu_cores() -> Optional[tuple[int, int]]:
|
|
"""Fallback: parse lscpu -p=CORE,SOCKET for unique physical cores."""
|
|
if not shutil.which("lscpu"):
|
|
return None
|
|
try:
|
|
res = subprocess.run(
|
|
["lscpu", "-p=CORE,SOCKET"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
lines = [l.strip() for l in res.stdout.strip().splitlines() if l.strip() and not l.startswith("#")]
|
|
if lines:
|
|
unique = set(lines)
|
|
return (len(unique), len(lines))
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def detect_cpu_topology() -> CpuTopology:
|
|
"""
|
|
Detect physical CPU topology. Prefers /sys filesystem, falls back
|
|
to lscpu, then estimates from os.cpu_count().
|
|
"""
|
|
logical = os.cpu_count() or 1
|
|
physical = logical
|
|
|
|
# Try /sys first (most reliable)
|
|
result = _read_sysfs_cores()
|
|
if result:
|
|
physical, logical = result
|
|
else:
|
|
# Try lscpu
|
|
result = _read_lscpu_cores()
|
|
if result:
|
|
physical, logical = result
|
|
else:
|
|
# Estimate: assume 2 threads/core if cpu_count > 2 and is even
|
|
if logical > 2 and logical % 2 == 0:
|
|
physical = logical // 2
|
|
|
|
tpc = logical // physical if physical > 0 else 1
|
|
|
|
# Try to get CPU model name
|
|
model = "Unknown CPU"
|
|
model_file = Path("/proc/cpuinfo")
|
|
if model_file.exists():
|
|
for line in model_file.read_text(errors="replace").splitlines():
|
|
if line.startswith("model name"):
|
|
model = line.split(":", 1)[1].strip()
|
|
break
|
|
else:
|
|
# Non-x86 / non-Linux: try lscpu
|
|
if shutil.which("lscpu"):
|
|
try:
|
|
res = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5)
|
|
for line in res.stdout.splitlines():
|
|
if "Model name" in line:
|
|
model = line.split(":", 1)[1].strip()
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
return CpuTopology(
|
|
physical_cores=physical,
|
|
logical_threads=logical,
|
|
threads_per_core=tpc,
|
|
model_name=model,
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# DISTRO DETECTION & PROFILES
|
|
# ──────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class DistroProfile:
|
|
family: str # Canonical family: arch, debian, redhat, suse, nixos, unknown
|
|
name: str # Pretty name: "Arch Linux", "Fedora 40", etc.
|
|
version_id: str # e.g. "40", "15.6", "24.05"
|
|
pkg_manager: str # e.g. "pacman", "dnf", "zypper", "apt", "nix"
|
|
install_cmd_template: str # e.g. "sudo pacman -S {packages}"
|
|
binary_extra_paths: list[str] # Distro-specific dirs to search for binaries
|
|
av1an_known_encoder_names: list[str] # Names this distro's av1an build may accept
|
|
ffmpeg_pkg: str # Package name providing ffmpeg
|
|
av1an_pkg: str # Package name providing av1an
|
|
notes: str # Distro-specific quirks worth showing the user
|
|
# Runtime dependency packages (key = generic name, value = distro package name)
|
|
dep_pkgs: dict[str, str] = field(default_factory=dict)
|
|
# Binaries that av1an invokes directly (not via ffmpeg)
|
|
encoder_binaries: dict[str, list[str]] = field(default_factory=dict)
|
|
# VSScript package name — on most distros this is bundled into 'vapoursynth',
|
|
# but Debian/Ubuntu split it into a separate -script-dev package.
|
|
# If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check.
|
|
vsscript_pkg: str = ""
|
|
|
|
|
|
def _read_os_release() -> dict[str, str]:
|
|
"""Parse /etc/os-release into a dict. Falls back to empty dict."""
|
|
os_release = Path("/etc/os-release")
|
|
fallback = Path("/usr/lib/os-release")
|
|
target = os_release if os_release.exists() else fallback
|
|
if not target.exists():
|
|
return {}
|
|
data = {}
|
|
for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if "=" in line and not line.startswith("#"):
|
|
key, _, val = line.partition("=")
|
|
data[key.strip()] = val.strip().strip('"')
|
|
return data
|
|
|
|
|
|
def detect_distro() -> DistroProfile:
|
|
"""
|
|
Detect the running Linux distribution via /etc/os-release.
|
|
Returns a DistroProfile with distro-specific package manager,
|
|
install commands, binary search paths, and known quirks.
|
|
"""
|
|
info = _read_os_release()
|
|
id_like = info.get("ID_LIKE", "").lower().split()
|
|
distro_id = info.get("ID", "").lower()
|
|
pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system()))
|
|
version = info.get("VERSION_ID", "?")
|
|
|
|
# --- Classify into families ---
|
|
if distro_id in ("arch", "manjaro", "endeavouros", "garuda", "cachyos") or "arch" in id_like:
|
|
return DistroProfile(
|
|
family="arch",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="pacman",
|
|
install_cmd_template="sudo pacman -S {packages}",
|
|
binary_extra_paths=[
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"~/.local/bin", # AUR user installs
|
|
"~/.cargo/bin", # Rust-based av1an installs
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "libopus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes=(
|
|
"Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. "
|
|
"SVT-AV1 encoder name is typically 'svt_av1'. "
|
|
"Cargo-installed av1an may live in ~/.cargo/bin."
|
|
),
|
|
)
|
|
|
|
if distro_id in ("fedora",) or "fedora" in id_like:
|
|
return DistroProfile(
|
|
family="redhat",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="dnf",
|
|
install_cmd_template="sudo dnf install {packages}",
|
|
binary_extra_paths=[
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"~/.cargo/bin",
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes=(
|
|
"Fedora: av1an may require COPR enablement first: "
|
|
"sudo dnf copr enable sergiomb/av1an (or build from source). "
|
|
"SVT-AV1 is in the main repos as 'svt-av1'. "
|
|
"Ensure RPM Fusion is enabled for full codec support."
|
|
),
|
|
)
|
|
|
|
if distro_id in ("rhel", "centos", "rocky", "almalinux", "ol") or "rhel" in id_like or "centos" in id_like:
|
|
return DistroProfile(
|
|
family="redhat",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="dnf" if Path("/usr/bin/dnf").exists() else "yum",
|
|
install_cmd_template=(
|
|
"sudo dnf install {packages}"
|
|
if Path("/usr/bin/dnf").exists()
|
|
else "sudo yum install {packages}"
|
|
),
|
|
binary_extra_paths=[
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"~/.cargo/bin",
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes=(
|
|
"RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. "
|
|
"Options: (1) cargo install av1an, (2) build from GitHub source, "
|
|
"(3) use pre-built binary from releases. "
|
|
"Enable EPEL + RPM Fusion for FFmpeg codec support."
|
|
),
|
|
)
|
|
|
|
if distro_id in ("opensuse-leap", "opensuse-tumbleweed", "sles") or "suse" in id_like:
|
|
return DistroProfile(
|
|
family="suse",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="zypper",
|
|
install_cmd_template="sudo zypper install {packages}",
|
|
binary_extra_paths=[
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"~/.cargo/bin",
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "libopus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes=(
|
|
"openSUSE: av1an may be available via OBS (Open Build Service). "
|
|
"Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. "
|
|
"Packman repo provides FFmpeg with full codec support."
|
|
),
|
|
)
|
|
|
|
if "nixos" in distro_id or "nixos" in " ".join(id_like):
|
|
return DistroProfile(
|
|
family="nixos",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="nix",
|
|
install_cmd_template="nix-shell -p {packages}",
|
|
binary_extra_paths=[
|
|
"/run/current-system/sw/bin",
|
|
"~/.nix-profile/bin",
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svt-av1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx",
|
|
"opus": "opus",
|
|
"vorbis": "libvorbis",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes=(
|
|
"NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. "
|
|
"Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. "
|
|
"av1an CLI flags may differ from other distros depending on the nixpkgs channel."
|
|
),
|
|
)
|
|
|
|
# Debian / Ubuntu / Mint / Pop!_OS / etc.
|
|
if distro_id in ("debian", "ubuntu", "linuxmint", "pop") or "debian" in id_like:
|
|
return DistroProfile(
|
|
family="debian",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="apt",
|
|
install_cmd_template="sudo apt install {packages}",
|
|
binary_extra_paths=[
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
"~/.cargo/bin",
|
|
],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={
|
|
"vapoursynth": "vapoursynth",
|
|
"svt-av1": "svtav1",
|
|
"x265": "x265",
|
|
"vpx": "libvpx-tools",
|
|
"opus": "libopus-dev",
|
|
"vorbis": "libvorbis-dev",
|
|
"flac": "flac",
|
|
},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
vsscript_pkg="libvapoursynth-script-dev",
|
|
notes=(
|
|
"Debian/Ubuntu: av1an is in the repos (apt install av1an). "
|
|
"Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. "
|
|
"VSScript is in a separate package: libvapoursynth-script-dev. "
|
|
"For newer builds, consider cargo install av1an."
|
|
),
|
|
)
|
|
|
|
# Fallback: unknown distro
|
|
return DistroProfile(
|
|
family="unknown",
|
|
name=pretty,
|
|
version_id=version,
|
|
pkg_manager="unknown",
|
|
install_cmd_template="# Unknown distro — install ffmpeg and av1an manually",
|
|
binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"],
|
|
av1an_known_encoder_names=["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"],
|
|
ffmpeg_pkg="ffmpeg",
|
|
av1an_pkg="av1an",
|
|
dep_pkgs={},
|
|
encoder_binaries={
|
|
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
|
"vpx": ["vpxenc"],
|
|
"x265": ["x265"],
|
|
},
|
|
notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.",
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# ENVIRONMENT PROBE (distro-aware, extended)
|
|
# ──────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class EnvProbe:
|
|
distro: DistroProfile = field(default_factory=lambda: DistroProfile(
|
|
family="unknown", name="Unknown", version_id="?",
|
|
pkg_manager="unknown", install_cmd_template="",
|
|
binary_extra_paths=[], av1an_known_encoder_names=[],
|
|
ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes=""
|
|
))
|
|
av1an_path: Optional[str] = None
|
|
ffmpeg_path: Optional[str] = None
|
|
ffprobe_path: Optional[str] = None
|
|
av1an_flags: dict = field(default_factory=dict)
|
|
av1an_version: Optional[str] = None
|
|
ffmpeg_version: Optional[str] = None
|
|
ffmpeg_libs: dict = field(default_factory=dict) # lib name -> bool (available)
|
|
runtime_deps: dict = field(default_factory=dict) # dep name -> bool (present)
|
|
missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install
|
|
vs_version: Optional[str] = None # VapourSynth version string (for diagnostics)
|
|
vs_script_lib: Optional[str] = None # path to libvapoursynth-script.so that passed
|
|
cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown"))
|
|
errors: list[str] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
return (self.av1an_path is not None and self.ffmpeg_path is not None
|
|
and not self.errors and not self.missing_dep_pkgs)
|
|
|
|
@property
|
|
def dep_install_hint(self) -> str:
|
|
"""Generate a distro-specific install command for missing runtime deps."""
|
|
if not self.missing_dep_pkgs or self.distro.family == "unknown":
|
|
return ""
|
|
return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs))
|
|
|
|
@property
|
|
def install_hint(self) -> str:
|
|
"""Generate a distro-specific install command for missing packages."""
|
|
missing = []
|
|
if self.av1an_path is None:
|
|
missing.append(self.distro.av1an_pkg)
|
|
if self.ffmpeg_path is None:
|
|
missing.append(self.distro.ffmpeg_pkg)
|
|
if not missing:
|
|
return ""
|
|
return self.distro.install_cmd_template.format(packages=" ".join(missing))
|
|
|
|
|
|
def _find_binary(name: str, distro: DistroProfile) -> Optional[str]:
|
|
"""
|
|
Search for a binary in: (1) standard PATH via shutil.which, then
|
|
(2) distro-specific extra paths (expanded ~). Returns first match.
|
|
"""
|
|
# Standard PATH search
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
|
|
# Distro-specific extra paths
|
|
for raw_path in distro.binary_extra_paths:
|
|
expanded = Path(raw_path).expanduser()
|
|
candidate = expanded / name
|
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
return str(candidate)
|
|
|
|
return None
|
|
|
|
|
|
def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]:
|
|
"""Check which encoder/decoder libraries ffmpeg was compiled with.
|
|
Runs ffmpeg -encoders ONCE and greps for all known encoder names.
|
|
Each entry: (key, [search_strings]) — any match = available.
|
|
"""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffmpeg_bin, "-encoders"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
output = res.stdout
|
|
except Exception:
|
|
output = ""
|
|
|
|
checks = [
|
|
("libsvtav1", ["libsvt_av1", "svt_av1"]),
|
|
("libaom", ["libaom_av1", "aom_av1"]),
|
|
("libvpx", ["libvpx-vp9", "libvpx_vp9", "vpx_vp9"]),
|
|
("libx265", ["libx265"]),
|
|
("libopus", ["libopus"]),
|
|
("libvorbis", ["libvorbis"]),
|
|
("flac", ["flac"]),
|
|
]
|
|
libs = {}
|
|
for lib_name, search_strings in checks:
|
|
libs[lib_name] = any(s in output for s in search_strings)
|
|
return libs
|
|
|
|
|
|
def _probe_av1an_version(av1an_bin: str) -> Optional[str]:
|
|
"""Extract av1an version string."""
|
|
try:
|
|
# Try --version first, fall back to parsing --help header
|
|
for args in (["--version"], ["--help"]):
|
|
res = subprocess.run(
|
|
[av1an_bin] + args,
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
output = res.stdout or res.stderr
|
|
match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1)
|
|
if res.stdout.strip(): # If --version produced output but no version match
|
|
return res.stdout.strip().splitlines()[0][:60]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _probe_ffmpeg_version(ffmpeg_bin: str) -> Optional[str]:
|
|
"""Extract ffmpeg version string."""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffmpeg_bin, "-version"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
first_line = res.stdout.splitlines()[0] if res.stdout else ""
|
|
match = re.search(r"ffmpeg version (\S+)", first_line)
|
|
return match.group(1) if match else first_line[:60]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]:
|
|
"""Check runtime dependencies that av1an needs to function.
|
|
Returns (deps_dict, missing_pkg_names).
|
|
|
|
Checks:
|
|
- VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen
|
|
to get the VSScript API — without this it panics with
|
|
'Failed to get VSScript API')
|
|
- Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc)
|
|
"""
|
|
deps: dict[str, bool] = {}
|
|
missing_pkgs: list[str] = []
|
|
|
|
# --- VapourSynth + VSScript (critical: av1an will panic without it) ---
|
|
# av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls
|
|
# vsscript_init() / vsscript_createScript() / etc. It does NOT use the
|
|
# Python vapoursynth module. The shared library and the VSScript API
|
|
# library can be packaged separately on some distros (e.g. Debian has
|
|
# libvapoursynth-script-dev). We must check what av1an actually loads.
|
|
#
|
|
# IMPORTANT: We do NOT call vsscript_init() in our probe. VSScript's init
|
|
# internally calls Py_Initialize(), which crashes/fails when Python is
|
|
# already running (our probe runs inside a Python subprocess). Instead,
|
|
# we verify the shared library exists AND can be dlopen'd (CDLL constructor
|
|
# resolves all .so dependencies). If it loads, it will work for av1an.
|
|
vs_ok = False
|
|
vs_detail = ""
|
|
vs_ver_str = ""
|
|
vs_lib_path = None
|
|
|
|
# --- Step 1: Direct filesystem check (most reliable) ---
|
|
# Check well-known install paths. Works even if ldconfig cache is stale.
|
|
_vs_script_search = [
|
|
"/usr/lib/libvapoursynth-script.so",
|
|
"/usr/lib/libvapoursynth_script.so",
|
|
"/usr/lib64/libvapoursynth-script.so",
|
|
"/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so",
|
|
"/usr/local/lib/libvapoursynth-script.so",
|
|
]
|
|
for p in _vs_script_search:
|
|
if Path(p).is_file():
|
|
vs_lib_path = p
|
|
break
|
|
|
|
# --- Step 2: Glob search on known lib dirs ---
|
|
if not vs_lib_path:
|
|
for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib",
|
|
"/usr/lib/x86_64-linux-gnu"):
|
|
d = Path(lib_dir)
|
|
if d.is_dir():
|
|
matches = list(d.glob("libvapoursynth-script.so*"))
|
|
# Prefer unversioned .so over .so.0 (dev symlink)
|
|
for m in sorted(matches, key=lambda p: p.name):
|
|
vs_lib_path = str(m)
|
|
break
|
|
if vs_lib_path:
|
|
break
|
|
|
|
# --- Step 3: ldconfig -p ---
|
|
if not vs_lib_path:
|
|
try:
|
|
res = subprocess.run(
|
|
["ldconfig", "-p"], capture_output=True, text=True, timeout=5,
|
|
)
|
|
for line in res.stdout.splitlines():
|
|
if "libvapoursynth-script" in line or "libvapoursynth_script" in line:
|
|
parts = line.split("=>")
|
|
if len(parts) >= 2:
|
|
vs_lib_path = parts[1].strip().split()[0]
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# --- Step 4: ctypes.util.find_library ---
|
|
if not vs_lib_path:
|
|
try:
|
|
for name in ("vapoursynth-script", "vapoursynth_script"):
|
|
found = ctypes.util.find_library(name)
|
|
if found:
|
|
vs_lib_path = found
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# --- Step 5: Distro-specific package file listing ---
|
|
if not vs_lib_path:
|
|
pkg_query = {
|
|
"arch": ["pacman", "-Ql", "vapoursynth"],
|
|
"debian": ["dpkg", "-L", "vapoursynth"],
|
|
"redhat": ["rpm", "-ql", "vapoursynth"],
|
|
"suse": ["rpm", "-ql", "vapoursynth"],
|
|
}
|
|
query_cmd = pkg_query.get(distro.family)
|
|
if query_cmd:
|
|
try:
|
|
res = subprocess.run(
|
|
query_cmd, capture_output=True, text=True, timeout=10,
|
|
)
|
|
for line in res.stdout.splitlines():
|
|
line = line.strip()
|
|
# Skip directory entries and grab .so files
|
|
if "libvapoursynth-script" in line and line.endswith(".so"):
|
|
vs_lib_path = line
|
|
break
|
|
if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path:
|
|
vs_lib_path = line # versioned .so as fallback
|
|
except Exception:
|
|
pass
|
|
|
|
# --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) ---
|
|
# We do NOT gate on dlopen success. The library's constructor may call
|
|
# Py_Initialize() which conflicts with our Python subprocess, causing a
|
|
# silent segfault. av1an loads this library in its own fresh Rust process
|
|
# where no Python is running — so it works there even if our probe crashes.
|
|
# We only use dlopen to produce an optional warning.
|
|
vs_dlopen_warning = ""
|
|
if vs_lib_path:
|
|
try:
|
|
_escaped = vs_lib_path.replace("'", "\\'")
|
|
probe_code = (
|
|
"import ctypes; "
|
|
f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') "
|
|
f"except OSError as e: print(f'LOAD_FAIL|{{e}}') "
|
|
f"except Exception as e: print(f'LOAD_OTHER|{{e}}') "
|
|
)
|
|
res = subprocess.run(
|
|
[sys.executable, "-c", probe_code],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
out = res.stdout.strip()
|
|
if out == "LOAD_OK":
|
|
vs_ok = True
|
|
elif out:
|
|
vs_dlopen_warning = f"dlopen test failed: {out}"
|
|
vs_ok = True # file exists — let av1an try in its own process
|
|
else:
|
|
# subprocess produced no output — likely segfault in library
|
|
# constructor (Py_Initialize conflict). File still exists.
|
|
vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)"
|
|
vs_ok = True
|
|
except subprocess.TimeoutExpired:
|
|
vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)"
|
|
vs_ok = True
|
|
except Exception as e:
|
|
vs_dlopen_warning = f"dlopen probe error: {e}"
|
|
vs_ok = True
|
|
|
|
# Final gate: library file was found on disk
|
|
if vs_lib_path and not vs_ok:
|
|
vs_ok = True # file found on disk is sufficient
|
|
|
|
if vs_ok:
|
|
vs_detail = vs_lib_path or "found"
|
|
# Try to get VapourSynth version from the core lib for diagnostics
|
|
try:
|
|
ver_probe = (
|
|
"import ctypes, ctypes.util; "
|
|
"_lib = ctypes.util.find_library('vapoursynth'); "
|
|
"if not _lib: "
|
|
" import subprocess as _sp; "
|
|
" _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); "
|
|
" _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() "
|
|
" if 'libvapoursynth.so.' in l and 'script' not in l]; "
|
|
" _lib = _m[0] if _m else None; "
|
|
"if _lib: "
|
|
" try: "
|
|
" _h = ctypes.CDLL(_lib); "
|
|
" _fn = _h.vapoursynth_version; "
|
|
" _fn.restype = ctypes.c_int; "
|
|
" print(_fn()) "
|
|
" except: pass "
|
|
)
|
|
res = subprocess.run(
|
|
[sys.executable, "-c", ver_probe],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
ver_out = res.stdout.strip()
|
|
if ver_out and ver_out.isdigit() and int(ver_out) > 0:
|
|
vs_ver_str = f"R{ver_out}"
|
|
except Exception:
|
|
pass
|
|
else:
|
|
if not vs_detail:
|
|
vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)"
|
|
|
|
deps["vapoursynth"] = vs_ok
|
|
if not vs_ok:
|
|
# Determine which package(s) to suggest.
|
|
# Most distros bundle VSScript into the main 'vapoursynth' package,
|
|
# but some split it (Debian/Ubuntu: libvapoursynth-script-dev).
|
|
# Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs.
|
|
if distro.vsscript_pkg:
|
|
missing_pkgs.append(distro.vsscript_pkg)
|
|
elif "vapoursynth" in distro.dep_pkgs:
|
|
missing_pkgs.append(distro.dep_pkgs["vapoursynth"])
|
|
deps["vs_detail"] = False # extra key for the diagnostic message
|
|
else:
|
|
deps["vs_detail"] = True
|
|
|
|
# --- Encoder binaries (av1an invokes these directly, not via ffmpeg) ---
|
|
for enc_key, binary_names in distro.encoder_binaries.items():
|
|
found = False
|
|
for bin_name in binary_names:
|
|
if _find_binary(bin_name, distro) is not None:
|
|
found = True
|
|
break
|
|
deps[enc_key] = found
|
|
if not found:
|
|
# Map encoder key to dep_pkgs key
|
|
dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"}
|
|
dep_key = dep_key_map.get(enc_key, enc_key)
|
|
if dep_key in distro.dep_pkgs:
|
|
pkg_name = distro.dep_pkgs[dep_key]
|
|
if pkg_name not in missing_pkgs:
|
|
missing_pkgs.append(pkg_name)
|
|
|
|
# --- ffprobe (needed for input file validation) ---
|
|
# Already checked in probe_environment() for the main binary, but let's
|
|
# make sure the dep dict reflects it for consistency.
|
|
# (ffprobe_path is set separately in probe_environment)
|
|
|
|
return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning
|
|
|
|
|
|
def probe_environment() -> EnvProbe:
|
|
"""
|
|
Distro-aware binary detection + av1an flag compatibility probe +
|
|
ffmpeg library availability check.
|
|
"""
|
|
distro = detect_distro()
|
|
result = EnvProbe(distro=distro)
|
|
result.cpu = detect_cpu_topology()
|
|
cpu = result.cpu
|
|
|
|
result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})")
|
|
result.warnings.append(
|
|
f"CPU: {cpu.model_name} — {cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical"
|
|
)
|
|
|
|
# --- Binary detection (distro-aware path search) ---
|
|
for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]:
|
|
path = _find_binary(name, distro)
|
|
if path is None:
|
|
result.errors.append(f"Missing binary: {name}")
|
|
else:
|
|
setattr(result, attr, path)
|
|
|
|
# --- Install hint for missing binaries ---
|
|
if result.install_hint:
|
|
result.warnings.append(f"Install command: {result.install_hint}")
|
|
|
|
# --- FFmpeg version + library probe ---
|
|
if result.ffmpeg_path:
|
|
result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path)
|
|
if result.ffmpeg_version:
|
|
result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}")
|
|
result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path)
|
|
|
|
# Warn about missing AUDIO libs (video codecs are handled by av1an's own
|
|
# encoder binaries — ffmpeg's video encoder list is irrelevant)
|
|
audio_lib_warnings = {
|
|
"Opus": "libopus",
|
|
"Vorbis": "libvorbis",
|
|
"FLAC": "flac",
|
|
}
|
|
for codec_label, lib_name in audio_lib_warnings.items():
|
|
if not result.ffmpeg_libs.get(lib_name, False):
|
|
result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)")
|
|
|
|
# --- Av1an version ---
|
|
if result.av1an_path:
|
|
result.av1an_version = _probe_av1an_version(result.av1an_path)
|
|
if result.av1an_version:
|
|
result.warnings.append(f"av1an version: {result.av1an_version}")
|
|
|
|
# --- Av1an flag compatibility probe ---
|
|
if result.av1an_path:
|
|
try:
|
|
help_out = subprocess.run(
|
|
[result.av1an_path, "--help"],
|
|
capture_output=True, text=True, timeout=15,
|
|
).stdout
|
|
result.av1an_flags = {
|
|
"worker": "--workers" if "--workers" in help_out else "-w",
|
|
"video_params": "--video-params" if "--video-params" in help_out else "-v",
|
|
"audio_params": "--audio-params" if "--audio-params" in help_out else "-a",
|
|
}
|
|
|
|
# Detect which encoder names this av1an build actually accepts.
|
|
# Substring matching on --help is unreliable (e.g. "svt" appears in
|
|
# descriptions but the real name may be "svtav1" or "svt_av1").
|
|
# Instead, pass a bogus encoder name and parse the clap error which
|
|
# lists all valid values.
|
|
svt_name = _detect_av1an_svt_encoder(result.av1an_path)
|
|
if svt_name:
|
|
result.av1an_flags["svt_name"] = svt_name
|
|
result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'")
|
|
else:
|
|
# Absolute fallback — should rarely be needed
|
|
result.av1an_flags["svt_name"] = "svt_av1"
|
|
result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)")
|
|
|
|
# Check for chunk-method availability (differs by av1an version/distro)
|
|
if "--chunk-method" in help_out:
|
|
result.av1an_flags["has_chunk_method"] = True
|
|
|
|
# Check for --temp flag (lets us relocate av1an work dir out of user folders)
|
|
if "--temp" in help_out:
|
|
result.av1an_flags["has_temp"] = True
|
|
result.av1an_flags["temp_flag"] = "--temp"
|
|
elif "-T" in help_out:
|
|
result.av1an_flags["has_temp"] = True
|
|
result.av1an_flags["temp_flag"] = "-T"
|
|
|
|
# Check for -s/segments flag (newer av1an)
|
|
if "-s" in help_out or "--scenes" in help_out:
|
|
result.av1an_flags["has_scenes"] = True
|
|
|
|
# Detect concat method: prefer mkvmerge, fall back to ffmpeg
|
|
if shutil.which("mkvmerge"):
|
|
result.av1an_flags["concat_method"] = "mkvmerge"
|
|
else:
|
|
result.av1an_flags["concat_method"] = "ffmpeg"
|
|
|
|
except Exception as e:
|
|
result.errors.append(f"av1an probe failed: {e}")
|
|
|
|
# --- Distro-specific notes ---
|
|
if distro.notes:
|
|
result.warnings.append(f"Distro note: {distro.notes}")
|
|
|
|
# --- Runtime dependency probe (vapoursynth, encoder binaries) ---
|
|
if result.av1an_path:
|
|
deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro)
|
|
result.runtime_deps = deps
|
|
result.missing_dep_pkgs = missing_pkgs
|
|
if deps.get("vapoursynth"):
|
|
result.vs_version = vs_ver
|
|
result.vs_script_lib = vs_detail
|
|
|
|
# Log VapourSynth/VSScript with extra detail
|
|
vs_status = "OK" if deps.get("vapoursynth") else "MISSING"
|
|
result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}")
|
|
if deps.get("vapoursynth"):
|
|
# vs_detail is the library path on success
|
|
result.warnings.append(f" VSScript lib: {vs_detail}")
|
|
if vs_dlopen_warn:
|
|
result.warnings.append(f" dlopen note: {vs_dlopen_warn}")
|
|
else:
|
|
# vs_detail is the failure reason
|
|
result.warnings.append(f" Reason: {vs_detail}")
|
|
|
|
# Log encoder binary deps (skip vs_detail key)
|
|
for dep_name, present in deps.items():
|
|
if dep_name in ("vapoursynth", "vs_detail"):
|
|
continue
|
|
status = "OK" if present else "MISSING"
|
|
result.warnings.append(f"Dependency: {dep_name} = {status}")
|
|
|
|
if missing_pkgs:
|
|
hint = result.dep_install_hint
|
|
result.errors.append(
|
|
f"Missing runtime dependencies: {', '.join(missing_pkgs)}"
|
|
)
|
|
if hint:
|
|
result.errors.append(f" FIX: {hint}")
|
|
|
|
return result
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# FFPREPBE VALIDATION
|
|
# ──────────────────────────────────────────────
|
|
|
|
def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> Optional[dict]:
|
|
"""Returns stream info dict or None if invalid/unreadable."""
|
|
try:
|
|
res = subprocess.run(
|
|
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
|
"-show_format", "-show_streams", str(filepath)],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
if res.returncode != 0:
|
|
return None
|
|
import json
|
|
return json.loads(res.stdout)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool:
|
|
"""Verify that an encoded file actually has the requested output resolution.
|
|
|
|
Returns True if the output matches (or is within 2px due to force_divisible_by=2),
|
|
False otherwise.
|
|
"""
|
|
import json
|
|
try:
|
|
res = subprocess.run(
|
|
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
|
"-show_streams", "-select_streams", "v:0", str(output_path)],
|
|
capture_output=True, text=True, timeout=15,
|
|
)
|
|
if res.returncode != 0:
|
|
return True # can't verify, don't block
|
|
data = json.loads(res.stdout)
|
|
streams = data.get("streams", [])
|
|
if not streams:
|
|
return True
|
|
ow = int(streams[0].get("width", 0) or 0)
|
|
oh = int(streams[0].get("height", 0) or 0)
|
|
# Allow 2px tolerance (force_divisible_by=2 rounding)
|
|
if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2:
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return True # can't verify, don't block
|
|
|
|
|
|
def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None:
|
|
"""Determine the exact encoder name av1an accepts for SVT-AV1.
|
|
|
|
Strategy (in order):
|
|
1. Run ``av1an --encoder __PROBE__`` and parse clap's error for
|
|
``[possible values: ...]``.
|
|
2. Parse ``--help`` for ``[default: <name>]`` 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 <ENCODER> ... [default: svt-av1]
|
|
m2 = re.search(
|
|
r"--encoder\s+<ENCODER>.*?\[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(
|
|
'<a href="http://git.dcos.net/dcosnet/OpenTranscode" '
|
|
'style="color: #888; text-decoration: none;">Visit Homepage</a>'
|
|
)
|
|
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 - <a href="http://dcos.net" '
|
|
'style="color: #888; text-decoration: none;">dcos.net</a> (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()) |