6572 lines
285 KiB
Python
Executable File
6572 lines
285 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
OpenTranscode — open-source batch video transcoder (av1an + ffmpeg)
|
||
====================================================================
|
||
A PySide6 GUI application that orchestrates av1an + ffmpeg for batch video
|
||
transcoding. Distro-aware, config-driven (codec / audio / container /
|
||
resolution profiles), with a QThread-based encoder worker, a from-git
|
||
source builder for resolving VapourSynth / av1an ABI mismatches, and a
|
||
retro-futuristic media-console UI.
|
||
|
||
This launcher script is preserved alongside the ``opentranscode/`` package
|
||
for backwards compatibility and as the test target for the mocked test
|
||
suite. It mirrors the package's behavior via inline copies of the same
|
||
modules. New code should ``import opentranscode`` (the package) instead.
|
||
|
||
v4.0.0 — Production Release
|
||
---------------------------
|
||
Resolved the "works up until near the end, never saves chunks into a full
|
||
file" bug that affected phone-recorded MP4s with sparse keyframes.
|
||
|
||
Root cause: when no VapourSynth source plugins are installed (the common
|
||
case), av1an auto-selects the Hybrid chunk method, which does
|
||
``ffmpeg -c copy -f segment`` to split the source at scene boundaries,
|
||
then re-decodes each segment to y4m. Phone-recorded MP4s only have
|
||
I-frames every 5-10s, so scene boundaries rarely align with keyframes →
|
||
segments start mid-GOP → the decoder errors with "error while decoding
|
||
MB 35 25" → the y4m pipe breaks → the encoder fails with "Failed to
|
||
read y4m frame delimiter. Read broken. EOF: 1" → every chunk fails →
|
||
no concat → no output file.
|
||
|
||
The fix has three parts:
|
||
|
||
1. ``_encode_one`` now accepts a ``chunk_method`` parameter. When av1an
|
||
fails with the y4m break pattern, it recursively retries with
|
||
``--chunk-method select`` (VapourSynth's select() filter, which
|
||
extracts frames one-by-one and avoids the keyframe-alignment issue).
|
||
This is faster than the ffmpeg fallback (chunk-parallel still works)
|
||
and produces identical-quality output.
|
||
|
||
2. The working chunk_method is cached in
|
||
``env.av1an_flags["chunk_method_override"]`` so subsequent files skip
|
||
the wasted first attempt.
|
||
|
||
3. ``env_probe`` now calls ``_probe_vs_source_plugins()`` to detect
|
||
installed VapourSynth source plugins (lsmash, ffms2, bestsource,
|
||
dgdecnv, vszip). When NONE are found, it pre-sets
|
||
``chunk_method_override = "select"`` to avoid the wasted first
|
||
attempt entirely.
|
||
|
||
Also fixed: the "SUMMARY block + non-zero exit" diagnostic previously
|
||
misdiagnosed y4m break failures as "concat failure" (because SVT-AV1
|
||
prints a SUMMARY block per-chunk before the pipe breaks). The check is
|
||
now guarded by ``"Failed to read y4m frame delimiter" not in stderr_full``
|
||
so it only fires for true concat failures.
|
||
|
||
New CLI flag: ``--chunk-method {auto,select,hybrid,segment,ffms2,lsmash,
|
||
bestsource,dgdecnv}`` lets the user force a specific chunk method.
|
||
|
||
New e2e test: ``test_y4m_break_recovery_produces_valid_output`` in
|
||
test_e2e_real_encode.py — generates a real video, simulates the y4m
|
||
break, and verifies the retry-with-select produces a valid AV1/MKV file.
|
||
|
||
Previous releases
|
||
-----------------
|
||
v3.x was the production-readiness pass: split the 515-line ``run()`` into
|
||
5 single-responsibility methods, narrowed 24 bare ``except Exception``
|
||
clauses, added the STOP-button interrupt (SIGTERM/SIGKILL on the process
|
||
group), per-worker temp subdirs (race-condition fix), the per-file
|
||
av1an→ffmpeg fallback, and the av1an VSScript smoke test. v2.x added
|
||
ffprobe pre-validation and the pattern-table failure diagnostics.
|
||
|
||
Key features
|
||
------------
|
||
- Config-driven codec/audio/container profiles (no 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 / MP4)
|
||
- AV1/VP9/x265 preset selection
|
||
- Configurable input extensions
|
||
- Batch delete with summary prompt (not per-file)
|
||
"""
|
||
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import platform
|
||
import re
|
||
import shutil
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import ctypes
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
|
||
QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox,
|
||
QStyleFactory,
|
||
)
|
||
from PySide6.QtCore import Qt, QThread, Signal, Slot, QPointF, QRectF, QTimer
|
||
from PySide6.QtGui import (
|
||
QFont, QPalette, QColor, QPainter, QPen, QBrush,
|
||
QRadialGradient, QFontMetrics,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# RADIO KNOB WIDGET (oldschool rotary control)
|
||
# ──────────────────────────────────────────────
|
||
|
||
class RadioKnob(QWidget):
|
||
"""
|
||
A retro radio-style rotary knob widget.
|
||
Supports arc range, tick marks, and a glowing indicator dot.
|
||
|
||
Rotation: 7 o'clock (min) to 5 o'clock (max) = 300 degrees.
|
||
"""
|
||
valueChanged = Signal(float)
|
||
|
||
def __init__(
|
||
self,
|
||
parent=None,
|
||
min_val: float = 0.0,
|
||
max_val: float = 100.0,
|
||
default_val: float = 50.0,
|
||
label: str = "",
|
||
unit: str = "",
|
||
color: tuple = (42, 130, 218),
|
||
num_ticks: int = 17,
|
||
tick_labels: list[str] | None = None,
|
||
snap_ticks: bool = False,
|
||
compact: bool = False,
|
||
):
|
||
super().__init__(parent)
|
||
self.min_val = min_val
|
||
self.max_val = max_val
|
||
self._value = default_val
|
||
self.label = label
|
||
self.unit = unit
|
||
self.color = QColor(*color)
|
||
self.num_ticks = num_ticks
|
||
self.tick_labels = tick_labels
|
||
self.snap_ticks = snap_ticks
|
||
self._dragging = False
|
||
self.compact = compact
|
||
|
||
# Arc geometry: 300-degree sweep, centered at 12 o'clock
|
||
self._arc_start = 210.0 # degrees (7 o'clock)
|
||
self._arc_span = -300.0 # negative = clockwise
|
||
|
||
# Scaling factor for compact mode (~70% of full size)
|
||
s = 0.70 if compact else 1.0
|
||
self._s = s
|
||
self.setFixedSize(int(180 * s), int(210 * s))
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
# --- Public API ---
|
||
|
||
def value(self) -> float:
|
||
return self._value
|
||
|
||
def setValue(self, v: float):
|
||
v = max(self.min_val, min(self.max_val, v))
|
||
if self.snap_ticks:
|
||
v = self._snap(v)
|
||
if v != self._value:
|
||
self._value = v
|
||
self.update()
|
||
self.valueChanged.emit(v)
|
||
|
||
def intValue(self) -> int:
|
||
return int(round(self._value))
|
||
|
||
def _snap(self, v: float) -> float:
|
||
"""Snap to nearest tick."""
|
||
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
|
||
return round((v - self.min_val) / step) * step + self.min_val
|
||
|
||
def _val_to_angle(self, v: float) -> float:
|
||
"""Map value to angle in degrees (matching the conical gradient)."""
|
||
ratio = (v - self.min_val) / (self.max_val - self.min_val) if self.max_val != self.min_val else 0
|
||
return self._arc_start + ratio * self._arc_span # goes from 210 -> -90
|
||
|
||
def _angle_to_val(self, angle_deg: float) -> float:
|
||
"""Map angle back to value."""
|
||
# Normalize angle relative to arc start
|
||
ratio = (angle_deg - self._arc_start) / self._arc_span
|
||
ratio = max(0.0, min(1.0, ratio))
|
||
v = self.min_val + ratio * (self.max_val - self.min_val)
|
||
if self.snap_ticks:
|
||
v = self._snap(v)
|
||
return v
|
||
|
||
# --- Painting ---
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
w, h = self.width(), self.height()
|
||
s = self._s # scale factor (0.7 for compact, 1.0 for full)
|
||
|
||
cx = w / 2
|
||
cy = h / 2 - 4 * s
|
||
outer_r = 70 * s
|
||
knob_r = 40 * s
|
||
arc_w = max(1, int(8 * s))
|
||
tick_w = max(1, 1.5 * s)
|
||
bezel_pad = 6 * s
|
||
|
||
# --- Outer bezel ring ---
|
||
bezel_grad = QRadialGradient(cx, cy, outer_r + bezel_pad)
|
||
bezel_grad.setColorAt(0.85, QColor(48, 48, 52))
|
||
bezel_grad.setColorAt(1.0, QColor(26, 26, 30))
|
||
p.setBrush(QBrush(bezel_grad))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(cx, cy), outer_r + bezel_pad, outer_r + bezel_pad)
|
||
|
||
# --- Inactive arc (dark track) ---
|
||
p.setPen(QPen(QColor(50, 50, 56), arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
|
||
int(self._arc_start * 16), int(self._arc_span * 16))
|
||
|
||
# --- Active arc (colored fill up to current value) ---
|
||
val_angle = self._val_to_angle(self._value)
|
||
active_span = val_angle - self._arc_start
|
||
if abs(active_span) > 0.5:
|
||
arc_color = QColor(self.color)
|
||
p.setPen(QPen(arc_color, arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
|
||
int(self._arc_start * 16), int(active_span * 16))
|
||
|
||
# --- Tick marks ---
|
||
for i in range(self.num_ticks):
|
||
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
|
||
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
|
||
tick_rad = tick_angle * math.pi / 180.0
|
||
ox = cx + (outer_r + 12 * s) * (-1) * math.sin(tick_rad)
|
||
oy = cy + (outer_r + 12 * s) * (-1) * (-math.cos(tick_rad))
|
||
ix_ = cx + (outer_r + 3 * s) * (-1) * math.sin(tick_rad)
|
||
iy_ = cy + (outer_r + 3 * s) * (-1) * (-math.cos(tick_rad))
|
||
p.setPen(QPen(QColor(130, 130, 130), tick_w))
|
||
p.drawLine(QPointF(ix_, iy_), QPointF(ox, oy))
|
||
|
||
# Tick labels (if provided)
|
||
if self.tick_labels:
|
||
p.setFont(QFont("Sans", max(5, int(7 * s))))
|
||
p.setPen(QColor(160, 160, 160))
|
||
step = max(1, self.num_ticks // len(self.tick_labels))
|
||
label_idx = 0
|
||
for i in range(0, self.num_ticks, step):
|
||
if label_idx >= len(self.tick_labels):
|
||
break
|
||
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
|
||
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
|
||
tick_rad = tick_angle * math.pi / 180.0
|
||
lx = cx + (outer_r + 24 * s) * (-1) * math.sin(tick_rad)
|
||
ly = cy + (outer_r + 24 * s) * (-1) * (-math.cos(tick_rad))
|
||
txt = self.tick_labels[label_idx]
|
||
fm = QFontMetrics(p.font())
|
||
tw = fm.horizontalAdvance(txt)
|
||
p.drawText(QPointF(lx - tw / 2, ly + 2 * s), txt)
|
||
label_idx += 1
|
||
|
||
# --- Knob body (dark brushed aluminum) ---
|
||
knob_grad = QRadialGradient(cx - 6 * s, cy - 6 * s, knob_r * 1.3)
|
||
knob_grad.setColorAt(0.0, QColor(72, 72, 78))
|
||
knob_grad.setColorAt(0.5, QColor(50, 50, 55))
|
||
knob_grad.setColorAt(1.0, QColor(34, 34, 38))
|
||
p.setBrush(QBrush(knob_grad))
|
||
p.setPen(QPen(QColor(26, 26, 30), max(1, 1.5 * s)))
|
||
p.drawEllipse(QPointF(cx, cy), knob_r, knob_r)
|
||
|
||
# --- Inner shadow ring ---
|
||
inner_shadow = QRadialGradient(cx, cy, knob_r - 2)
|
||
inner_shadow.setColorAt(0.85, QColor(0, 0, 0, 0))
|
||
inner_shadow.setColorAt(1.0, QColor(0, 0, 0, 60))
|
||
p.setBrush(QBrush(inner_shadow))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(cx, cy), knob_r - 1, knob_r - 1)
|
||
|
||
# --- Indicator line (pointer) ---
|
||
ptr_angle = self._val_to_angle(self._value)
|
||
ptr_rad = ptr_angle * 3.14159265 / 180.0
|
||
ptr_len = knob_r - 8 * s
|
||
px = cx + ptr_len * (-1) * math.sin(ptr_rad)
|
||
py = cy + ptr_len * (-1) * (-math.cos(ptr_rad))
|
||
p.setPen(QPen(QColor(255, 255, 255, 220), max(1, 2.5 * s),
|
||
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||
p.drawLine(QPointF(cx, cy), QPointF(px, py))
|
||
|
||
# --- Center cap dot ---
|
||
cap_r = max(2, 5 * s)
|
||
cap_grad = QRadialGradient(cx, cy, cap_r)
|
||
cap_grad.setColorAt(0.0, QColor(60, 60, 65))
|
||
cap_grad.setColorAt(1.0, QColor(30, 30, 34))
|
||
p.setBrush(QBrush(cap_grad))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(cx, cy), cap_r, cap_r)
|
||
|
||
# --- Glow dot at arc tip ---
|
||
glow_r = max(3, 10 * s)
|
||
glow_x = cx + outer_r * (-1) * math.sin(ptr_rad)
|
||
glow_y = cy + outer_r * (-1) * (-math.cos(ptr_rad))
|
||
glow = QRadialGradient(glow_x, glow_y, glow_r * 1.2)
|
||
glow.setColorAt(0.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 200))
|
||
glow.setColorAt(1.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 0))
|
||
p.setBrush(QBrush(glow))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawEllipse(QPointF(glow_x, glow_y), glow_r, glow_r)
|
||
|
||
p.end()
|
||
|
||
# --- Label + value text below knob ---
|
||
p2 = QPainter(self)
|
||
p2.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
# Value line (e.g. "32.0 CRF")
|
||
val_font_sz = max(6, int(13 * s))
|
||
p2.setFont(QFont("Consolas", val_font_sz, QFont.Weight.Bold))
|
||
val_color = QColor(self.color.red(), self.color.green(), self.color.blue())
|
||
p2.setPen(val_color)
|
||
val_text = f"{self._value:.0f} {self.unit}" if self.unit else f"{self._value:.0f}"
|
||
p2.drawText(QRectF(0, h - 38 * s, w, 20 * s), Qt.AlignmentFlag.AlignCenter, val_text)
|
||
|
||
# Label line (e.g. "Quality")
|
||
lbl_font_sz = max(5, int(9 * s))
|
||
p2.setFont(QFont("Consolas", lbl_font_sz, QFont.Weight.Bold))
|
||
p2.setPen(QColor(160, 160, 160))
|
||
p2.drawText(QRectF(0, h - 18 * s, w, 16 * s), Qt.AlignmentFlag.AlignCenter, self.label)
|
||
p2.end()
|
||
|
||
# --- Input handling ---
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self._dragging = True
|
||
self._update_from_mouse(event.position())
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if self._dragging:
|
||
self._update_from_mouse(event.position())
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
self._dragging = False
|
||
|
||
def wheelEvent(self, event):
|
||
delta = event.angleDelta().y()
|
||
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
|
||
if delta > 0:
|
||
self.setValue(self._value + step)
|
||
elif delta < 0:
|
||
self.setValue(self._value - step)
|
||
|
||
def _update_from_mouse(self, pos: QPointF):
|
||
cx = self.width() / 2
|
||
cy = self.height() / 2 - 4 * self._s
|
||
dx = pos.x() - cx
|
||
dy = pos.y() - cy
|
||
angle = math.degrees(math.atan2(dx, -dy)) # 0=north, CW positive
|
||
if angle < 0:
|
||
angle += 360
|
||
# Clamp to arc range: 210..510 (which is 210..360 and 0..150)
|
||
# Our arc: 210 degrees to -90 (=270) degrees clockwise
|
||
if angle < 210 and angle > 150:
|
||
# Dead zone at bottom (between 150 and 210)
|
||
# Push to nearest end
|
||
angle = 210 if abs(angle - 210) < abs(angle - 510) else 510
|
||
if angle > 360:
|
||
angle -= 360 # normalize back to 0..360
|
||
self.setValue(self._angle_to_val(angle))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# CONFIG-DRIVEN PROFILES (replaces all if/else chains)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class VideoCodecProfile:
|
||
label: str # Display name in combo box
|
||
av1an_encoder: str # Encoder name passed to --encoder
|
||
ffmpeg_encoder: str # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1")
|
||
container: str # Default container extension (mkv or webm)
|
||
crf_range: tuple[int, int] # (min, max) valid CRF values
|
||
default_crf: int
|
||
# (crf, preset) -> av1an --video-params string. Passed to SvtAv1EncApp /
|
||
# vpxenc / x265 as a CLI invocation, so ONLY CLI-accepted flags may
|
||
# appear here. Thread capping lives in ffmpeg_vargs_fn and in
|
||
# EncoderWorker's --workers.
|
||
params_fn: Callable[[int, int], str] # (crf, preset) -> av1an video-params string
|
||
ffmpeg_vargs_fn: Callable[[int, int], list[str]] # (crf, preset) -> ffmpeg -c:v args
|
||
presets: list[str] # Human-readable preset labels
|
||
preset_map: dict[str, int] # label -> internal preset value
|
||
# v4.3.0: the codec_name ffprobe returns for files encoded with this
|
||
# profile. Used by _output_already_encoded() to detect skip-existing.
|
||
# av1 → "av1", vp9 → "vp9", hevc → "hevc".
|
||
ffprobe_codec_name: str = ""
|
||
|
||
@dataclass
|
||
class AudioProfile:
|
||
label: str
|
||
params: list[str] # Tokens passed to --audio-params (joined with space)
|
||
# v3 (OTC-012, SEI CERT STR09-C): the ffmpeg audio encoder name this
|
||
# profile depends on, e.g. "libopus", "libvorbis", "flac", "libiamf".
|
||
# Used by _check_combo_compatibility and _disable_unavailable_codecs
|
||
# to look up the encoder directly in EnvProbe.ffmpeg_libs — replacing
|
||
# the v2 substring match (`"libiamf" in ap.params`) which would
|
||
# falsely match a hypothetical `-libiamf-mode` argument.
|
||
# Empty string means "no ffmpeg encoder dependency" (rare; only used
|
||
# by passthrough profiles that don't transcode audio).
|
||
ffmpeg_encoder_name: str = ""
|
||
# v4.3.0: the codec_name ffprobe returns for files encoded with this
|
||
# profile. Used by _output_already_encoded() to detect skip-existing.
|
||
# opus → "opus", vorbis → "vorbis", flac → "flac", iamf → "iamf".
|
||
ffprobe_codec_name: str = ""
|
||
|
||
@dataclass
|
||
class ContainerProfile:
|
||
label: str
|
||
ext: str # e.g. "mkv", "webm"
|
||
|
||
|
||
def _av1_params(crf: int, preset: int) -> str:
|
||
"""SVT-AV1 encoder params for av1an's --video-params.
|
||
|
||
av1an splits the --video-params value by whitespace (``split_whitespace()``)
|
||
and passes each resulting token as a separate argument to SvtAv1EncApp.
|
||
Therefore the string must contain space-separated ``--flag value`` pairs
|
||
that SvtAv1EncApp can parse natively.
|
||
|
||
Colon-separated ``key=value:key=value`` does NOT work because there are
|
||
no whitespace boundaries for av1an to split on — the entire string reaches
|
||
SvtAv1EncApp as one opaque argument, producing:
|
||
``Maybe missing spacing between tokens``.
|
||
|
||
Thread capping is NOT injected here. SvtAv1EncApp (the standalone CLI
|
||
av1an invokes per-chunk) uses `--lp N` (logical processors), not
|
||
`--threads N`. Thread capping is handled via av1an's `--workers` flag
|
||
(chunk-parallel count) and via `-threads` in the ffmpeg fallback path
|
||
(where libsvtav1 is a library and accepts it).
|
||
"""
|
||
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},
|
||
ffprobe_codec_name="av1", # v4.3.0: skip-existing detection
|
||
),
|
||
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},
|
||
ffprobe_codec_name="vp9", # v4.3.0: skip-existing detection
|
||
),
|
||
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},
|
||
ffprobe_codec_name="hevc", # v4.3.0: skip-existing detection
|
||
),
|
||
]
|
||
|
||
AUDIO_PROFILES: list[AudioProfile] = [
|
||
AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"],
|
||
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
|
||
AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"],
|
||
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
|
||
AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"],
|
||
ffmpeg_encoder_name="flac", ffprobe_codec_name="flac"),
|
||
# IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family).
|
||
# Built on Opus internally; requires ffmpeg compiled with --enable-libiamf.
|
||
# CANNOT be muxed into MKV/WebM — must use the MP4 container (see below).
|
||
# The -strict experimental flag is harmless on ffmpeg builds where libiamf
|
||
# is already stable, and required on builds where it's still flagged
|
||
# experimental, so we always pass it for forward compatibility.
|
||
AudioProfile(
|
||
label="IAMF (128k)",
|
||
params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"],
|
||
ffmpeg_encoder_name="libiamf",
|
||
ffprobe_codec_name="iamf",
|
||
),
|
||
]
|
||
|
||
CONTAINER_PROFILES: list[ContainerProfile] = [
|
||
ContainerProfile(label="MKV (Matroska)", ext="mkv"),
|
||
ContainerProfile(label="WebM", ext="webm"),
|
||
# MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec).
|
||
# Also useful as a more universally compatible output container.
|
||
ContainerProfile(label="MP4", ext="mp4"),
|
||
]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# FFMPEG_LIB_KEY_MAP — single source of truth (OTC-007, SEI CERT MSC04-C).
|
||
#
|
||
# Maps the `ffmpeg_encoder` field of a VideoCodecProfile (e.g. "libsvtav1",
|
||
# "libvpx-vp9") to the corresponding key in EnvProbe.ffmpeg_libs (which is
|
||
# populated by _probe_ffmpeg_libs()).
|
||
#
|
||
# v2 had this map duplicated in three call sites:
|
||
# - _ffmpeg_fallback_encode (around line 2075)
|
||
# - _probe_and_init status bar (around line 4309)
|
||
# - _handle_vs_incompat fallback check (around line 4532)
|
||
# Adding a new codec required updating all three in sync — a classic
|
||
# MSC04-C violation. v3 hoists it to one module-level constant.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
FFMPEG_LIB_KEY_MAP: dict[str, str] = {
|
||
"libsvtav1": "libsvtav1",
|
||
"libaom-av1": "libaom",
|
||
"libvpx-vp9": "libvpx",
|
||
"libx265": "libx265",
|
||
}
|
||
|
||
|
||
def ffmpeg_lib_key_for(ffmpeg_encoder: str) -> str:
|
||
"""Look up the ffmpeg_libs key for a given ffmpeg encoder name.
|
||
|
||
Returns the encoder name itself if no mapping is known — this preserves
|
||
forward compatibility with encoders added after this map was last
|
||
updated (the caller's .get() will then return False, which is the
|
||
safe default for an unknown encoder).
|
||
"""
|
||
return FFMPEG_LIB_KEY_MAP.get(ffmpeg_encoder, ffmpeg_encoder)
|
||
|
||
|
||
# ── Resolution presets ──
|
||
# Aspect ratios:
|
||
# Standard 16:9 -> w/h = 1.778
|
||
# Wide 21:9 -> w/h = 2.333
|
||
# Ultrawide 32:9 -> w/h = 3.556
|
||
|
||
@dataclass
|
||
class ResolutionProfile:
|
||
label: str # Display label in dropdown, e.g. "1080p Wide (2560x1080)"
|
||
category: str # Grouping key: "standard", "wide", "ultrawide", "original"
|
||
width: int | None # None for "original" (no scaling)
|
||
height: int | None # None for "original"
|
||
aspect_label: str # "16:9", "21:9", "32:9", "Source"
|
||
|
||
|
||
RESOLUTION_PRESETS: list[ResolutionProfile] = [
|
||
# ── Original (no scaling) ──
|
||
ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"),
|
||
|
||
# ── Standard 16:9 ──
|
||
ResolutionProfile("480p ( 854x 480)", "standard", 854, 480, "16:9"),
|
||
ResolutionProfile("720p (1280x 720)", "standard", 1280, 720, "16:9"),
|
||
ResolutionProfile("1080p (1920x1080)", "standard", 1920, 1080, "16:9"),
|
||
ResolutionProfile("2K (2560x1440)", "standard", 2560, 1440, "16:9"),
|
||
ResolutionProfile("4K (3840x2160)", "standard", 3840, 2160, "16:9"),
|
||
|
||
# ── Wide 21:9 ──
|
||
ResolutionProfile("480p Wide ( 854x 366)", "wide", 854, 366, "21:9"),
|
||
ResolutionProfile("720p Wide (1280x 549)", "wide", 1280, 549, "21:9"),
|
||
ResolutionProfile("1080p Wide (2560x1080)", "wide", 2560, 1080, "21:9"),
|
||
ResolutionProfile("2K Wide (3440x1440)", "wide", 3440, 1440, "21:9"),
|
||
ResolutionProfile("4K Wide (5120x2160)", "wide", 5120, 2160, "21:9"),
|
||
|
||
# ── Ultrawide 32:9 ──
|
||
ResolutionProfile("480p UW (1706x 480)", "ultrawide", 1706, 480, "32:9"),
|
||
ResolutionProfile("1080p UW (3840x1080)", "ultrawide", 3840, 1080, "32:9"),
|
||
ResolutionProfile("2K UW (5120x1440)", "ultrawide", 5120, 1440, "32:9"),
|
||
ResolutionProfile("4K UW (7680x2160)", "ultrawide", 7680, 2160, "32:9"),
|
||
]
|
||
|
||
|
||
SUBTITLE_OPTIONS = [
|
||
("None", None),
|
||
("English", "eng"),
|
||
]
|
||
|
||
|
||
DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# LICENSE NOTICES — third-party components invoked by this application.
|
||
#
|
||
# Each entry is a tuple of (tool name, SPDX identifier, short attribution,
|
||
# full notice). The short form is used for the startup banner and the
|
||
# pre-transcode summary; the full form is shown in the About dialog.
|
||
#
|
||
# This application is a thin orchestration layer; it does not incorporate
|
||
# the source code of any of these tools. The license obligations of each
|
||
# tool therefore flow through to the end user independently, and this
|
||
# registry exists to make those obligations visible at runtime.
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass(frozen=True)
|
||
class LicenseNotice:
|
||
"""Immutable descriptor for a third-party component license.
|
||
|
||
SEI CERT MSC04-C spirit: secrets and licensing data are not duplicated
|
||
across the codebase; the canonical source is this table.
|
||
"""
|
||
name: str # e.g. "FFmpeg"
|
||
spdx: str # e.g. "LGPL-2.1-or-later"
|
||
home_url: str # canonical upstream URL
|
||
short: str # one-line attribution shown in banners
|
||
full: str # multi-line notice shown in About dialog
|
||
|
||
|
||
LICENSE_NOTICES: tuple[LicenseNotice, ...] = (
|
||
LicenseNotice(
|
||
name="FFmpeg",
|
||
spdx="LGPL-2.1-or-later (or GPL-2.0-or-later with --enable-gpl)",
|
||
home_url="https://ffmpeg.org",
|
||
short="FFmpeg (LGPL-2.1+, GPL build flags noted at runtime)",
|
||
full=(
|
||
"FFmpeg\n"
|
||
"Copyright (c) FFmpeg developers\n"
|
||
"Licensed under LGPL-2.1-or-later; the build's effective license\n"
|
||
"may upgrade to GPL-2.0-or-later when --enable-gpl or any GPL-only\n"
|
||
"library (libx264, libx265, libfdk-aac) is configured in.\n"
|
||
"Source: https://ffmpeg.org\n"
|
||
"License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="av1an",
|
||
spdx="GPL-3.0-or-later",
|
||
home_url="https://github.com/master-of-zen/av1an",
|
||
short="av1an (GPL-3.0+)",
|
||
full=(
|
||
"av1an — Av1an is a frame-parallel AV1/VP9/x265 encoder\n"
|
||
"Copyright (c) master-of-zen and contributors\n"
|
||
"Licensed under GPL-3.0-or-later.\n"
|
||
"Source: https://github.com/master-of-zen/av1an\n"
|
||
"License: https://www.gnu.org/licenses/gpl-3.0.html"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="VapourSynth",
|
||
spdx="LGPL-2.1-or-later",
|
||
home_url="https://www.vapoursynth.com",
|
||
short="VapourSynth (LGPL-2.1+)",
|
||
full=(
|
||
"VapourSynth — a video processing framework\n"
|
||
"Copyright (c) Fredrik Mellbin and contributors\n"
|
||
"Licensed under LGPL-2.1-or-later.\n"
|
||
"Source: https://github.com/vapoursynth/vapoursynth\n"
|
||
"License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="SVT-AV1",
|
||
spdx="BSD-3-Clause AND PMK-2-Clause",
|
||
home_url="https://gitlab.com/AOMediaCodec/SVT-AV1",
|
||
short="SVT-AV1 (BSD-3-Clause, AOMedia)",
|
||
full=(
|
||
"SVT-AV1 — Scalable Video Technology for AV1\n"
|
||
"Copyright (c) Alliance for Open Media and contributors\n"
|
||
"Licensed under BSD-3-Clause and the AOMedia Patent License.\n"
|
||
"Source: https://gitlab.com/AOMediaCodec/SVT-AV1\n"
|
||
"License: https://opensource.org/license/bsd-3-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="libvpx",
|
||
spdx="BSD-3-Clause",
|
||
home_url="https://github.com/webmproject/libvpx",
|
||
short="libvpx / VP9 (BSD-3-Clause)",
|
||
full=(
|
||
"libvpx — VP8/VP9 codec library\n"
|
||
"Copyright (c) The WebM Project authors\n"
|
||
"Licensed under BSD-3-Clause.\n"
|
||
"Source: https://github.com/webmproject/libvpx\n"
|
||
"License: https://opensource.org/license/bsd-3-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="x265",
|
||
spdx="GPL-2.0-or-later (commercial license available)",
|
||
home_url="https://bitbucket.org/multicoreware/x265_git",
|
||
short="x265 / HEVC (GPL-2.0+)",
|
||
full=(
|
||
"x265 — HEVC encoder\n"
|
||
"Copyright (c) MulticoreWare, Inc and contributors\n"
|
||
"Licensed under GPL-2.0-or-later; a commercial license is\n"
|
||
"available from MulticoreWare for non-GPL distribution.\n"
|
||
"Source: https://bitbucket.org/multicoreware/x265_git\n"
|
||
"License: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="libopus",
|
||
spdx="BSD-3-Clause",
|
||
home_url="https://opus-codec.org",
|
||
short="libopus / Opus (BSD-3-Clause)",
|
||
full=(
|
||
"libopus — Opus audio codec (IETF RFC 6716)\n"
|
||
"Copyright (c) Xiph.Org Foundation, Skype Limited, Mozilla,\n"
|
||
"and contributors\n"
|
||
"Licensed under BSD-3-Clause.\n"
|
||
"Source: https://github.com/xiph/opus\n"
|
||
"License: https://opensource.org/license/bsd-3-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="libvorbis",
|
||
spdx="BSD-3-Clause",
|
||
home_url="https://xiph.org/vorbis",
|
||
short="libvorbis / Vorbis (BSD-3-Clause)",
|
||
full=(
|
||
"libvorbis — Vorbis audio codec\n"
|
||
"Copyright (c) Xiph.Org Foundation and contributors\n"
|
||
"Licensed under BSD-3-Clause.\n"
|
||
"Source: https://github.com/xiph/vorbis\n"
|
||
"License: https://opensource.org/license/bsd-3-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="libFLAC",
|
||
spdx="BSD-3-Clause",
|
||
home_url="https://xiph.org/flac",
|
||
short="libFLAC / FLAC (BSD-3-Clause)",
|
||
full=(
|
||
"libFLAC — Free Lossless Audio Codec\n"
|
||
"Copyright (c) Xiph.Org Foundation and contributors\n"
|
||
"Licensed under BSD-3-Clause.\n"
|
||
"Source: https://github.com/xiph/flac\n"
|
||
"License: https://opensource.org/license/bsd-3-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="libiamf",
|
||
spdx="BSD-2-Clause",
|
||
home_url="https://github.com/AOMediaCodec/libiamf",
|
||
short="libiamf / IAMF (BSD-2-Clause, AOMedia)",
|
||
full=(
|
||
"libiamf — AOMedia Immersive Audio Model and Formats\n"
|
||
"Copyright (c) Alliance for Open Media and contributors\n"
|
||
"Licensed under BSD-2-Clause.\n"
|
||
"Source: https://github.com/AOMediaCodec/libiamf\n"
|
||
"License: https://opensource.org/license/bsd-2-clause"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="Qt / PySide6",
|
||
spdx="LGPL-3.0-only (commercial available from The Qt Company)",
|
||
home_url="https://www.qt.io",
|
||
short="Qt / PySide6 (LGPL-3.0)",
|
||
full=(
|
||
"Qt — application framework\n"
|
||
"Copyright (c) The Qt Company Ltd and contributors\n"
|
||
"Licensed under LGPL-3.0-only; a commercial license is available.\n"
|
||
"Source: https://www.qt.io\n"
|
||
"License: https://www.gnu.org/licenses/lgpl-3.0.html"
|
||
),
|
||
),
|
||
LicenseNotice(
|
||
name="Python",
|
||
spdx="PSF-2.0",
|
||
home_url="https://www.python.org",
|
||
short="Python (PSF License)",
|
||
full=(
|
||
"Python — programming language\n"
|
||
"Copyright (c) Python Software Foundation\n"
|
||
"Licensed under the PSF License Agreement.\n"
|
||
"Source: https://www.python.org\n"
|
||
"License: https://docs.python.org/3/license.html"
|
||
),
|
||
),
|
||
)
|
||
|
||
|
||
def active_license_notices(env) -> list[LicenseNotice]:
|
||
"""Return the subset of LICENSE_NOTICES that apply to the running
|
||
environment. Determined by which tools / libraries env reports as
|
||
present. Always includes FFmpeg, Python, and Qt (framework deps).
|
||
|
||
Data-driven dispatch: avoids a per-tool if/elif chain by looking up
|
||
each notice's presence in env attributes via a small table.
|
||
"""
|
||
presence_rules: tuple[tuple[str, bool], ...] = (
|
||
("FFmpeg", bool(getattr(env, "ffmpeg_path", None))),
|
||
("av1an", bool(getattr(env, "av1an_path", None))),
|
||
("VapourSynth", bool(getattr(env, "vs_version", None))),
|
||
("SVT-AV1", bool(getattr(env, "av1an_flags", {}).get("svt_name"))),
|
||
("libvpx", bool(getattr(env, "ffmpeg_libs", {}).get("libvpx"))),
|
||
("x265", bool(getattr(env, "ffmpeg_libs", {}).get("libx265"))),
|
||
("libopus", bool(getattr(env, "ffmpeg_libs", {}).get("libopus"))),
|
||
("libvorbis", bool(getattr(env, "ffmpeg_libs", {}).get("libvorbis"))),
|
||
("libFLAC", bool(getattr(env, "ffmpeg_libs", {}).get("flac"))),
|
||
("libiamf", bool(getattr(env, "ffmpeg_libs", {}).get("libiamf"))),
|
||
("Qt / PySide6", True), # framework, always present
|
||
("Python", True),
|
||
)
|
||
active_names = {name for name, present in presence_rules if present}
|
||
return [n for n in LICENSE_NOTICES if n.name in active_names]
|
||
|
||
|
||
def license_banner_short(notices: list[LicenseNotice]) -> str:
|
||
"""One-line summary suitable for a status bar or log header."""
|
||
return " | ".join(n.short for n in notices)
|
||
|
||
|
||
def license_banner_full(notices: list[LicenseNotice]) -> str:
|
||
"""Multi-line text block suitable for an About / Licenses dialog."""
|
||
sep = "─" * 60
|
||
blocks = [sep, " OPEN SOURCE LICENSE ATTRIBUTIONS", sep]
|
||
for n in notices:
|
||
blocks.append(n.full)
|
||
blocks.append(sep)
|
||
blocks.append(
|
||
"This application invokes these tools as external processes.\n"
|
||
"Source code of each tool is NOT bundled with this application.\n"
|
||
"For the full text of each license, follow the upstream URL cited\n"
|
||
"above. Questions about redistribution rights should be directed\n"
|
||
"to the upstream projects."
|
||
)
|
||
return "\n".join(blocks)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# CPU TOPOLOGY (physical cores, not hyperthreads)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class CpuTopology:
|
||
physical_cores: int
|
||
logical_threads: int
|
||
threads_per_core: int
|
||
model_name: str
|
||
|
||
|
||
def _read_sysfs_cores() -> (tuple[int, int]) | None:
|
||
"""
|
||
Read /sys/devices/system/cpu/cpu*/topology/ to count unique
|
||
(physical_package_id, core_id) pairs — i.e. physical cores.
|
||
Returns (physical_cores, logical_threads) or None.
|
||
"""
|
||
cpu_base = Path("/sys/devices/system/cpu")
|
||
if not cpu_base.exists():
|
||
return None
|
||
|
||
unique_cores: set[tuple[str, str]] = set()
|
||
logical = 0
|
||
for cpu_dir in sorted(cpu_base.glob("cpu[0-9]*")):
|
||
core_id_file = cpu_dir / "topology" / "core_id"
|
||
pkg_id_file = cpu_dir / "topology" / "physical_package_id"
|
||
if core_id_file.exists() and pkg_id_file.exists():
|
||
try:
|
||
pkg = pkg_id_file.read_text().strip()
|
||
core = core_id_file.read_text().strip()
|
||
unique_cores.add((pkg, core))
|
||
logical += 1
|
||
except (OSError, ValueError):
|
||
# OSError: file vanished/permission; ValueError: UnicodeDecodeError
|
||
pass
|
||
if unique_cores and logical:
|
||
return (len(unique_cores), logical)
|
||
return None
|
||
|
||
|
||
def _read_lscpu_cores() -> (tuple[int, int]) | None:
|
||
"""Fallback: parse lscpu -p=CORE,SOCKET for unique physical cores."""
|
||
if not shutil.which("lscpu"):
|
||
return None
|
||
try:
|
||
res = subprocess.run(
|
||
["lscpu", "-p=CORE,SOCKET"],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
lines = [l.strip() for l in res.stdout.strip().splitlines() if l.strip() and not l.startswith("#")]
|
||
if lines:
|
||
unique = set(lines)
|
||
return (len(unique), len(lines))
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def detect_cpu_topology() -> CpuTopology:
|
||
"""
|
||
Detect physical CPU topology. Prefers /sys filesystem, falls back
|
||
to lscpu, then estimates from os.cpu_count().
|
||
"""
|
||
logical = os.cpu_count() or 1
|
||
physical = logical
|
||
|
||
# Try /sys first (most reliable)
|
||
result = _read_sysfs_cores()
|
||
if result:
|
||
physical, logical = result
|
||
else:
|
||
# Try lscpu
|
||
result = _read_lscpu_cores()
|
||
if result:
|
||
physical, logical = result
|
||
else:
|
||
# Estimate: assume 2 threads/core if cpu_count > 2 and is even
|
||
if logical > 2 and logical % 2 == 0:
|
||
physical = logical // 2
|
||
|
||
tpc = logical // physical if physical > 0 else 1
|
||
|
||
# Try to get CPU model name
|
||
model = "Unknown CPU"
|
||
model_file = Path("/proc/cpuinfo")
|
||
if model_file.exists():
|
||
for line in model_file.read_text(errors="replace").splitlines():
|
||
if line.startswith("model name"):
|
||
model = line.split(":", 1)[1].strip()
|
||
break
|
||
else:
|
||
# Non-x86 / non-Linux: try lscpu
|
||
if shutil.which("lscpu"):
|
||
try:
|
||
res = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5)
|
||
for line in res.stdout.splitlines():
|
||
if "Model name" in line:
|
||
model = line.split(":", 1)[1].strip()
|
||
break
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
|
||
return CpuTopology(
|
||
physical_cores=physical,
|
||
logical_threads=logical,
|
||
threads_per_core=tpc,
|
||
model_name=model,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# DISTRO DETECTION & PROFILES
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class DistroProfile:
|
||
family: str # Canonical family: arch, debian, redhat, suse, nixos, unknown
|
||
name: str # Pretty name: "Arch Linux", "Fedora 40", etc.
|
||
version_id: str # e.g. "40", "15.6", "24.05"
|
||
pkg_manager: str # e.g. "pacman", "dnf", "zypper", "apt", "nix"
|
||
install_cmd_template: str # e.g. "sudo pacman -S {packages}"
|
||
binary_extra_paths: list[str] # Distro-specific dirs to search for binaries
|
||
av1an_known_encoder_names: list[str] # Names this distro's av1an build may accept
|
||
ffmpeg_pkg: str # Package name providing ffmpeg
|
||
av1an_pkg: str # Package name providing av1an
|
||
notes: str # Distro-specific quirks worth showing the user
|
||
# Runtime dependency packages (key = generic name, value = distro package name)
|
||
dep_pkgs: dict[str, str] = field(default_factory=dict)
|
||
# Binaries that av1an invokes directly (not via ffmpeg)
|
||
encoder_binaries: dict[str, list[str]] = field(default_factory=dict)
|
||
# VSScript package name — on most distros this is bundled into 'vapoursynth',
|
||
# but Debian/Ubuntu split it into a separate -script-dev package.
|
||
# If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check.
|
||
vsscript_pkg: str = ""
|
||
|
||
|
||
def _read_os_release() -> dict[str, str]:
|
||
"""Parse /etc/os-release into a dict. Falls back to empty dict."""
|
||
os_release = Path("/etc/os-release")
|
||
fallback = Path("/usr/lib/os-release")
|
||
target = os_release if os_release.exists() else fallback
|
||
if not target.exists():
|
||
return {}
|
||
data = {}
|
||
for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
line = line.strip()
|
||
if "=" in line and not line.startswith("#"):
|
||
key, _, val = line.partition("=")
|
||
data[key.strip()] = val.strip().strip('"')
|
||
return data
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# DISTRO_REGISTRY — data-driven distro detection (v3, OTC-014).
|
||
#
|
||
# v1/v2 had a 250-line if/elif chain in detect_distro() with one branch per
|
||
# distro family. Each branch constructed a DistroProfile with mostly-identical
|
||
# fields — a classic SEI CERT MSC04-C violation (no single source of truth).
|
||
#
|
||
# v3 collapses the chain into a tuple-of-dicts table. Each entry has:
|
||
# ids: tuple of distro_id strings that match this family
|
||
# id_likes: tuple of ID_LIKE substrings that also match this family
|
||
# family: canonical family name
|
||
# pkg_manager: package manager binary name
|
||
# install_cmd: template with {packages} placeholder
|
||
# extra_paths: list of distro-specific binary search paths
|
||
# dep_pkgs: map of generic name -> distro package name
|
||
# notes: distro-specific quirks string
|
||
# vsscript_pkg: (optional) separate VSScript package name
|
||
#
|
||
# Adding a new distro is now a single-table-row change — no code modification.
|
||
# The encoder_binaries field is identical across all distros and lives in the
|
||
# function body (it's the same dict literal every time).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# encoder_binaries is identical for every distro — define once.
|
||
_ENCODER_BINARIES: dict[str, list[str]] = {
|
||
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
|
||
"vpx": ["vpxenc"],
|
||
"x265": ["x265"],
|
||
}
|
||
|
||
# Common av1an encoder names known across distros.
|
||
_AV1AN_KNOWN_ENCODERS: list[str] = ["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _DistroEntry:
|
||
"""One row in the DISTRO_REGISTRY table."""
|
||
ids: tuple[str, ...] # exact distro_id matches
|
||
id_likes: tuple[str, ...] # ID_LIKE substring matches
|
||
family: str
|
||
pkg_manager: str
|
||
install_cmd: str # template with {packages}
|
||
extra_paths: tuple[str, ...]
|
||
dep_pkgs: dict[str, str]
|
||
notes: str
|
||
vsscript_pkg: str = ""
|
||
|
||
|
||
DISTRO_REGISTRY: tuple[_DistroEntry, ...] = (
|
||
_DistroEntry(
|
||
ids=("arch", "manjaro", "endeavouros", "garuda", "cachyos"),
|
||
id_likes=("arch",),
|
||
family="arch",
|
||
pkg_manager="pacman",
|
||
install_cmd="sudo pacman -S {packages}",
|
||
extra_paths=("/usr/bin", "/usr/local/bin", "~/.local/bin", "~/.cargo/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svt-av1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx",
|
||
"opus": "libopus",
|
||
"vorbis": "libvorbis",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. "
|
||
"SVT-AV1 encoder name is typically 'svt_av1'. "
|
||
"Cargo-installed av1an may live in ~/.cargo/bin."
|
||
),
|
||
),
|
||
_DistroEntry(
|
||
ids=("fedora",),
|
||
id_likes=("fedora",),
|
||
family="redhat",
|
||
pkg_manager="dnf",
|
||
install_cmd="sudo dnf install {packages}",
|
||
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svt-av1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx-tools",
|
||
"opus": "opus",
|
||
"vorbis": "libvorbis",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"Fedora: av1an may require COPR enablement first: "
|
||
"sudo dnf copr enable sergiomb/av1an (or build from source). "
|
||
"SVT-AV1 is in the main repos as 'svt-av1'. "
|
||
"Ensure RPM Fusion is enabled for full codec support."
|
||
),
|
||
),
|
||
_DistroEntry(
|
||
ids=("rhel", "centos", "rocky", "almalinux", "ol"),
|
||
id_likes=("rhel", "centos"),
|
||
family="redhat",
|
||
# RHEL-family: dnf if present, fall back to yum
|
||
pkg_manager="", # resolved at runtime in detect_distro()
|
||
install_cmd="", # resolved at runtime in detect_distro()
|
||
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svt-av1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx-tools",
|
||
"opus": "opus",
|
||
"vorbis": "libvorbis",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. "
|
||
"Options: (1) cargo install av1an, (2) build from GitHub source, "
|
||
"(3) use pre-built binary from releases. "
|
||
"Enable EPEL + RPM Fusion for FFmpeg codec support."
|
||
),
|
||
),
|
||
_DistroEntry(
|
||
ids=("opensuse-leap", "opensuse-tumbleweed", "sles"),
|
||
id_likes=("suse",),
|
||
family="suse",
|
||
pkg_manager="zypper",
|
||
install_cmd="sudo zypper install {packages}",
|
||
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svt-av1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx",
|
||
"opus": "libopus",
|
||
"vorbis": "libvorbis",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"openSUSE: av1an may be available via OBS (Open Build Service). "
|
||
"Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. "
|
||
"Packman repo provides FFmpeg with full codec support."
|
||
),
|
||
),
|
||
_DistroEntry(
|
||
ids=("nixos",),
|
||
id_likes=("nixos",),
|
||
family="nixos",
|
||
pkg_manager="nix",
|
||
install_cmd="nix-shell -p {packages}",
|
||
extra_paths=("/run/current-system/sw/bin", "~/.nix-profile/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svt-av1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx",
|
||
"opus": "opus",
|
||
"vorbis": "libvorbis",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. "
|
||
"Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. "
|
||
"av1an CLI flags may differ from other distros depending on the nixpkgs channel."
|
||
),
|
||
),
|
||
_DistroEntry(
|
||
ids=("debian", "ubuntu", "linuxmint", "pop"),
|
||
id_likes=("debian",),
|
||
family="debian",
|
||
pkg_manager="apt",
|
||
install_cmd="sudo apt install {packages}",
|
||
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
|
||
dep_pkgs={
|
||
"vapoursynth": "vapoursynth",
|
||
"svt-av1": "svtav1",
|
||
"x265": "x265",
|
||
"vpx": "libvpx-tools",
|
||
"opus": "libopus-dev",
|
||
"vorbis": "libvorbis-dev",
|
||
"flac": "flac",
|
||
},
|
||
notes=(
|
||
"Debian/Ubuntu: av1an is in the repos (apt install av1an). "
|
||
"Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. "
|
||
"VSScript is in a separate package: libvapoursynth-script-dev. "
|
||
"For newer builds, consider cargo install av1an."
|
||
),
|
||
vsscript_pkg="libvapoursynth-script-dev",
|
||
),
|
||
)
|
||
|
||
|
||
def _match_distro_entry(distro_id: str, id_like: list[str]) -> _DistroEntry | None:
|
||
"""Find the first DISTRO_REGISTRY entry whose ids or id_likes match.
|
||
|
||
SEI CERT MSC04-C spirit: the matching logic is one flat loop over a
|
||
table — no nested if/elif chain. Adding a new distro is a one-line
|
||
table change in DISTRO_REGISTRY above; this function never needs
|
||
modification.
|
||
"""
|
||
for entry in DISTRO_REGISTRY:
|
||
if distro_id in entry.ids:
|
||
return entry
|
||
if any(like in id_like for like in entry.id_likes):
|
||
return entry
|
||
return None
|
||
|
||
|
||
def detect_distro() -> DistroProfile:
|
||
"""
|
||
Detect the running Linux distribution via /etc/os-release.
|
||
Returns a DistroProfile with distro-specific package manager,
|
||
install commands, binary search paths, and known quirks.
|
||
|
||
v3 (OTC-014): the per-distro data lives in DISTRO_REGISTRY above.
|
||
This function is now ~30 lines of glue instead of a 250-line
|
||
if/elif chain.
|
||
"""
|
||
info = _read_os_release()
|
||
id_like = info.get("ID_LIKE", "").lower().split()
|
||
distro_id = info.get("ID", "").lower()
|
||
pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system()))
|
||
version = info.get("VERSION_ID", "?")
|
||
|
||
entry = _match_distro_entry(distro_id, id_like)
|
||
|
||
if entry is None:
|
||
# Fallback: unknown distro
|
||
return DistroProfile(
|
||
family="unknown",
|
||
name=pretty,
|
||
version_id=version,
|
||
pkg_manager="unknown",
|
||
install_cmd_template="# Unknown distro — install ffmpeg and av1an manually",
|
||
binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"],
|
||
av1an_known_encoder_names=list(_AV1AN_KNOWN_ENCODERS),
|
||
ffmpeg_pkg="ffmpeg",
|
||
av1an_pkg="av1an",
|
||
dep_pkgs={},
|
||
encoder_binaries=dict(_ENCODER_BINARIES),
|
||
notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.",
|
||
)
|
||
|
||
# Resolve runtime-determined fields (RHEL family: dnf vs yum)
|
||
pkg_manager = entry.pkg_manager
|
||
install_cmd = entry.install_cmd
|
||
if not pkg_manager:
|
||
# RHEL/CentOS family: pick dnf if installed, else yum
|
||
has_dnf = Path("/usr/bin/dnf").exists()
|
||
pkg_manager = "dnf" if has_dnf else "yum"
|
||
install_cmd = (
|
||
"sudo dnf install {packages}" if has_dnf
|
||
else "sudo yum install {packages}"
|
||
)
|
||
|
||
return DistroProfile(
|
||
family=entry.family,
|
||
name=pretty,
|
||
version_id=version,
|
||
pkg_manager=pkg_manager,
|
||
install_cmd_template=install_cmd,
|
||
binary_extra_paths=list(entry.extra_paths),
|
||
av1an_known_encoder_names=(
|
||
# Arch family includes the additional 'svt-av1' alias
|
||
["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"]
|
||
if entry.family == "arch"
|
||
else list(_AV1AN_KNOWN_ENCODERS)
|
||
),
|
||
ffmpeg_pkg="ffmpeg",
|
||
av1an_pkg="av1an",
|
||
dep_pkgs=dict(entry.dep_pkgs),
|
||
encoder_binaries=dict(_ENCODER_BINARIES),
|
||
vsscript_pkg=entry.vsscript_pkg,
|
||
notes=entry.notes,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ENVIRONMENT PROBE (distro-aware, extended)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class EnvProbe:
|
||
distro: DistroProfile = field(default_factory=lambda: DistroProfile(
|
||
family="unknown", name="Unknown", version_id="?",
|
||
pkg_manager="unknown", install_cmd_template="",
|
||
binary_extra_paths=[], av1an_known_encoder_names=[],
|
||
ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes=""
|
||
))
|
||
av1an_path: str | None = None
|
||
ffmpeg_path: str | None = None
|
||
ffprobe_path: str | None = None
|
||
# v3 (OTC-011, PEP 868): parameterized dict/list type hints.
|
||
# av1an_flags values are sometimes str (flag name), sometimes bool
|
||
# (has_chunk_method), sometimes int — keep as dict[str, object] for honesty.
|
||
av1an_flags: dict[str, object] = field(default_factory=dict)
|
||
av1an_version: str | None = None
|
||
ffmpeg_version: str | None = None
|
||
ffmpeg_libs: dict[str, bool] = field(default_factory=dict) # lib name -> available
|
||
runtime_deps: dict[str, bool] = field(default_factory=dict) # dep name -> present
|
||
missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install
|
||
vs_version: str | None = None # VapourSynth version string (for diagnostics)
|
||
vs_script_lib: str | None = None # path to libvapoursynth-script.so that passed
|
||
cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown"))
|
||
errors: list[str] = field(default_factory=list)
|
||
warnings: list[str] = field(default_factory=list)
|
||
|
||
@property
|
||
def ready(self) -> bool:
|
||
return (self.av1an_path is not None and self.ffmpeg_path is not None
|
||
and not self.errors and not self.missing_dep_pkgs)
|
||
|
||
@property
|
||
def dep_install_hint(self) -> str:
|
||
"""Generate a distro-specific install command for missing runtime deps."""
|
||
if not self.missing_dep_pkgs or self.distro.family == "unknown":
|
||
return ""
|
||
return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs))
|
||
|
||
@property
|
||
def install_hint(self) -> str:
|
||
"""Generate a distro-specific install command for missing packages."""
|
||
missing = []
|
||
if self.av1an_path is None:
|
||
missing.append(self.distro.av1an_pkg)
|
||
if self.ffmpeg_path is None:
|
||
missing.append(self.distro.ffmpeg_pkg)
|
||
if not missing:
|
||
return ""
|
||
return self.distro.install_cmd_template.format(packages=" ".join(missing))
|
||
|
||
|
||
def _find_binary(name: str, distro: DistroProfile) -> str | None:
|
||
"""
|
||
Search for a binary in: (1) standard PATH via shutil.which, then
|
||
(2) distro-specific extra paths (expanded ~). Returns first match.
|
||
"""
|
||
# Standard PATH search
|
||
found = shutil.which(name)
|
||
if found:
|
||
return found
|
||
|
||
# Distro-specific extra paths
|
||
for raw_path in distro.binary_extra_paths:
|
||
expanded = Path(raw_path).expanduser()
|
||
candidate = expanded / name
|
||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||
return str(candidate)
|
||
|
||
return None
|
||
|
||
|
||
def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]:
|
||
"""Check which encoder/decoder libraries ffmpeg was compiled with.
|
||
Runs ffmpeg -encoders ONCE and greps for all known encoder names.
|
||
Each entry: (key, [search_strings]) — any match = available.
|
||
|
||
v4 STABILITY FIX: the v3 search strings for libsvtav1 and libaom were
|
||
wrong. ffmpeg's `-encoders` output lists them as `libsvtav1` and
|
||
`libaom-av1` (no underscore between svt/av1, hyphen between aom/av1) —
|
||
NOT `libsvt_av1` / `libaom_av1`. This caused _probe_ffmpeg_libs to
|
||
report False for both even when they were installed, which then caused
|
||
_handle_vs_incompat to incorrectly tell the user "ffmpeg also lacks
|
||
libsvtav1" and abort — even though ffmpeg actually had it. The e2e
|
||
test test_probe_detects_ffmpeg_libs caught this.
|
||
"""
|
||
try:
|
||
res = subprocess.run(
|
||
[ffmpeg_bin, "-encoders"],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
output = res.stdout
|
||
except (OSError, subprocess.SubprocessError):
|
||
output = ""
|
||
|
||
# v4: search strings match the EXACT names ffmpeg -encoders prints.
|
||
# Verified against ffmpeg 7.x output:
|
||
# V..... libsvtav1 SVT-AV1(...) encoder (codec av1)
|
||
# V....D libaom-av1 libaom AV1 (codec av1)
|
||
# V....D libvpx-vp9 libvpx VP9 (codec vp9)
|
||
# The trailing space in each search string anchors the match to the
|
||
# encoder name boundary, preventing false positives like "libvpx_vp9"
|
||
# matching "libvpx_vp9_decoder" (which doesn't exist, but defensive).
|
||
# We also include the underscore variant as a fallback for older
|
||
# ffmpeg builds that may have used that spelling.
|
||
checks = [
|
||
("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]),
|
||
("libaom", ["libaom-av1 ", "libaom_av1", "aom_av1 "]),
|
||
("libvpx", ["libvpx-vp9 ", "libvpx_vp9", "vpx_vp9 "]),
|
||
("libx265", ["libx265 "]),
|
||
("libopus", ["libopus "]),
|
||
("libvorbis", ["libvorbis "]),
|
||
("flac", ["flac "]),
|
||
]
|
||
libs = {}
|
||
for lib_name, search_strings in checks:
|
||
libs[lib_name] = any(s in output for s in search_strings)
|
||
return libs
|
||
|
||
|
||
def _probe_av1an_version(av1an_bin: str) -> str | None:
|
||
"""Extract av1an version string."""
|
||
try:
|
||
# Try --version first, fall back to parsing --help header
|
||
for args in (["--version"], ["--help"]):
|
||
res = subprocess.run(
|
||
[av1an_bin] + args,
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
output = res.stdout or res.stderr
|
||
match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE)
|
||
if match:
|
||
return match.group(1)
|
||
if res.stdout.strip(): # If --version produced output but no version match
|
||
return res.stdout.strip().splitlines()[0][:60]
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def _probe_ffmpeg_version(ffmpeg_bin: str) -> str | None:
|
||
"""Extract ffmpeg version string."""
|
||
try:
|
||
res = subprocess.run(
|
||
[ffmpeg_bin, "-version"],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
first_line = res.stdout.splitlines()[0] if res.stdout else ""
|
||
match = re.search(r"ffmpeg version (\S+)", first_line)
|
||
return match.group(1) if match else first_line[:60]
|
||
except (OSError, subprocess.SubprocessError):
|
||
return None
|
||
|
||
|
||
def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]:
|
||
"""Check runtime dependencies that av1an needs to function.
|
||
Returns (deps_dict, missing_pkg_names).
|
||
|
||
Checks:
|
||
- VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen
|
||
to get the VSScript API — without this it panics with
|
||
'Failed to get VSScript API')
|
||
- Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc)
|
||
"""
|
||
deps: dict[str, bool] = {}
|
||
missing_pkgs: list[str] = []
|
||
|
||
# --- VapourSynth + VSScript (critical: av1an will panic without it) ---
|
||
# av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls
|
||
# vsscript_init() / vsscript_createScript() / etc. It does NOT use the
|
||
# Python vapoursynth module. The shared library and the VSScript API
|
||
# library can be packaged separately on some distros (e.g. Debian has
|
||
# libvapoursynth-script-dev). We must check what av1an actually loads.
|
||
#
|
||
# IMPORTANT: We do NOT call vsscript_init() in our probe. VSScript's init
|
||
# internally calls Py_Initialize(), which crashes/fails when Python is
|
||
# already running (our probe runs inside a Python subprocess). Instead,
|
||
# we verify the shared library exists AND can be dlopen'd (CDLL constructor
|
||
# resolves all .so dependencies). If it loads, it will work for av1an.
|
||
vs_ok = False
|
||
vs_detail = ""
|
||
vs_ver_str = ""
|
||
vs_lib_path = None
|
||
|
||
# --- Step 1: Direct filesystem check (most reliable) ---
|
||
# Check well-known install paths. Works even if ldconfig cache is stale.
|
||
_vs_script_search = [
|
||
"/usr/lib/libvapoursynth-script.so",
|
||
"/usr/lib/libvapoursynth_script.so",
|
||
"/usr/lib64/libvapoursynth-script.so",
|
||
"/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so",
|
||
"/usr/local/lib/libvapoursynth-script.so",
|
||
]
|
||
for p in _vs_script_search:
|
||
if Path(p).is_file():
|
||
vs_lib_path = p
|
||
break
|
||
|
||
# --- Step 2: Glob search on known lib dirs ---
|
||
if not vs_lib_path:
|
||
for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib",
|
||
"/usr/lib/x86_64-linux-gnu"):
|
||
d = Path(lib_dir)
|
||
if d.is_dir():
|
||
matches = list(d.glob("libvapoursynth-script.so*"))
|
||
# Prefer unversioned .so over .so.0 (dev symlink)
|
||
for m in sorted(matches, key=lambda p: p.name):
|
||
vs_lib_path = str(m)
|
||
break
|
||
if vs_lib_path:
|
||
break
|
||
|
||
# --- Step 3: ldconfig -p ---
|
||
if not vs_lib_path:
|
||
try:
|
||
res = subprocess.run(
|
||
["ldconfig", "-p"], capture_output=True, text=True, timeout=5,
|
||
)
|
||
for line in res.stdout.splitlines():
|
||
if "libvapoursynth-script" in line or "libvapoursynth_script" in line:
|
||
parts = line.split("=>")
|
||
if len(parts) >= 2:
|
||
vs_lib_path = parts[1].strip().split()[0]
|
||
break
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
|
||
# --- Step 4: ctypes.util.find_library ---
|
||
if not vs_lib_path:
|
||
try:
|
||
for name in ("vapoursynth-script", "vapoursynth_script"):
|
||
found = ctypes.util.find_library(name)
|
||
if found:
|
||
vs_lib_path = found
|
||
break
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
|
||
# --- Step 5: Distro-specific package file listing ---
|
||
if not vs_lib_path:
|
||
pkg_query = {
|
||
"arch": ["pacman", "-Ql", "vapoursynth"],
|
||
"debian": ["dpkg", "-L", "vapoursynth"],
|
||
"redhat": ["rpm", "-ql", "vapoursynth"],
|
||
"suse": ["rpm", "-ql", "vapoursynth"],
|
||
}
|
||
query_cmd = pkg_query.get(distro.family)
|
||
if query_cmd:
|
||
try:
|
||
res = subprocess.run(
|
||
query_cmd, capture_output=True, text=True, timeout=10,
|
||
)
|
||
for line in res.stdout.splitlines():
|
||
line = line.strip()
|
||
# Skip directory entries and grab .so files
|
||
if "libvapoursynth-script" in line and line.endswith(".so"):
|
||
vs_lib_path = line
|
||
break
|
||
if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path:
|
||
vs_lib_path = line # versioned .so as fallback
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
|
||
# --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) ---
|
||
# We do NOT gate on dlopen success. The library's constructor may call
|
||
# Py_Initialize() which conflicts with our Python subprocess, causing a
|
||
# silent segfault. av1an loads this library in its own fresh Rust process
|
||
# where no Python is running — so it works there even if our probe crashes.
|
||
# We only use dlopen to produce an optional warning.
|
||
vs_dlopen_warning = ""
|
||
if vs_lib_path:
|
||
try:
|
||
_escaped = vs_lib_path.replace("'", "\\'")
|
||
probe_code = (
|
||
"import ctypes; "
|
||
f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') "
|
||
f"except OSError as e: print(f'LOAD_FAIL|{{e}}') "
|
||
f"except Exception as e: print(f'LOAD_OTHER|{{e}}') "
|
||
)
|
||
res = subprocess.run(
|
||
[sys.executable, "-c", probe_code],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
out = res.stdout.strip()
|
||
if out == "LOAD_OK":
|
||
vs_ok = True
|
||
elif out:
|
||
vs_dlopen_warning = f"dlopen test failed: {out}"
|
||
vs_ok = True # file exists — let av1an try in its own process
|
||
else:
|
||
# subprocess produced no output — likely segfault in library
|
||
# constructor (Py_Initialize conflict). File still exists.
|
||
vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)"
|
||
vs_ok = True
|
||
except subprocess.TimeoutExpired:
|
||
vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)"
|
||
vs_ok = True
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
vs_dlopen_warning = f"dlopen probe error: {e}"
|
||
vs_ok = True
|
||
|
||
# Final gate: library file was found on disk
|
||
if vs_lib_path and not vs_ok:
|
||
vs_ok = True # file found on disk is sufficient
|
||
|
||
if vs_ok:
|
||
vs_detail = vs_lib_path or "found"
|
||
# Try to get VapourSynth version from the core lib for diagnostics
|
||
try:
|
||
ver_probe = (
|
||
"import ctypes, ctypes.util; "
|
||
"_lib = ctypes.util.find_library('vapoursynth'); "
|
||
"if not _lib: "
|
||
" import subprocess as _sp; "
|
||
" _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); "
|
||
" _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() "
|
||
" if 'libvapoursynth.so.' in l and 'script' not in l]; "
|
||
" _lib = _m[0] if _m else None; "
|
||
"if _lib: "
|
||
" try: "
|
||
" _h = ctypes.CDLL(_lib); "
|
||
" _fn = _h.vapoursynth_version; "
|
||
" _fn.restype = ctypes.c_int; "
|
||
" print(_fn()) "
|
||
" except: pass "
|
||
)
|
||
res = subprocess.run(
|
||
[sys.executable, "-c", ver_probe],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
ver_out = res.stdout.strip()
|
||
if ver_out and ver_out.isdigit() and int(ver_out) > 0:
|
||
vs_ver_str = f"R{ver_out}"
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
else:
|
||
if not vs_detail:
|
||
vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)"
|
||
|
||
deps["vapoursynth"] = vs_ok
|
||
if not vs_ok:
|
||
# Determine which package(s) to suggest.
|
||
# Most distros bundle VSScript into the main 'vapoursynth' package,
|
||
# but some split it (Debian/Ubuntu: libvapoursynth-script-dev).
|
||
# Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs.
|
||
if distro.vsscript_pkg:
|
||
missing_pkgs.append(distro.vsscript_pkg)
|
||
elif "vapoursynth" in distro.dep_pkgs:
|
||
missing_pkgs.append(distro.dep_pkgs["vapoursynth"])
|
||
deps["vs_detail"] = False # extra key for the diagnostic message
|
||
else:
|
||
deps["vs_detail"] = True
|
||
|
||
# --- Encoder binaries (av1an invokes these directly, not via ffmpeg) ---
|
||
for enc_key, binary_names in distro.encoder_binaries.items():
|
||
found = False
|
||
for bin_name in binary_names:
|
||
if _find_binary(bin_name, distro) is not None:
|
||
found = True
|
||
break
|
||
deps[enc_key] = found
|
||
if not found:
|
||
# Map encoder key to dep_pkgs key
|
||
dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"}
|
||
dep_key = dep_key_map.get(enc_key, enc_key)
|
||
if dep_key in distro.dep_pkgs:
|
||
pkg_name = distro.dep_pkgs[dep_key]
|
||
if pkg_name not in missing_pkgs:
|
||
missing_pkgs.append(pkg_name)
|
||
|
||
# --- ffprobe (needed for input file validation) ---
|
||
# Already checked in probe_environment() for the main binary, but let's
|
||
# make sure the dep dict reflects it for consistency.
|
||
# (ffprobe_path is set separately in probe_environment)
|
||
|
||
return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning
|
||
|
||
|
||
def probe_environment() -> EnvProbe:
|
||
"""
|
||
Distro-aware binary detection + av1an flag compatibility probe +
|
||
ffmpeg library availability check.
|
||
"""
|
||
distro = detect_distro()
|
||
result = EnvProbe(distro=distro)
|
||
result.cpu = detect_cpu_topology()
|
||
cpu = result.cpu
|
||
|
||
result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})")
|
||
result.warnings.append(
|
||
f"CPU: {cpu.model_name} — {cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical"
|
||
)
|
||
|
||
# --- Binary detection (distro-aware path search) ---
|
||
for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]:
|
||
path = _find_binary(name, distro)
|
||
if path is None:
|
||
result.errors.append(f"Missing binary: {name}")
|
||
else:
|
||
setattr(result, attr, path)
|
||
|
||
# --- Install hint for missing binaries ---
|
||
if result.install_hint:
|
||
result.warnings.append(f"Install command: {result.install_hint}")
|
||
|
||
# --- FFmpeg version + library probe ---
|
||
if result.ffmpeg_path:
|
||
result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path)
|
||
if result.ffmpeg_version:
|
||
result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}")
|
||
result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path)
|
||
|
||
# Warn about missing AUDIO libs (video codecs are handled by av1an's own
|
||
# encoder binaries — ffmpeg's video encoder list is irrelevant)
|
||
audio_lib_warnings = {
|
||
"Opus": "libopus",
|
||
"Vorbis": "libvorbis",
|
||
"FLAC": "flac",
|
||
}
|
||
for codec_label, lib_name in audio_lib_warnings.items():
|
||
if not result.ffmpeg_libs.get(lib_name, False):
|
||
result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)")
|
||
|
||
# --- Av1an version ---
|
||
if result.av1an_path:
|
||
result.av1an_version = _probe_av1an_version(result.av1an_path)
|
||
if result.av1an_version:
|
||
result.warnings.append(f"av1an version: {result.av1an_version}")
|
||
|
||
# --- Av1an flag compatibility probe ---
|
||
if result.av1an_path:
|
||
try:
|
||
help_out = subprocess.run(
|
||
[result.av1an_path, "--help"],
|
||
capture_output=True, text=True, timeout=15,
|
||
).stdout
|
||
result.av1an_flags = {
|
||
"worker": "--workers" if "--workers" in help_out else "-w",
|
||
"video_params": "--video-params" if "--video-params" in help_out else "-v",
|
||
"audio_params": "--audio-params" if "--audio-params" in help_out else "-a",
|
||
}
|
||
|
||
# Detect which encoder names this av1an build actually accepts.
|
||
# Substring matching on --help is unreliable (e.g. "svt" appears in
|
||
# descriptions but the real name may be "svtav1" or "svt_av1").
|
||
# Instead, pass a bogus encoder name and parse the clap error which
|
||
# lists all valid values.
|
||
svt_name = _detect_av1an_svt_encoder(result.av1an_path)
|
||
if svt_name:
|
||
result.av1an_flags["svt_name"] = svt_name
|
||
result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'")
|
||
else:
|
||
# Absolute fallback — should rarely be needed
|
||
result.av1an_flags["svt_name"] = "svt_av1"
|
||
result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)")
|
||
|
||
# Check for chunk-method availability (differs by av1an version/distro)
|
||
if "--chunk-method" in help_out:
|
||
result.av1an_flags["has_chunk_method"] = True
|
||
|
||
# Check for --temp flag (lets us relocate av1an work dir out of user folders)
|
||
if "--temp" in help_out:
|
||
result.av1an_flags["has_temp"] = True
|
||
result.av1an_flags["temp_flag"] = "--temp"
|
||
elif "-T" in help_out:
|
||
result.av1an_flags["has_temp"] = True
|
||
result.av1an_flags["temp_flag"] = "-T"
|
||
|
||
# Check for -s/segments flag (newer av1an)
|
||
if "-s" in help_out or "--scenes" in help_out:
|
||
result.av1an_flags["has_scenes"] = True
|
||
|
||
# Detect concat method: prefer mkvmerge, fall back to ffmpeg
|
||
if shutil.which("mkvmerge"):
|
||
result.av1an_flags["concat_method"] = "mkvmerge"
|
||
else:
|
||
result.av1an_flags["concat_method"] = "ffmpeg"
|
||
|
||
# v4.0.0: Probe VapourSynth source plugins. When NONE of the
|
||
# source plugins (lsmash, ffms2, bestsource, dgdecnv) are
|
||
# installed, av1an falls back to the Hybrid chunk method —
|
||
# which fails on phone-recorded MP4s with sparse keyframes
|
||
# (the "works up until near the end, never saves chunks into
|
||
# a full file" bug). Pre-setting chunk_method_override="select"
|
||
# avoids the wasted first-attempt + retry on every file.
|
||
#
|
||
# The select method uses VapourSynth's select() filter to
|
||
# extract frames one-by-one — slower than ffms2/bestsource
|
||
# but reliable for any file VapourSynth can open.
|
||
vs_plugins = _probe_vs_source_plugins()
|
||
result.av1an_flags["vs_plugins"] = vs_plugins
|
||
if vs_plugins:
|
||
result.warnings.append(
|
||
f"VapourSynth source plugins: {', '.join(vs_plugins)} "
|
||
f"— av1an will auto-select a fast chunk method"
|
||
)
|
||
else:
|
||
result.warnings.append(
|
||
"VapourSynth source plugins: NONE found — "
|
||
"forcing --chunk-method select (reliable but slower). "
|
||
"Install vapoursynth-{lsmash,ffms2,bestsource} for faster "
|
||
"chunk-parallel encoding."
|
||
)
|
||
result.av1an_flags["chunk_method_override"] = "select"
|
||
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
result.errors.append(f"av1an probe failed: {e}")
|
||
|
||
# --- Distro-specific notes ---
|
||
if distro.notes:
|
||
result.warnings.append(f"Distro note: {distro.notes}")
|
||
|
||
# --- Runtime dependency probe (vapoursynth, encoder binaries) ---
|
||
if result.av1an_path:
|
||
deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro)
|
||
result.runtime_deps = deps
|
||
result.missing_dep_pkgs = missing_pkgs
|
||
if deps.get("vapoursynth"):
|
||
result.vs_version = vs_ver
|
||
result.vs_script_lib = vs_detail
|
||
|
||
# Log VapourSynth/VSScript with extra detail
|
||
vs_status = "OK" if deps.get("vapoursynth") else "MISSING"
|
||
result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}")
|
||
if deps.get("vapoursynth"):
|
||
# vs_detail is the library path on success
|
||
result.warnings.append(f" VSScript lib: {vs_detail}")
|
||
if vs_dlopen_warn:
|
||
result.warnings.append(f" dlopen note: {vs_dlopen_warn}")
|
||
else:
|
||
# vs_detail is the failure reason
|
||
result.warnings.append(f" Reason: {vs_detail}")
|
||
|
||
# Log encoder binary deps (skip vs_detail key)
|
||
for dep_name, present in deps.items():
|
||
if dep_name in ("vapoursynth", "vs_detail"):
|
||
continue
|
||
status = "OK" if present else "MISSING"
|
||
result.warnings.append(f"Dependency: {dep_name} = {status}")
|
||
|
||
if missing_pkgs:
|
||
hint = result.dep_install_hint
|
||
result.errors.append(
|
||
f"Missing runtime dependencies: {', '.join(missing_pkgs)}"
|
||
)
|
||
if hint:
|
||
result.errors.append(f" FIX: {hint}")
|
||
|
||
return result
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# FFPREPBE VALIDATION
|
||
# ──────────────────────────────────────────────
|
||
|
||
def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> dict[str, object] | None:
|
||
"""Returns stream info dict or None if invalid/unreadable."""
|
||
try:
|
||
res = subprocess.run(
|
||
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
||
"-show_format", "-show_streams", str(filepath)],
|
||
capture_output=True, text=True, timeout=30,
|
||
)
|
||
if res.returncode != 0:
|
||
return None
|
||
return json.loads(res.stdout)
|
||
except (OSError, subprocess.SubprocessError, ValueError):
|
||
# ValueError covers json.JSONDecodeError
|
||
return None
|
||
|
||
|
||
def ffprobe_duration(filepath: Path, ffprobe_bin: str) -> float | None:
|
||
"""Return media duration in seconds via ffprobe, or None on failure.
|
||
|
||
Used by the EncoderWorker post-encode integrity check to compare source
|
||
and output durations. Modeled after :func:`ffprobe_validate` — every
|
||
failure path returns ``None`` so the caller can treat unverifiable
|
||
durations as "skip the check" rather than crashing the worker thread.
|
||
"""
|
||
try:
|
||
res = subprocess.run(
|
||
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
||
"-show_format", "-show_entries", "format=duration",
|
||
str(filepath)],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
if res.returncode != 0 or not res.stdout:
|
||
return None
|
||
data = json.loads(res.stdout)
|
||
dur_str = (data.get("format") or {}).get("duration")
|
||
if dur_str is None:
|
||
return None
|
||
return float(dur_str)
|
||
except (OSError, subprocess.SubprocessError, ValueError):
|
||
# ValueError covers json.JSONDecodeError and float() parse failures
|
||
return None
|
||
|
||
|
||
def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool:
|
||
"""Verify that an encoded file actually has the requested output resolution.
|
||
|
||
Returns True if the output matches (or is within 2px due to force_divisible_by=2),
|
||
False otherwise.
|
||
"""
|
||
try:
|
||
res = subprocess.run(
|
||
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
|
||
"-show_streams", "-select_streams", "v:0", str(output_path)],
|
||
capture_output=True, text=True, timeout=15,
|
||
)
|
||
if res.returncode != 0:
|
||
return True # can't verify, don't block
|
||
data = json.loads(res.stdout)
|
||
streams = data.get("streams", [])
|
||
if not streams:
|
||
return True
|
||
ow = int(streams[0].get("width", 0) or 0)
|
||
oh = int(streams[0].get("height", 0) or 0)
|
||
# Allow 2px tolerance (force_divisible_by=2 rounding)
|
||
if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2:
|
||
return True
|
||
return False
|
||
except (OSError, subprocess.SubprocessError, ValueError):
|
||
# ValueError covers json.JSONDecodeError and int() parse failures
|
||
return True # can't verify, don't block
|
||
|
||
|
||
def _identify_file_type(file_path: Path) -> str:
|
||
"""Run `file` on the given path and return the type string.
|
||
|
||
v5-03: Used by _validate_file to tell the user WHAT a file actually is
|
||
when ffprobe can't read it. This immediately reveals:
|
||
- "HTML document" → failed yt-dlp download (YouTube error page saved as .mp4)
|
||
- "ASCII text" → same as above (different yt-dlp version)
|
||
- "data" → truncated, encrypted, or partial download
|
||
- "ISO Media, MP4 Base Media v1" → valid MP4 that ffprobe just can't parse (rare)
|
||
|
||
Returns the first line of `file` output (minus the filename prefix),
|
||
or an empty string if `file` is not available or fails.
|
||
"""
|
||
file_bin = shutil.which("file")
|
||
if not file_bin:
|
||
return ""
|
||
try:
|
||
res = subprocess.run(
|
||
[file_bin, "-b", str(file_path)],
|
||
capture_output=True, text=True, timeout=5,
|
||
)
|
||
if res.returncode == 0:
|
||
return res.stdout.strip()
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
return ""
|
||
|
||
|
||
def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None:
|
||
"""Determine the exact encoder name av1an accepts for SVT-AV1.
|
||
|
||
Strategy (in order):
|
||
1. Run ``av1an --encoder __PROBE__`` and parse clap's error for
|
||
``[possible values: ...]``.
|
||
2. Parse ``--help`` for ``[default: <name>]`` next to ``--encoder``.
|
||
3. Regex fallback on the error output.
|
||
"""
|
||
try:
|
||
# --- Method 1: clap error with possible values ---
|
||
res = subprocess.run(
|
||
[av1an_bin, "--encoder", "__PROBE_TEST__"],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
stderr = res.stderr or ""
|
||
stdout = res.stdout or ""
|
||
combined = stderr + stdout
|
||
|
||
m = re.search(r"\[possible values:\s*([^\]]+)\]", combined)
|
||
if m:
|
||
values = [v.strip().rstrip(',') for v in m.group(1).split()]
|
||
for v in values:
|
||
if "svt" in v.lower():
|
||
return v
|
||
|
||
# --- Method 2: parse --help for encoder default value ---
|
||
help_res = subprocess.run(
|
||
[av1an_bin, "--help"],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
help_text = (help_res.stdout or "") + (help_res.stderr or "")
|
||
# Look for pattern: --encoder <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 (OSError, subprocess.SubprocessError):
|
||
return None
|
||
|
||
|
||
def _av1an_env() -> dict[str, str]:
|
||
"""Build an env dict for subprocess that includes ~/.local/lib in LD_LIBRARY_PATH.
|
||
|
||
When VapourSynth is built from git and installed to ~/.local/, the linker
|
||
won't find libvapoursynth-script.so unless LD_LIBRARY_PATH points there.
|
||
This function ensures every av1an invocation inherits that path.
|
||
"""
|
||
env = os.environ.copy()
|
||
local_lib = str(Path.home() / ".local" / "lib")
|
||
existing = env.get("LD_LIBRARY_PATH", "")
|
||
if local_lib not in existing:
|
||
env["LD_LIBRARY_PATH"] = f"{local_lib}:{existing}".rstrip(":")
|
||
return env
|
||
|
||
|
||
# v4.0.0: VapourSynth source plugin probe. Returns a list of available
|
||
# plugin names (e.g. ["lsmash", "ffms2", "bestsource"]). When the list
|
||
# is empty, av1an falls back to the Hybrid chunk method — which fails
|
||
# on phone-recorded MP4s with sparse keyframes. The caller uses this
|
||
# to decide whether to pre-set chunk_method_override="select".
|
||
_VS_PLUGIN_PROBE_PATHS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
# (plugin_name, candidate .so filenames)
|
||
# lsmash: imported as `havsfmt` / `lsmas` in VS; .so is libvslsmashsource.so
|
||
("lsmash", ("libvslsmashsource.so",)),
|
||
# ffms2: imported as `ffms2` in VS; .so is libffms2.so (sometimes libvffms2.so)
|
||
("ffms2", ("libffms2.so", "libvffms2.so")),
|
||
# bestsource: imported as `bestsource` / `bs` in VS
|
||
("bestsource", ("libbestsource.so", "libvsbestsource.so")),
|
||
# dgdecnv: NVIDIA hardware-accelerated decoder
|
||
("dgdecnv", ("libdgdecnv.so",)),
|
||
# vszip: high-performance resize/format plugins
|
||
("vszip", ("libvszip.so",)),
|
||
)
|
||
|
||
|
||
def _probe_vs_source_plugins() -> list[str]:
|
||
"""Probe for VapourSynth source plugins in standard locations.
|
||
|
||
Searches (in order):
|
||
1. ``$XDG_DATA_HOME/vapoursynth/`` (or ``~/.local/share/vapoursynth/``)
|
||
2. ``~/.local/lib/vapoursynth/`` (user-installed plugins from source)
|
||
3. ``/usr/lib/vapoursynth/`` (distro-installed plugins)
|
||
4. ``/usr/local/lib/vapoursynth/`` (manually installed)
|
||
5. ``/usr/lib/x86_64-linux-gnu/vapoursynth/`` (Debian multiarch)
|
||
|
||
Returns a sorted list of available plugin names. Empty list = no
|
||
source plugins found, which means av1an will fall back to Hybrid
|
||
chunk method and likely fail on phone-recorded MP4s.
|
||
|
||
Pure-stdlib (no vapoursynth Python bindings required). Best-effort:
|
||
if a plugin is installed but not in these paths, this probe will
|
||
miss it — but the av1an runtime will still detect it, and the
|
||
v4.0.0 retry in _encode_one will still switch to select on first
|
||
failure.
|
||
"""
|
||
search_dirs: list[Path] = []
|
||
xdg_data = os.environ.get("XDG_DATA_HOME", "")
|
||
if xdg_data:
|
||
search_dirs.append(Path(xdg_data) / "vapoursynth")
|
||
else:
|
||
search_dirs.append(Path.home() / ".local" / "share" / "vapoursynth")
|
||
search_dirs.append(Path.home() / ".local" / "lib" / "vapoursynth")
|
||
search_dirs.append(Path("/usr/lib/vapoursynth"))
|
||
search_dirs.append(Path("/usr/local/lib/vapoursynth"))
|
||
search_dirs.append(Path("/usr/lib/x86_64-linux-gnu/vapoursynth"))
|
||
|
||
found: set[str] = set()
|
||
for d in search_dirs:
|
||
if not d.is_dir():
|
||
continue
|
||
try:
|
||
entries = list(d.iterdir())
|
||
except OSError:
|
||
continue
|
||
for entry in entries:
|
||
if not entry.is_file():
|
||
continue
|
||
name_lower = entry.name.lower()
|
||
for plugin_name, so_names in _VS_PLUGIN_PROBE_PATHS:
|
||
for so_name in so_names:
|
||
if so_name in name_lower:
|
||
found.add(plugin_name)
|
||
break
|
||
|
||
return sorted(found)
|
||
|
||
|
||
def _av1an_vsscript_smoke_test(
|
||
av1an_bin: str,
|
||
ffmpeg_bin: str,
|
||
av1an_flags: dict,
|
||
svt_name: str = "svt_av1",
|
||
timeout: int = 30,
|
||
) -> tuple[bool, str]:
|
||
"""Pre-flight test: create a tiny video and try to run av1an on it.
|
||
|
||
This catches 'Failed to get VSScript API' panics BEFORE the real queue
|
||
starts. File-existence checks for libvapoursynth-script.so pass even
|
||
when the ABI is incompatible (av1an's Rust vapoursynth crate built
|
||
against a different VS version). Only actually invoking av1an reveals
|
||
the mismatch.
|
||
|
||
Returns (ok, detail_message).
|
||
ok=True -> av1an initialized VSScript successfully.
|
||
ok=False -> av1an panicked or failed; detail_message explains why.
|
||
"""
|
||
|
||
with tempfile.TemporaryDirectory(prefix="av1an_smoke_") as tmpdir:
|
||
test_in = Path(tmpdir) / "test_smoke.mkv"
|
||
test_out = Path(tmpdir) / "test_smoke_out.mkv"
|
||
|
||
# Create a 1-second 64x64 black video (video-only is enough to
|
||
# trigger VSScript init in av1an — no audio needed).
|
||
gen_cmd = [
|
||
ffmpeg_bin,
|
||
"-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24",
|
||
"-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(test_in),
|
||
]
|
||
try:
|
||
res = subprocess.run(gen_cmd, capture_output=True, text=True, timeout=15)
|
||
if res.returncode != 0:
|
||
return False, f"ffmpeg test-video failed (rc={res.returncode}): {(res.stderr or '')[-200:]}"
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
return False, f"Could not generate smoke test video: {e}"
|
||
|
||
if not test_in.exists():
|
||
return False, "Smoke test video was not created by ffmpeg"
|
||
|
||
# Build minimal av1an command
|
||
worker_flag = av1an_flags.get("worker", "--workers")
|
||
vparams_flag = av1an_flags.get("video_params", "--video-params")
|
||
aparams_flag = av1an_flags.get("audio_params", "--audio-params")
|
||
|
||
cmd = [
|
||
av1an_bin,
|
||
"-i", str(test_in),
|
||
worker_flag, "1",
|
||
"--encoder", svt_name,
|
||
vparams_flag, "--preset 8 --crf 40 --keyint 240",
|
||
"-o", str(test_out),
|
||
]
|
||
|
||
# Use chunk-method select if available (triggers VSScript init)
|
||
if av1an_flags.get("has_chunk_method"):
|
||
cmd.extend(["--chunk-method", "select"])
|
||
|
||
# SEI CERT ERR01-C: catch only the specific exception types we
|
||
# expect from subprocess.run; never swallow unrelated failures.
|
||
try:
|
||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
|
||
env=_av1an_env())
|
||
except subprocess.TimeoutExpired:
|
||
# Timeout is a real failure — av1an is hanging. Do NOT mask it.
|
||
return False, f"SMOKE_TIMEOUT: av1an smoke test exceeded {timeout}s — likely hung in VSScript init or encoder spawn"
|
||
except FileNotFoundError as e:
|
||
return False, f"SMOKE_BIN_MISSING: {e}"
|
||
except OSError as e:
|
||
return False, f"SMOKE_OS_ERROR: {e}"
|
||
|
||
stderr = res.stderr or ""
|
||
stdout = res.stdout or ""
|
||
|
||
# Success requires BOTH rc==0 AND the output file actually exists.
|
||
# The previous code returned True on any non-VSScript failure, which
|
||
# masked real bugs (missing encoder binary, concat failure, etc.)
|
||
# and led to "chunks but never saves a file" symptoms in production.
|
||
if res.returncode == 0 and test_out.exists():
|
||
test_out.unlink(missing_ok=True)
|
||
return True, "av1an VSScript init OK"
|
||
|
||
# Classify the known failure modes by inspecting stderr.
|
||
if "Failed to get VSScript API" in stderr:
|
||
return False, "VSScript_API_INCOMPAT"
|
||
|
||
if "invalid value" in stderr and "--encoder" in stderr:
|
||
return False, f"INVALID_ENCODER: {stderr[-200:]}"
|
||
|
||
if "No usable encoder found" in stderr:
|
||
return False, f"ENCODER_BIN_MISSING: {stderr[-300:]}"
|
||
|
||
# Unknown failure — return False so the caller can offer ffmpeg
|
||
# fallback or rebuild. Include the FULL stderr (not just the tail)
|
||
# so the user can see the actual error and the diagnostic patterns
|
||
# below can match on it.
|
||
combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:]
|
||
return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}"
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# TEMP DIRECTORY MANAGEMENT
|
||
# ──────────────────────────────────────────────
|
||
|
||
_APP_CACHE_DIR: Path | None = None
|
||
|
||
def _get_app_temp_dir() -> Path:
|
||
"""Return the shared temp directory for all intermediate files.
|
||
|
||
Priority:
|
||
1. ``~/.cache/OpenTranscode/tmp/`` (XDG-compliant, persistent across reboots)
|
||
2. ``/tmp/OpenTranscode/`` (fallback if home cache is unwritable)
|
||
|
||
The directory is created on first call. All temp intermediates
|
||
(pre-scaled MKVs, av1an work dirs) go here so the user's video
|
||
folders stay clean.
|
||
|
||
v3 (OTC-013, SEI CERT FIO09-C): the directory is created with
|
||
``mode=0o700`` so that other users on the system cannot create
|
||
symlinks inside it (which the cleanup sweep would then follow and
|
||
delete arbitrary files). The mode is verified after creation in
|
||
case the directory already existed with looser permissions.
|
||
"""
|
||
global _APP_CACHE_DIR
|
||
if _APP_CACHE_DIR is not None:
|
||
return _APP_CACHE_DIR
|
||
|
||
# Try XDG cache dir first
|
||
xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
|
||
if xdg_cache:
|
||
candidate = Path(xdg_cache) / "OpenTranscode" / "tmp"
|
||
else:
|
||
candidate = Path.home() / ".cache" / "OpenTranscode" / "tmp"
|
||
|
||
if _mkdir_private(candidate):
|
||
_APP_CACHE_DIR = candidate
|
||
return _APP_CACHE_DIR
|
||
|
||
# Fallback: /tmp/OpenTranscode
|
||
fallback = Path("/tmp/OpenTranscode")
|
||
if _mkdir_private(fallback):
|
||
_APP_CACHE_DIR = fallback
|
||
return _APP_CACHE_DIR
|
||
|
||
# Last resort: system temp
|
||
_APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode"
|
||
_mkdir_private(_APP_CACHE_DIR)
|
||
return _APP_CACHE_DIR
|
||
|
||
|
||
def _mkdir_private(path: Path) -> bool:
|
||
"""Create *path* (and parents) with mode 0o700.
|
||
|
||
Returns True on success, False on OSError/PermissionError.
|
||
|
||
SEI CERT FIO09-C: if the directory already existed with looser
|
||
permissions (e.g. created by a previous version of this app, or by
|
||
another user before us), we attempt to tighten the mode with
|
||
os.chmod(). The chmod may fail silently if we don't own the dir —
|
||
that's an accepted risk, logged but not fatal.
|
||
"""
|
||
try:
|
||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
# mkdir(mode=) is masked by umask; explicitly chmod to be sure
|
||
os.chmod(path, 0o700)
|
||
return True
|
||
except (OSError, PermissionError):
|
||
return False
|
||
|
||
|
||
def _worker_temp_dir(worker_pid: int) -> Path:
|
||
"""Return a per-worker temp subdir named by PID.
|
||
|
||
v3: each EncoderWorker gets its own subdir under the shared app temp
|
||
dir, so the final cleanup sweep can safely nuke only this worker's
|
||
intermediates without affecting a concurrent worker. The subdir is
|
||
also created with mode=0o700 (FIO09-C).
|
||
"""
|
||
base = _get_app_temp_dir()
|
||
sub = base / f"worker-{worker_pid}"
|
||
_mkdir_private(sub)
|
||
return sub
|
||
|
||
|
||
def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv",
|
||
worker_dir: Path | None = None) -> Path:
|
||
"""Build a unique temp path for *file_path* inside the app temp dir.
|
||
|
||
Uses a short hash of the original absolute path to avoid collisions
|
||
when files in different subdirs share the same stem.
|
||
|
||
v3: if *worker_dir* is provided (per-worker subdir), the temp file
|
||
lands there instead of the shared parent. This isolates concurrent
|
||
workers' intermediates from each other.
|
||
"""
|
||
tmp_dir = worker_dir if worker_dir is not None else _get_app_temp_dir()
|
||
# Hash the absolute source path for uniqueness
|
||
path_hash = hashlib.sha256(str(file_path.resolve()).encode()).hexdigest()[:12]
|
||
return tmp_dir / f"{file_path.stem}.{path_hash}{suffix}"
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# KEEP-AWAKE (v6-06: anti-sleep / anti-hibernate)
|
||
# ──────────────────────────────────────────────
|
||
|
||
class KeepAwake:
|
||
"""Keep the system awake during a transcode.
|
||
|
||
v6-06: Uses systemd-inhibit (preferred) to block sleep/idle at the
|
||
systemd level, plus optional xdotool mouse nudging every 60s as a
|
||
belt-and-suspenders fallback. The user sees a bright-red banner in
|
||
the UI while active. Mouse movement is minimal (1px jitter, not
|
||
constant) so the user can still click STOP or close the window.
|
||
|
||
Usage::
|
||
ka = KeepAwake(log_fn=worker.log_msg.emit)
|
||
ka.start()
|
||
try:
|
||
while encoding:
|
||
ka.update_eta(remaining_seconds)
|
||
time.sleep(5)
|
||
finally:
|
||
ka.stop()
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
log_fn=None,
|
||
enable_mouse_nudge: bool = False,
|
||
nudge_interval: int = 60,
|
||
):
|
||
self._log_fn = log_fn or (lambda msg: None)
|
||
self._enable_mouse_nudge = enable_mouse_nudge and bool(shutil.which("xdotool"))
|
||
self._nudge_interval = nudge_interval
|
||
self._inhibit_proc: subprocess.Popen | None = None
|
||
self._nudge_count = 0
|
||
self._last_nudge = 0.0
|
||
self._start_time = 0.0
|
||
self._eta_seconds: float | None = None
|
||
self._active = False
|
||
|
||
def start(self) -> None:
|
||
"""Acquire systemd-inhibit handle. Safe to call multiple times."""
|
||
if self._active:
|
||
return
|
||
self._active = True
|
||
self._start_time = time.monotonic()
|
||
self._acquire_inhibit()
|
||
if self._enable_mouse_nudge:
|
||
self._log_fn("KEEP-AWAKE: mouse nudging enabled (xdotool, every "
|
||
f"{self._nudge_interval}s)")
|
||
else:
|
||
self._log_fn("KEEP-AWAKE: mouse nudging disabled (xdotool not found "
|
||
"or not requested)")
|
||
|
||
def stop(self) -> None:
|
||
"""Release the inhibit handle and stop nudging."""
|
||
if not self._active:
|
||
return
|
||
self._active = False
|
||
self._release_inhibit()
|
||
if self._nudge_count > 0:
|
||
self._log_fn(f"KEEP-AWAKE: stopped (mouse nudged {self._nudge_count} times)")
|
||
|
||
def update_eta(self, remaining_seconds: float | None) -> None:
|
||
"""Update the ETA shown in the banner. None = unknown."""
|
||
self._eta_seconds = remaining_seconds
|
||
|
||
def tick(self) -> str | None:
|
||
"""Called periodically (e.g. every 5s) from the UI thread.
|
||
|
||
Performs mouse nudge if interval has elapsed.
|
||
Returns the current banner text, or None if keep-awake is not active.
|
||
"""
|
||
if not self._active:
|
||
return None
|
||
now = time.monotonic()
|
||
if self._enable_mouse_nudge and (now - self._last_nudge) >= self._nudge_interval:
|
||
self._nudge_mouse()
|
||
self._last_nudge = now
|
||
return self.banner_text()
|
||
|
||
def banner_text(self) -> str:
|
||
"""Return the banner text for the UI (styled bright-red in QSS)."""
|
||
eta_str = self._format_eta(self._eta_seconds)
|
||
elapsed = time.monotonic() - self._start_time
|
||
elapsed_str = self._format_eta(elapsed)
|
||
nudge_str = f" | mouse: {self._nudge_count}" if self._nudge_count > 0 else ""
|
||
return (
|
||
f"KEEP-AWAKE ACTIVE — system will not sleep | "
|
||
f"elapsed: {elapsed_str} | ETA: {eta_str}{nudge_str}"
|
||
)
|
||
|
||
def _format_eta(self, seconds: float | None) -> str:
|
||
if seconds is None:
|
||
return "unknown"
|
||
if seconds < 0:
|
||
return "almost done"
|
||
hours = int(seconds // 3600)
|
||
mins = int((seconds % 3600) // 60)
|
||
secs = int(seconds % 60)
|
||
if hours > 0:
|
||
return f"~{hours}h{mins:02d}m"
|
||
if mins > 0:
|
||
return f"~{mins}m{secs:02d}s"
|
||
return f"~{secs}s"
|
||
|
||
def _acquire_inhibit(self) -> None:
|
||
"""Fork a systemd-inhibit subprocess that holds the sleep/idle
|
||
inhibit handle for the duration of the transcode."""
|
||
inhibit_bin = shutil.which("systemd-inhibit")
|
||
if not inhibit_bin:
|
||
self._log_fn("KEEP-AWAKE: systemd-inhibit not found — "
|
||
"system may sleep during transcode")
|
||
return
|
||
try:
|
||
self._inhibit_proc = subprocess.Popen(
|
||
[
|
||
inhibit_bin,
|
||
"--what=sleep:idle",
|
||
"--who=OpenTranscode",
|
||
"--why=Batch video transcode in progress",
|
||
"--mode=block",
|
||
"sleep", "infinity",
|
||
],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
self._log_fn("KEEP-AWAKE: systemd-inhibit active (sleep/idle blocked)")
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
self._log_fn(f"KEEP-AWAKE: failed to acquire systemd-inhibit: {e}")
|
||
self._inhibit_proc = None
|
||
|
||
def _release_inhibit(self) -> None:
|
||
"""Kill the systemd-inhibit subprocess to release the handle."""
|
||
if self._inhibit_proc is None:
|
||
return
|
||
try:
|
||
self._inhibit_proc.terminate()
|
||
self._inhibit_proc.wait(timeout=3)
|
||
except subprocess.TimeoutExpired:
|
||
self._inhibit_proc.kill()
|
||
self._inhibit_proc.wait(timeout=1)
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass
|
||
finally:
|
||
self._inhibit_proc = None
|
||
self._log_fn("KEEP-AWAKE: systemd-inhibit released")
|
||
|
||
def _nudge_mouse(self) -> None:
|
||
"""Move the mouse 1 pixel to prevent screen-blank.
|
||
|
||
Alternates +1px right / -1px left so the cursor ends up where it
|
||
started after every pair of nudges.
|
||
"""
|
||
xdotool = shutil.which("xdotool")
|
||
if not xdotool:
|
||
return
|
||
delta = 1 if (self._nudge_count % 2 == 0) else -1
|
||
try:
|
||
subprocess.run(
|
||
[xdotool, "mousemove_relative", "--", str(delta), "0"],
|
||
capture_output=True, timeout=3,
|
||
)
|
||
self._nudge_count += 1
|
||
except (OSError, subprocess.SubprocessError):
|
||
pass # best-effort — don't crash the transcode over a nudge
|
||
|
||
@property
|
||
def is_active(self) -> bool:
|
||
return self._active
|
||
|
||
@property
|
||
def has_inhibit(self) -> bool:
|
||
return self._inhibit_proc is not None
|
||
|
||
def __enter__(self):
|
||
self.start()
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
self.stop()
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# INTELLIGENT WORKER-COUNT MATH (v4.1.0)
|
||
# ──────────────────────────────────────────────
|
||
|
||
def _compute_intelligent_worker_count_for(
|
||
env: EnvProbe,
|
||
max_workers: int | None = None,
|
||
threads_per_worker_override: int | None = None,
|
||
) -> tuple[int, int]:
|
||
"""Compute ``(worker_count, threads_per_worker)`` to prevent thread
|
||
oversubscription on high-core-count machines.
|
||
|
||
PROBLEM (v4.0.0 and earlier)
|
||
----------------------------
|
||
``run()`` set ``worker_count = max(1, physical_cores - 1)`` and
|
||
passed no per-chunk thread cap to the encoder. SVT-AV1's default
|
||
``--threads 0`` means "use all logical cores," so each chunk-parallel
|
||
worker spawned an SvtAv1EncApp process that grabbed every logical
|
||
thread. On a 28-thread Xeon (14 physical cores), 13 workers × 28
|
||
threads = ~364 active threads on 28 logical CPUs — the kernel
|
||
scheduler drowns, I/O wait escalates, and the box hard-locks even
|
||
though no single process is at fault. The 1-second STOP-button
|
||
poll in ``_run_with_stop_check`` can't get scheduled, so even
|
||
clicking STOP doesn't recover it.
|
||
|
||
v4.0.0 made it WORSE for the phone-video workload because the
|
||
``--chunk-method select`` auto-override keeps the pipeline tighter
|
||
(no Hybrid warm-up between chunks), so more SVT-AV1 instances hit
|
||
full tilt at the same instant.
|
||
|
||
SOLUTION
|
||
--------
|
||
Budget the total thread count to ``logical_threads - 1`` (one
|
||
logical thread reserved for OS/UI), then split that budget across
|
||
chunk-parallel workers. Each encoder instance gets
|
||
``--threads N`` so it can't grab more than its share.
|
||
|
||
Algorithm
|
||
---------
|
||
1. ``budget = max(1, logical_threads - 1)`` — leave 1 logical
|
||
thread for OS / UI / av1an orchestrator.
|
||
2. ``ideal_tpw = 4`` — empirical sweet spot for SVT-AV1, x265,
|
||
and vpxenc. Beyond ~6 threads per encoder instance you hit
|
||
memory-bandwidth contention and diminishing returns.
|
||
3. ``target_workers = max(1, budget // ideal_tpw)``.
|
||
4. Cap ``target_workers`` at ``max(1, physical_cores - 1)`` so
|
||
chunk-parallel never exceeds the physical core count.
|
||
5. ``threads_per_worker = max(1, budget // target_workers)``.
|
||
6. Apply user overrides (``max_workers`` /
|
||
``threads_per_worker_override``) if provided.
|
||
|
||
Examples
|
||
--------
|
||
4-core / 8-thread laptop:
|
||
budget=7, target_workers=7//4=1, tpw=7//1=7 → 1×7 = 7
|
||
8-core / 16-thread desktop:
|
||
budget=15, target_workers=15//4=3, tpw=15//3=5 → 3×5 = 15
|
||
14-core / 28-thread Xeon (the user's box):
|
||
budget=27, target_workers=27//4=6, tpw=27//6=4 → 6×4 = 24
|
||
(leaves 4 logical threads for OS/UI breathing room)
|
||
32-core / 64-thread EPYC:
|
||
budget=63, target_workers=63//4=15, tpw=63//15=4 → 15×4=60
|
||
1-core / 2-thread VM:
|
||
budget=1, target_workers=1, tpw=1 → 1×1 = 1
|
||
|
||
Returns ``(worker_count, threads_per_worker)``. Both are ≥1.
|
||
"""
|
||
physical = max(1, env.cpu.physical_cores)
|
||
logical = max(1, env.cpu.logical_threads)
|
||
|
||
# User override short-circuit (highest priority).
|
||
if max_workers is not None and threads_per_worker_override is not None:
|
||
wc = max(1, int(max_workers))
|
||
tpw = max(1, int(threads_per_worker_override))
|
||
return wc, tpw
|
||
|
||
# Budget: leave 1 logical thread for OS / UI / av1an orchestrator.
|
||
budget = max(1, logical - 1)
|
||
|
||
# Ideal threads per encoder instance — empirical sweet spot.
|
||
# v4.1.0 used 4; v4.1.1 bumped to 6 because SVT-AV1 with only 4
|
||
# threads was too slow per-chunk, making the total throughput
|
||
# feel "borked" even though the thread budget was correct.
|
||
# With 6 threads per worker, SVT-AV1 has enough parallelism for
|
||
# motion estimation while staying under the logical-thread budget.
|
||
IDEAL_THREADS_PER_WORKER = 6
|
||
|
||
# Target worker count from budget / ideal_tpw.
|
||
target_workers = max(1, budget // IDEAL_THREADS_PER_WORKER)
|
||
|
||
# Cap at physical_cores - 1 so chunk-parallel doesn't exceed
|
||
# physical core count (avoids L3 cache thrash on chiplet CPUs).
|
||
max_by_phys = max(1, physical - 1) if physical > 1 else 1
|
||
target_workers = min(target_workers, max_by_phys)
|
||
|
||
# Apply --max-workers override if provided (still cap by physical).
|
||
if max_workers is not None:
|
||
target_workers = min(max(1, int(max_workers)), max_by_phys)
|
||
|
||
# Compute threads per worker.
|
||
if threads_per_worker_override is not None:
|
||
tpw = max(1, int(threads_per_worker_override))
|
||
else:
|
||
tpw = max(1, budget // target_workers)
|
||
|
||
return target_workers, tpw
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ENCODER WORKER (QThread, from PySide6 ver, extended)
|
||
# ──────────────────────────────────────────────
|
||
|
||
class EncoderWorker(QThread):
|
||
log_msg = Signal(str)
|
||
progress_msg = Signal(str, int, int) # (filename, current, total)
|
||
finished_queue = Signal(int, int) # (success_count, fail_count)
|
||
|
||
def __init__(
|
||
self,
|
||
in_dir: Path,
|
||
out_dir: Path,
|
||
video_codec: VideoCodecProfile,
|
||
audio_profile: AudioProfile,
|
||
container: ContainerProfile,
|
||
crf: int,
|
||
preset_label: str,
|
||
delete_source: bool,
|
||
env: EnvProbe,
|
||
extensions: set[str],
|
||
resolution: ResolutionProfile,
|
||
audio_level_db: float = 0.0,
|
||
use_ffmpeg_fallback: bool = False,
|
||
subtitle_lang: str | None = None,
|
||
force: bool = False,
|
||
# v4.1.0: explicit overrides for the intelligent worker-count
|
||
# computation. When None, EncoderWorker computes (worker_count,
|
||
# threads_per_worker) from CPU topology so that
|
||
# ``worker_count * threads_per_worker <= logical_threads - 1``
|
||
# (i.e. no thread oversubscription → no hard lock). When set,
|
||
# these take precedence — useful for troubleshooting or for
|
||
# workloads where the auto-compute picks a suboptimal split.
|
||
# Both can also be supplied via env.av1an_flags["max_workers"] /
|
||
# ["threads_per_worker"] (set by the CLI's --max-workers /
|
||
# --threads-per-worker flags) so the GUI doesn't need code changes
|
||
# to honor them.
|
||
max_workers: int | None = None,
|
||
threads_per_worker: int | None = 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
|
||
# v5: force=True skips ffprobe validation and attempts encode even
|
||
# for files ffprobe cannot read. Use for the 1% edge case where
|
||
# ffprobe fails but the file is actually valid (rare codec, broken
|
||
# container metadata, etc.). Default False — most "ffprobe can't
|
||
# read" files are genuinely invalid (failed downloads, HTML saved
|
||
# as .mp4, truncated files, etc.).
|
||
self.force = force
|
||
# v4.1.0: intelligent chunking overrides. Falls back to
|
||
# env.av1an_flags if not explicitly passed (so the CLI flags
|
||
# --max-workers / --threads-per-worker reach the GUI-spawned
|
||
# worker without ui_window.py code changes).
|
||
self.max_workers = max_workers if max_workers is not None else (
|
||
env.av1an_flags.get("max_workers") if isinstance(
|
||
env.av1an_flags.get("max_workers"), int
|
||
) else None
|
||
)
|
||
self.threads_per_worker_override = (
|
||
threads_per_worker if threads_per_worker is not None else (
|
||
env.av1an_flags.get("threads_per_worker") if isinstance(
|
||
env.av1an_flags.get("threads_per_worker"), int
|
||
) else None
|
||
)
|
||
)
|
||
# Resolved at run() time — kept on self so _encode_one can read it
|
||
# without changing its call signature (which is invoked recursively
|
||
# by the y4m-pipe-break retry path).
|
||
self._resolved_threads_per_worker = 0
|
||
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
|
||
# v4.3.0: tracks files skipped because the output already existed
|
||
# with a matching codec. Reported in the final summary as
|
||
# "Skipped: N" alongside Success/Failed.
|
||
self.skipped_count = 0
|
||
# v4.3.0: skip-existing detection. When True (default), the
|
||
# worker probes the output file before encoding; if it already
|
||
# exists with a matching video+audio codec, the file is skipped.
|
||
self.skip_existing = bool(env.av1an_flags.get("skip_existing", True))
|
||
# v4.4.3: verbose flag (was missing in launcher script, causing
|
||
# AttributeError when _check_disk_space referenced self.verbose).
|
||
self.verbose = bool(env.av1an_flags.get("verbose", False))
|
||
# v4.4.0: per-file encode timeout (seconds). Default 86400s = 24h,
|
||
# up from v4.0.0's 7200s = 2h. A 30GB 1080p BluRay rip at SVT-AV1
|
||
# preset 6 (~5-10 fps) on a 2-hour movie takes 4-10 hours; the old
|
||
# 2h timeout killed massive-file encodes partway through.
|
||
self.encode_timeout = int(env.av1an_flags.get("encode_timeout", 86400))
|
||
# v5-02: track consecutive failures with the same error pattern.
|
||
# After 3 consecutive same-pattern failures, auto-abort the queue.
|
||
self._consecutive_fail_count = 0
|
||
self._last_fail_pattern: str | None = None
|
||
# v3 (OTC-013, SEI CERT FIO09-C): each worker gets its own
|
||
# per-PID subdir under the shared app temp dir, so the final
|
||
# cleanup sweep can safely nuke only this worker's intermediates
|
||
# without affecting a concurrent worker. The subdir is created
|
||
# with mode=0o700 to prevent symlink attacks from other users.
|
||
self._temp_dir = _worker_temp_dir(os.getpid())
|
||
# v6-06: KeepAwake instance — started in run(), stopped in finally.
|
||
# mouse_nudge defaults to False (opt-in) to avoid surprising the
|
||
# user with cursor movement. systemd-inhibit is always-on when
|
||
# available (no visible side effects).
|
||
self._keepawake = KeepAwake(
|
||
log_fn=lambda msg: self.log_msg.emit(msg),
|
||
enable_mouse_nudge=False,
|
||
)
|
||
self._encode_start_time = 0.0
|
||
# v4.4.0: per-file context for combined status lines.
|
||
self._current_idx = 0
|
||
self._current_total = 0
|
||
self._current_filename = ""
|
||
|
||
def _status_prefix(self) -> str:
|
||
"""v4.4.0: Build the '[N/total] filename — ' prefix for combined status lines."""
|
||
if self._current_total:
|
||
return f"[{self._current_idx}/{self._current_total}] {self._current_filename} — "
|
||
return f"{self._current_filename} — " if self._current_filename else ""
|
||
|
||
def _vlog(self, msg: str) -> None:
|
||
"""v4.2.1: Verbose-only log emit. No-op unless self.verbose is True."""
|
||
if self.verbose:
|
||
self.log_msg.emit(msg)
|
||
|
||
def _compute_intelligent_worker_count(self) -> tuple[int, int]:
|
||
"""Compute ``(worker_count, threads_per_worker)`` to prevent thread
|
||
oversubscription on high-core-count machines. Delegates to the
|
||
module-level ``_compute_intelligent_worker_count_for`` so the GUI
|
||
probe banner can call the same math without an EncoderWorker
|
||
instance. See the module-level docstring for the full algorithm.
|
||
"""
|
||
return _compute_intelligent_worker_count_for(
|
||
self.env,
|
||
max_workers=self.max_workers,
|
||
threads_per_worker_override=self.threads_per_worker_override,
|
||
)
|
||
|
||
def _run_with_stop_check(
|
||
self,
|
||
cmd: list[str],
|
||
env: dict[str, str] | None = None,
|
||
timeout: int = 7200,
|
||
log_prefix: str = " ",
|
||
) -> tuple[str, int, str, str]:
|
||
"""Run a subprocess with STOP-button support.
|
||
|
||
Replaces ``subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=7200)`` in the av1an and ffmpeg-fallback encode paths so
|
||
that clicking STOP in the UI interrupts a running encode within
|
||
~1 second instead of waiting up to 2 hours for the per-file
|
||
timeout to expire.
|
||
|
||
Polls ``self._stop`` every ~1 second. When STOP is requested,
|
||
sends SIGTERM to the subprocess's *process group* (so av1an's
|
||
child encoders — SvtAv1EncApp / vpxenc / x265 — die too, not just
|
||
the av1an parent), waits 5s, then SIGKILLs the group if still
|
||
alive. Also enforces the overall ``timeout`` (7200s) limit.
|
||
|
||
Two background drainer threads read stdout/stderr continuously
|
||
into StringIO buffers. This prevents the classic pipe-buffer
|
||
deadlock: av1an's progress bar can easily exceed the ~64KB OS
|
||
pipe buffer over a long encode, and without draining the child
|
||
would block on ``write()`` and ``proc.poll()`` would never see
|
||
it exit. This is the same pattern ``subprocess.run`` uses
|
||
internally via ``_communicate``.
|
||
|
||
Returns a 4-tuple ``(status, returncode, stdout, stderr)`` where
|
||
``status`` is one of:
|
||
|
||
- ``"ok"`` — process exited normally; caller inspects
|
||
``returncode`` (0 = success) and uses
|
||
``stdout`` / ``stderr`` for diagnostics.
|
||
- ``"stop"`` — user requested STOP via the UI. Caller must
|
||
NOT increment ``fail_count`` (a user abort is
|
||
not a transcode failure). ``self._stop`` is
|
||
already True (set by the UI thread), so the
|
||
orchestrator's queue loop will break on the
|
||
next iteration and emit
|
||
"STOP: Aborted by user."
|
||
- ``"timeout"`` — process exceeded ``timeout`` seconds.
|
||
Caller MUST increment ``fail_count`` (a
|
||
timeout is a failure) and emit the existing
|
||
user-visible TIMEOUT message.
|
||
|
||
Raises ``OSError`` / ``subprocess.SubprocessError`` if the
|
||
``Popen`` constructor itself fails (e.g. ``FileNotFoundError``
|
||
when the binary is missing) — the caller's existing ``except``
|
||
clause handles these unchanged.
|
||
"""
|
||
proc = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
env=env,
|
||
# start_new_session=True puts the child in its own process
|
||
# group (setsid). We can then os.killpg() the whole group
|
||
# to reach av1an's child encoders (SvtAv1EncApp / vpxenc /
|
||
# x265), which a bare proc.terminate() would miss.
|
||
start_new_session=True,
|
||
)
|
||
|
||
stdout_buf = io.StringIO()
|
||
stderr_buf = io.StringIO()
|
||
|
||
# v4.1.1: live tail — emit each line of av1an's stdout/stderr
|
||
# to the GUI log as it arrives, so the user sees progress in
|
||
# real-time instead of staring at a frozen "Encoding: file.mp4"
|
||
# message for 10+ minutes. The previous drainer read into a
|
||
# StringIO buffer and only emitted on process exit, which made
|
||
# v4.1.0's slower (capped-thread) encodes look "borked" even
|
||
# though av1an was working fine underneath.
|
||
#
|
||
# Handles both \n (log lines) and \r (progress bar updates) as
|
||
# line boundaries, so av1an's progress bar renders correctly.
|
||
# Incomplete trailing data is buffered until the next read
|
||
# completes the line.
|
||
def _drain(stream, buf, emit_fn, prefix):
|
||
"""Read from stream into buf, emitting each complete line via
|
||
emit_fn. Handles \\n and \\r as line boundaries."""
|
||
pending = ""
|
||
try:
|
||
while True:
|
||
chunk = stream.read(4096)
|
||
if not chunk:
|
||
break
|
||
buf.write(chunk)
|
||
if emit_fn is None:
|
||
continue
|
||
pending += chunk
|
||
# Emit each complete line (delimited by \n or \r).
|
||
# av1an's progress bar uses \r; log lines use \n.
|
||
while True:
|
||
nl = pending.find('\n')
|
||
cr = pending.find('\r')
|
||
if nl == -1 and cr == -1:
|
||
break
|
||
if nl == -1:
|
||
pos = cr
|
||
elif cr == -1:
|
||
pos = nl
|
||
else:
|
||
pos = min(nl, cr)
|
||
line = pending[:pos]
|
||
pending = pending[pos + 1:]
|
||
stripped = line.rstrip()
|
||
if stripped:
|
||
try:
|
||
emit_fn(f"{prefix}{stripped}")
|
||
except (RuntimeError, OSError):
|
||
# Signal might be disconnected mid-encode
|
||
# if the GUI is closing. Stop emitting
|
||
# but keep draining the buffer.
|
||
emit_fn = None
|
||
break
|
||
if emit_fn is None:
|
||
break
|
||
except (OSError, ValueError):
|
||
# Stream closed under us or process gone — stop reading.
|
||
pass
|
||
# Emit any remaining pending data (process exited mid-line).
|
||
if emit_fn is not None:
|
||
stripped = pending.rstrip()
|
||
if stripped:
|
||
try:
|
||
emit_fn(f"{prefix}{stripped}")
|
||
except (RuntimeError, OSError):
|
||
pass
|
||
|
||
tail_prefix = f"{log_prefix}│ "
|
||
# v4.4.2: gate live tail behind --verbose. The user wants just
|
||
# start + finish lines, no per-frame progress chatter.
|
||
tail_emit = self.log_msg.emit if self.verbose else None
|
||
t_out = threading.Thread(
|
||
target=_drain,
|
||
args=(proc.stdout, stdout_buf, tail_emit, tail_prefix),
|
||
daemon=True,
|
||
)
|
||
t_err = threading.Thread(
|
||
target=_drain,
|
||
args=(proc.stderr, stderr_buf, tail_emit, tail_prefix),
|
||
daemon=True,
|
||
)
|
||
t_out.start()
|
||
t_err.start()
|
||
|
||
status = "ok"
|
||
rc: int | None = None
|
||
start_time = time.monotonic()
|
||
# v4.1.1: heartbeat timer — emit a "still encoding" message every
|
||
# 30 seconds so the user knows the process is alive even if av1an
|
||
# isn't producing line-delimited output (e.g. during a long SVT-AV1
|
||
# encode that only updates a \r progress bar, which the live tail
|
||
# emits as a single line that might not change for minutes).
|
||
last_heartbeat = start_time
|
||
HEARTBEAT_INTERVAL = 30 # seconds
|
||
while True:
|
||
rc = proc.poll()
|
||
if rc is not None:
|
||
# Process exited — break and drain pipes below.
|
||
break
|
||
|
||
if self._stop:
|
||
self.log_msg.emit(
|
||
f"{log_prefix}STOP: Aborting current encode, "
|
||
f"terminating subprocess..."
|
||
)
|
||
try:
|
||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||
except (ProcessLookupError, OSError):
|
||
# Process already gone — nothing to signal.
|
||
pass
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
# SIGTERM didn't take effect within the grace period —
|
||
# escalate to SIGKILL on the whole group.
|
||
try:
|
||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except subprocess.TimeoutExpired:
|
||
# Truly stuck (e.g. uninterruptible IO). We've
|
||
# done what we can; the process will be reaped
|
||
# later. Continue to pipe drainage.
|
||
pass
|
||
status = "stop"
|
||
self.log_msg.emit(f"{log_prefix}STOP: Subprocess terminated.")
|
||
break
|
||
|
||
if time.monotonic() - start_time > timeout:
|
||
# Overall timeout — kill the process group. The caller
|
||
# logs the user-visible TIMEOUT message (it includes the
|
||
# file name / "ffmpeg" context this helper doesn't know).
|
||
try:
|
||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except subprocess.TimeoutExpired:
|
||
pass
|
||
status = "timeout"
|
||
break
|
||
|
||
# v4.4.1: heartbeat gated behind --verbose. The user wants
|
||
# just start + finish lines — no "still encoding" chatter
|
||
# in between.
|
||
now = time.monotonic()
|
||
if self.verbose and now - last_heartbeat >= HEARTBEAT_INTERVAL:
|
||
elapsed = int(now - start_time)
|
||
self.log_msg.emit(
|
||
f"{log_prefix}... {elapsed}s elapsed"
|
||
)
|
||
last_heartbeat = now
|
||
|
||
time.sleep(1)
|
||
|
||
# Wait for drainer threads to finish reading any remaining pipe
|
||
# data, then close the pipes explicitly (defensive — __del__
|
||
# would also close them, but explicit is better and avoids
|
||
# ResourceWarning under -X dev).
|
||
t_out.join(timeout=10)
|
||
t_err.join(timeout=10)
|
||
try:
|
||
proc.stdout.close()
|
||
except (OSError, ValueError):
|
||
pass
|
||
try:
|
||
proc.stderr.close()
|
||
except (OSError, ValueError):
|
||
pass
|
||
|
||
return (
|
||
status,
|
||
rc if rc is not None else -1,
|
||
stdout_buf.getvalue(),
|
||
stderr_buf.getvalue(),
|
||
)
|
||
|
||
def _ffmpeg_fallback_encode(
|
||
self,
|
||
file_path: Path,
|
||
encode_input: Path,
|
||
output_f: Path,
|
||
) -> bool:
|
||
"""Encode a single file using pure ffmpeg (av1an fallback path).
|
||
|
||
Used when av1an cannot initialize VapourSynth. No chunk-parallel
|
||
mode, but ffmpeg uses multithreaded encoding internally.
|
||
|
||
Returns True on success, False on failure.
|
||
"""
|
||
# Check if ffmpeg has the video encoder we need
|
||
ffmpeg_enc = self.video_codec.ffmpeg_encoder
|
||
# v3: use the module-level FFMPEG_LIB_KEY_MAP (OTC-007).
|
||
ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc)
|
||
|
||
if not self.env.ffmpeg_libs.get(ffmpeg_lib_key, False):
|
||
self.log_msg.emit(
|
||
f" FATAL: ffmpeg does not have '{ffmpeg_enc}' encoder. "
|
||
f"Cannot fall back. Install a ffmpeg build with {ffmpeg_enc} support."
|
||
)
|
||
return False
|
||
|
||
v_args = self.video_codec.ffmpeg_vargs_fn(self.crf, self.preset_val)
|
||
|
||
# Belt-and-suspenders: if a target resolution is set, inject -vf scale
|
||
# directly into the ffmpeg command. This guarantees the output resolution
|
||
# matches the dropdown even if the intermediate pre-scale was bypassed.
|
||
vf_scale_args: list[str] = []
|
||
if self.resolution.width is not None and self.resolution.height is not None:
|
||
vf_scale_args = [
|
||
"-vf", (
|
||
f"scale={self.resolution.width}:{self.resolution.height}:"
|
||
f"force_original_aspect_ratio=decrease:force_divisible_by=2"
|
||
),
|
||
]
|
||
|
||
# Audio args from profile
|
||
audio_args = list(self.audio_profile.params)
|
||
if abs(self.audio_level_db) > 0.01:
|
||
per_file_gain = self._analyze_audio_loudness(file_path)
|
||
if per_file_gain is not None and abs(per_file_gain) > 0.01:
|
||
audio_args.extend(["-af", f"volume={per_file_gain:+.1f}dB"])
|
||
else:
|
||
static_db = f"{self.audio_level_db:+.1f}".replace("+", "")
|
||
audio_args.extend(["-af", f"volume={static_db}dB"])
|
||
|
||
# Container-specific muxer flags. -movflags +faststart is MP4-only
|
||
# (it relocates the moov atom for streaming); passing it for MKV or
|
||
# WebM is silently ignored by ffmpeg but pollutes the command line
|
||
# and confuses users reading the log. Apply it only when the
|
||
# output container is MP4.
|
||
mux_flags: list[str] = []
|
||
if self.container.ext == "mp4":
|
||
mux_flags = ["-movflags", "+faststart"]
|
||
|
||
cmd = [
|
||
self.env.ffmpeg_path,
|
||
"-i", str(encode_input),
|
||
] + vf_scale_args + v_args + audio_args + mux_flags + [
|
||
"-y",
|
||
str(output_f),
|
||
]
|
||
|
||
try:
|
||
result = self._run_with_stop_check(cmd, timeout=self.encode_timeout, log_prefix=" ")
|
||
status, rc, stdout, stderr = result
|
||
|
||
if status == "stop":
|
||
# User requested STOP — do NOT count as failure. The
|
||
# caller (_process_one_file) guards the fail_count
|
||
# increment with `if not self._stop`. Remove partial
|
||
# output so it isn't mistaken for a finished file.
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
if status == "timeout":
|
||
self.log_msg.emit(f"{self._status_prefix()}FAIL: timeout (exceeded {self.encode_timeout}s limit)")
|
||
return False
|
||
|
||
# status == "ok" — wrap in CompletedProcess so the downstream
|
||
# returncode/stderr logic is byte-for-byte unchanged.
|
||
res = subprocess.CompletedProcess(cmd, rc, stdout, stderr)
|
||
|
||
if res.returncode == 0 and output_f.exists():
|
||
src_size = file_path.stat().st_size
|
||
out_size = output_f.stat().st_size
|
||
ratio = out_size / src_size if src_size > 0 else 0
|
||
|
||
# Integrity gate: 1KB absolute minimum. A valid container
|
||
# header alone is ~1KB; anything below is definitely corrupt.
|
||
# The duration check in _verify_and_finalize (≥95% of source
|
||
# duration) is the real quality gate for high-bitrate sources.
|
||
if out_size > 1024:
|
||
return True
|
||
else:
|
||
self.log_msg.emit(
|
||
f" INTEGRITY: output only {ratio * 100:.1f}% of source."
|
||
)
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
else:
|
||
stderr_snip = (res.stderr or "")[-300:]
|
||
self.log_msg.emit(
|
||
f" ffmpeg error (rc={res.returncode}): {stderr_snip.strip()}"
|
||
)
|
||
return False
|
||
except OSError as e:
|
||
self.log_msg.emit(f"{self._status_prefix()}FAIL: system error: {e}")
|
||
return False
|
||
|
||
def run(self):
|
||
# v4.1.0: intelligent worker count + per-chunk thread cap.
|
||
# Replaces the v3 ``max(1, physical_cores - 1)`` heuristic that
|
||
# produced 13 workers × auto (≈28) = 364 threads on a 28-thread
|
||
# Xeon and drowned the kernel scheduler (hard lock).
|
||
# _compute_intelligent_worker_count returns (worker_count,
|
||
# threads_per_worker) such that
|
||
# worker_count * threads_per_worker <= logical_threads - 1
|
||
# The threads_per_worker is stashed on self so _encode_one can
|
||
# inject it into the encoder's --video-params (each SvtAv1EncApp
|
||
# / vpxenc / x265 instance then respects its share).
|
||
worker_count, threads_per_worker = self._compute_intelligent_worker_count()
|
||
self._resolved_threads_per_worker = threads_per_worker
|
||
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:
|
||
# v4.1.0: show the thread budget so the user can verify the
|
||
# intelligent worker math at a glance. e.g. on a 28-thread Xeon:
|
||
# "Chunk-parallel: 6 workers × 4 threads = 24 active
|
||
# (28 logical - 4 reserved for OS/UI)"
|
||
active = worker_count * threads_per_worker
|
||
reserved = logical - active
|
||
self.log_msg.emit(
|
||
f"Chunk-parallel: {worker_count} workers × {threads_per_worker} threads "
|
||
f"= {active} active "
|
||
f"({logical} logical - {reserved} reserved for OS/UI)"
|
||
)
|
||
if self.max_workers is not None or self.threads_per_worker_override is not None:
|
||
self.log_msg.emit(
|
||
f" (overrides: max_workers={self.max_workers!r}, "
|
||
f"threads_per_worker={self.threads_per_worker_override!r})"
|
||
)
|
||
self.log_msg.emit(f"Found {total} file(s) to process.")
|
||
self.log_msg.emit(f"Temp dir: {self._temp_dir}")
|
||
|
||
# ── Pre-scan: show each file's source → output resolution ──
|
||
needs_scale = (
|
||
self.resolution.width is not None
|
||
and self.resolution.height is not None
|
||
)
|
||
if needs_scale:
|
||
self.log_msg.emit(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})")
|
||
else:
|
||
self.log_msg.emit("Output resolution: Original (no scaling)")
|
||
self.log_msg.emit("─── FILE RESOLUTION MAP ───")
|
||
self._file_res_map: dict[Path, tuple] = {} # file -> (src_w, src_h, out_w, out_h)
|
||
if self.env.ffprobe_path:
|
||
for f in all_files:
|
||
info = ffprobe_validate(f, self.env.ffprobe_path)
|
||
sw, sh = None, None
|
||
if info:
|
||
for s in info.get("streams", []):
|
||
if s.get("codec_type") == "video":
|
||
sw = int(s.get("width", 0) or 0)
|
||
sh = int(s.get("height", 0) or 0)
|
||
break
|
||
if sw and sh:
|
||
ow, oh = (self.resolution.width, self.resolution.height) if needs_scale else (sw, sh)
|
||
self._file_res_map[f] = (sw, sh, ow, oh)
|
||
arrow = "->" if needs_scale else "="
|
||
action = "" if needs_scale or sw == ow else " (no change)"
|
||
self.log_msg.emit(f" {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}")
|
||
else:
|
||
self._file_res_map[f] = (None, None, self.resolution.width if needs_scale else None, self.resolution.height if needs_scale else None)
|
||
self.log_msg.emit(f" {f.name:<40s} (unknown resolution)")
|
||
else:
|
||
self.log_msg.emit(" (ffprobe unavailable — resolution map skipped)")
|
||
self.log_msg.emit("───────────────────────────")
|
||
|
||
# ── v5-04: Pre-flight validation pass ──
|
||
# Scan all files with ffprobe BEFORE the encode loop. Report how
|
||
# many are valid vs invalid. This gives the user immediate feedback
|
||
# ("46 files found, 0 valid, 46 invalid") instead of failing one
|
||
# by one over 2 hours. If ALL files are invalid and force=False,
|
||
# abort now — don't waste time entering the encode loop.
|
||
if self.env.ffprobe_path and not self.force:
|
||
valid_count = 0
|
||
invalid_count = 0
|
||
invalid_samples: list[str] = []
|
||
for f in all_files:
|
||
info = ffprobe_validate(f, self.env.ffprobe_path)
|
||
if info is None:
|
||
invalid_count += 1
|
||
if len(invalid_samples) < 3:
|
||
ft = _identify_file_type(f)
|
||
invalid_samples.append(f" {f.name}: {ft}" if ft else f" {f.name}: (file type unknown)")
|
||
else:
|
||
has_video = any(s.get("codec_type") == "video" for s in info.get("streams", []))
|
||
duration = float(info.get("format", {}).get("duration", 0))
|
||
if has_video and duration >= 0.5:
|
||
valid_count += 1
|
||
else:
|
||
invalid_count += 1
|
||
if len(invalid_samples) < 3:
|
||
reason = "no video stream" if not has_video else f"too short ({duration:.1f}s)"
|
||
invalid_samples.append(f" {f.name}: {reason}")
|
||
|
||
self.log_msg.emit("─── PRE-FLIGHT VALIDATION ───")
|
||
self.log_msg.emit(f" Valid files: {valid_count}")
|
||
self.log_msg.emit(f" Invalid files: {invalid_count}")
|
||
if invalid_samples:
|
||
self.log_msg.emit(f" First {len(invalid_samples)} invalid:")
|
||
for s in invalid_samples:
|
||
self.log_msg.emit(s)
|
||
self.log_msg.emit("─────────────────────────────")
|
||
|
||
if valid_count == 0 and invalid_count > 0:
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
f"ABORT: All {invalid_count} file(s) are invalid. "
|
||
f"Aborting queue — no files to encode."
|
||
)
|
||
self.log_msg.emit(
|
||
" Common causes: (1) failed yt-dlp downloads (HTML saved as .mp4), "
|
||
"(2) files on a network mount that's not responding, "
|
||
"(3) wrong input directory."
|
||
)
|
||
self.log_msg.emit(
|
||
" Run `file <filename>` on any file to see what it actually is."
|
||
)
|
||
self.fail_count = invalid_count
|
||
self._final_cleanup_sweep()
|
||
self.log_msg.emit(
|
||
f"QUEUE COMPLETE. Success: 0, Failed: {self.fail_count}."
|
||
)
|
||
self.finished_queue.emit(0, self.fail_count)
|
||
return
|
||
elif invalid_count > 0:
|
||
self.log_msg.emit(
|
||
f" {invalid_count} invalid file(s) will be skipped during encoding."
|
||
)
|
||
self.log_msg.emit("")
|
||
|
||
scale_filter = (
|
||
f"scale={self.resolution.width}:{self.resolution.height}:"
|
||
f"force_original_aspect_ratio=decrease:force_divisible_by=2,"
|
||
f"pad={self.resolution.width}:{self.resolution.height}:(ow-iw)/2:(oh-ih)/2"
|
||
) if needs_scale else ""
|
||
|
||
# v6-06: Start keep-awake (systemd-inhibit + optional mouse nudge)
|
||
self._encode_start_time = time.monotonic()
|
||
self._keepawake.start()
|
||
try:
|
||
for idx, file_path in enumerate(all_files, 1):
|
||
if self._stop:
|
||
self.log_msg.emit("STOP: Aborted by user.")
|
||
break
|
||
|
||
prev_success = self.success_count
|
||
prev_fail = self.fail_count
|
||
|
||
# v6-06: Update keep-awake ETA before each file.
|
||
# ETA = (avg time per file so far) × (remaining files)
|
||
processed = idx - 1
|
||
if processed > 0:
|
||
elapsed = time.monotonic() - self._encode_start_time
|
||
avg_per_file = elapsed / processed
|
||
remaining = total - processed
|
||
self._keepawake.update_eta(avg_per_file * remaining)
|
||
else:
|
||
self._keepawake.update_eta(None) # unknown for first file
|
||
|
||
self._process_one_file(file_path, idx, total, worker_count, needs_scale, scale_filter)
|
||
|
||
# v5-02: track consecutive failures with the same error pattern.
|
||
# After 3 consecutive same-pattern failures, auto-abort the queue.
|
||
if self.fail_count > prev_fail:
|
||
pass # pattern tracking is handled in _process_one_file
|
||
elif self.success_count > prev_success:
|
||
# Success resets the consecutive failure counter.
|
||
self._consecutive_fail_count = 0
|
||
self._last_fail_pattern = None
|
||
|
||
# ── Final cleanup pass: residual sweep ──
|
||
self._final_cleanup_sweep()
|
||
|
||
# ── Deferred source deletion (only after all cleanup is done) ──
|
||
if self._sources_to_delete:
|
||
deleted = 0
|
||
for src in self._sources_to_delete:
|
||
try:
|
||
if src.exists():
|
||
src.unlink()
|
||
deleted += 1
|
||
except OSError:
|
||
pass
|
||
self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s) after verified transcode.")
|
||
self._sources_to_delete.clear()
|
||
|
||
# v4.3.0: include skipped count when > 0.
|
||
if self.skipped_count > 0:
|
||
self.log_msg.emit(
|
||
f"QUEUE COMPLETE. Success: {self.success_count}, "
|
||
f"Failed: {self.fail_count}, Skipped: {self.skipped_count}."
|
||
)
|
||
else:
|
||
self.log_msg.emit(
|
||
f"QUEUE COMPLETE. Success: {self.success_count}, Failed: {self.fail_count}."
|
||
)
|
||
self.finished_queue.emit(self.success_count, self.fail_count)
|
||
finally:
|
||
# v6-06: Always stop keep-awake, even if the encode loop crashed.
|
||
self._keepawake.stop()
|
||
|
||
def _process_one_file(self, file_path, idx, total, worker_count, needs_scale, scale_filter):
|
||
"""Process a single file end-to-end (validate -> prepare -> encode -> verify).
|
||
|
||
v4.4.0: the per-file banner is NOT emitted upfront. Instead, each
|
||
terminal status (SKIP / OK / FAIL) emits a SINGLE combined line:
|
||
[N/total] filename — SKIP (already av1/opus)
|
||
[N/total] filename — OK: 1.6MB -> 1.3MB (81%)
|
||
[N/total] filename — FAIL: <reason>
|
||
"""
|
||
self.progress_msg.emit(file_path.name, idx, total)
|
||
# v4.4.0: stash idx/total on self so downstream methods can emit
|
||
# combined status lines with the [N/total] filename prefix.
|
||
self._current_idx = idx
|
||
self._current_total = total
|
||
self._current_filename = file_path.name
|
||
|
||
# --- ffprobe pre-validation ---
|
||
skip, info, src_w, src_h = self._validate_file(file_path)
|
||
if skip:
|
||
# v5-02: a skip is a failure for consecutive-failure tracking.
|
||
self._check_consecutive_failures(file_path, accepted=False)
|
||
return # _validate_file already logged SKIP + incremented fail_count
|
||
|
||
# --- Determine actual output resolution ---
|
||
if src_w and src_h:
|
||
out_w, out_h = src_w, src_h
|
||
if needs_scale:
|
||
out_w, out_h = self.resolution.width, self.resolution.height
|
||
self.log_msg.emit(f" Source: {src_w}x{src_h} -> Output: {out_w}x{out_h}")
|
||
else:
|
||
if needs_scale:
|
||
self.log_msg.emit(f" Source: unknown -> Output: {self.resolution.width}x{self.resolution.height}")
|
||
else:
|
||
self.log_msg.emit(f" Source: unknown -> Output: original")
|
||
|
||
# --- Pre-scale / symlink + build output path ---
|
||
prepared = self._prepare_input(file_path, src_w, src_h, needs_scale, scale_filter)
|
||
if prepared is None:
|
||
# v5-02: prepare failure counts for consecutive-failure tracking.
|
||
self._check_consecutive_failures(file_path, accepted=False)
|
||
return # _prepare_input already logged + cleaned up + incremented fail_count
|
||
encode_input, output_f = prepared
|
||
|
||
# v4.3.0: skip-existing detection. If the output file already
|
||
# exists with a matching video+audio codec, skip the encode.
|
||
# v4.4.0: moved ABOVE the disk-space check so skipped files
|
||
# don't trigger disk-space warnings. Combined into a single
|
||
# log line with the [N/total] prefix.
|
||
if self.skip_existing and self._output_already_encoded(file_path, output_f):
|
||
self.skipped_count += 1
|
||
vcodec = self.video_codec.ffprobe_codec_name or "?"
|
||
acodec = self.audio_profile.ffprobe_codec_name or "?"
|
||
self.log_msg.emit(
|
||
f"[{idx}/{total}] {file_path.name} — SKIP (already {vcodec}/{acodec})"
|
||
)
|
||
self._check_consecutive_failures(file_path, accepted=True)
|
||
self._cleanup_current_temps()
|
||
if self.delete_source:
|
||
self._sources_to_delete.append(file_path)
|
||
return
|
||
|
||
# v4.4.0: disk space pre-check for massive files. Warns (does NOT
|
||
# abort) if free space on the output/temp partition is less than
|
||
# the source size. Skipped for files < 1 GB. Runs ONLY for files
|
||
# we're actually about to encode (after the skip-existing check).
|
||
self._check_disk_space(file_path, output_f, needs_scale)
|
||
|
||
# v4.4.0: emit the per-file banner HERE (not at the top) so skipped
|
||
# files don't get a dangling "[N/total] filename" line. The final
|
||
# OK/FAIL status line at the end repeats the prefix.
|
||
self.log_msg.emit(f"[{idx}/{total}] {file_path.name}")
|
||
|
||
# --- Encode ---
|
||
encode_ok = self._encode_one(file_path, encode_input, output_f, worker_count)
|
||
if not encode_ok:
|
||
# ffmpeg fallback path: _ffmpeg_fallback_encode does NOT touch
|
||
# _current_temps or fail_count, so we do both here to match the
|
||
# original `else: self.fail_count += 1; self._cleanup_current_temps()`.
|
||
# av1an path: _encode_one's `finally` already cleaned temps and
|
||
# fail_count was incremented inside _encode_one.
|
||
#
|
||
# STOP exception: when the user clicked STOP mid-encode,
|
||
# _run_with_stop_check returned "stop" and _ffmpeg_fallback_encode
|
||
# returned False WITHOUT incrementing fail_count (a user abort is
|
||
# not a transcode failure). Honor that here by skipping the
|
||
# fail_count increment when self._stop is set — temp cleanup
|
||
# still runs so we don't leak intermediate files.
|
||
if self.use_ffmpeg_fallback:
|
||
if not self._stop:
|
||
self.fail_count += 1
|
||
self._cleanup_current_temps()
|
||
# v5-02: encode failure counts for consecutive-failure tracking
|
||
# (but only if not a user STOP — a STOP is not a failure).
|
||
if not self._stop:
|
||
self._check_consecutive_failures(file_path, accepted=False)
|
||
return
|
||
|
||
# --- Post-encode verification + finalize ---
|
||
accepted = self._verify_and_finalize(file_path, output_f, encode_input, needs_scale)
|
||
if self.use_ffmpeg_fallback:
|
||
# ffmpeg path always cleans up explicitly at every exit
|
||
# (av1an path already cleaned up via _encode_one's `finally`).
|
||
self._cleanup_current_temps()
|
||
# accepted=True -> success_count already incremented in _verify_and_finalize.
|
||
# accepted=False -> fail_count already incremented + output unlinked there.
|
||
|
||
# v5-02: check for consecutive failures with the same pattern.
|
||
self._check_consecutive_failures(file_path, accepted)
|
||
|
||
def _check_consecutive_failures(self, file_path: Path, accepted: bool):
|
||
"""v5-02: Track consecutive failures and auto-abort after 3.
|
||
|
||
After 3 consecutive failures (regardless of pattern — if 3 files
|
||
in a row fail, something is systematically wrong), auto-abort the
|
||
queue with a clear message. The user can still click STOP to
|
||
abort earlier.
|
||
|
||
This prevents the scenario from the user's log: 46 files, all
|
||
failing identically, processed one by one over ~2 hours. With
|
||
this fix, the queue aborts after file 3.
|
||
"""
|
||
if accepted:
|
||
self._consecutive_fail_count = 0
|
||
return
|
||
|
||
self._consecutive_fail_count += 1
|
||
if self._consecutive_fail_count >= 3 and not self._stop:
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
f"ABORT: {self._consecutive_fail_count} consecutive failures. "
|
||
f"Auto-aborting queue — something is systematically wrong."
|
||
)
|
||
self.log_msg.emit(
|
||
" The remaining files will likely fail the same way. "
|
||
"Fix the root cause (check the diagnostics above) and retry."
|
||
)
|
||
self.log_msg.emit(
|
||
" Common root causes: (1) all files are invalid (failed downloads), "
|
||
"(2) av1an/encoder binary is broken, (3) out of disk space, "
|
||
"(4) network mount is down."
|
||
)
|
||
self._stop = True
|
||
|
||
def _validate_file(self, file_path):
|
||
"""ffprobe pre-validation. Returns (skip, info, src_w, src_h).
|
||
|
||
skip=True signals the caller to abandon this file — the SKIP log
|
||
line and fail_count increment have already happened here.
|
||
|
||
If ffprobe cannot read the file, SKIP it. The encode fails ~99%
|
||
of the time when ffprobe fails (failed download, HTML saved as
|
||
.mp4, truncated, etc.). The `force=True` constructor flag
|
||
overrides this for the rare edge case (rare codec, broken
|
||
container metadata where ffprobe fails but ffmpeg can decode).
|
||
"""
|
||
# Reuse pre-scanned dimensions if available, otherwise probe now
|
||
prescan = self._file_res_map.get(file_path)
|
||
src_w, src_h = (prescan[0], prescan[1]) if prescan else (None, None)
|
||
info = None
|
||
if self.env.ffprobe_path:
|
||
info = ffprobe_validate(file_path, self.env.ffprobe_path)
|
||
if info is None:
|
||
if self.force:
|
||
self.log_msg.emit(
|
||
f"WARN: ffprobe could not read {file_path.name} — "
|
||
f"attempting encode anyway (force=True)."
|
||
)
|
||
else:
|
||
# Identify the file's actual type via `file` command.
|
||
file_type = _identify_file_type(file_path)
|
||
self.log_msg.emit(f"{self._status_prefix()}SKIP: not a valid video (ffprobe could not read it)")
|
||
if file_type and self.verbose:
|
||
self._vlog(f" File type: {file_type}")
|
||
if "HTML" in file_type or "ASCII" in file_type or "text" in file_type:
|
||
self._vlog(
|
||
" This looks like a text/HTML file, not a video. "
|
||
"Common cause: failed yt-dlp download (region-locked, "
|
||
"age-restricted, or removed video). Re-download the file."
|
||
)
|
||
elif "data" in file_type:
|
||
self._vlog(
|
||
" File type is 'data' — possibly truncated, encrypted, "
|
||
"or a partial download. Verify the file plays in mpv/VLC."
|
||
)
|
||
if self.verbose:
|
||
self._vlog(
|
||
" (Use the Force checkbox to attempt encode anyway.)"
|
||
)
|
||
self.fail_count += 1
|
||
return (True, None, None, None)
|
||
else:
|
||
duration = float(info.get("format", {}).get("duration", 0))
|
||
has_video = any(s.get("codec_type") == "video" for s in info.get("streams", []))
|
||
if not has_video:
|
||
self.log_msg.emit(f"{self._status_prefix()}SKIP: no video stream")
|
||
self.fail_count += 1
|
||
return (True, None, None, None)
|
||
if duration < 0.5:
|
||
self.log_msg.emit(f"{self._status_prefix()}SKIP: too short ({duration:.1f}s)")
|
||
self.fail_count += 1
|
||
return (True, None, None, None)
|
||
# Extract dims if pre-scan didn't have them
|
||
if not src_w or not src_h:
|
||
for s in info.get("streams", []):
|
||
if s.get("codec_type") == "video":
|
||
src_w = int(s.get("width", 0) or 0)
|
||
src_h = int(s.get("height", 0) or 0)
|
||
break
|
||
return (False, info, src_w, src_h)
|
||
|
||
def _prepare_input(self, file_path, src_w, src_h, needs_scale, scale_filter):
|
||
"""Pre-scale (if needed) and ensure the av1an work dir lands in temp.
|
||
|
||
Returns (encode_input, output_f) on success, or None on failure
|
||
(after logging + cleaning up current temps + incrementing fail_count).
|
||
"""
|
||
# --- Pre-scale with ffmpeg if target resolution selected ---
|
||
# ALL intermediates (scaled files, av1an work dirs) go to the app
|
||
# temp directory so the user's video folders stay clean.
|
||
encode_input = file_path
|
||
|
||
if needs_scale:
|
||
try:
|
||
temp_scaled = _temp_path_for(file_path, ".scaled_tmp.mkv", worker_dir=self._temp_dir)
|
||
self._current_temps.append(temp_scaled)
|
||
# Use libx265 lossless for the intermediate — NOT ffv1.
|
||
# ffv1 is not supported by VapourSynth source plugins (bestource,
|
||
# ffms2, lsmash), so av1an's chunking pipeline produces an empty
|
||
# pipe and the encoder emits "Fatal: Failed to open input file".
|
||
# libx265 -crf 0 is bit-for-bit lossless, fast at ultrafast preset,
|
||
# and HEVC-in-MKV is universally supported by every VS plugin.
|
||
scale_cmd = [
|
||
self.env.ffmpeg_path,
|
||
"-i", str(file_path),
|
||
"-vf", scale_filter,
|
||
"-c:v", "libx265",
|
||
"-crf", "0",
|
||
"-preset", "ultrafast",
|
||
"-pix_fmt", "yuv420p", # force 8-bit 4:2:0
|
||
"-y",
|
||
str(temp_scaled),
|
||
]
|
||
self.log_msg.emit(f" Scaling {src_w or '?'}x{src_h or '?'} -> {self.resolution.width}x{self.resolution.height}...")
|
||
scale_res = subprocess.run(
|
||
scale_cmd, capture_output=True, text=True, timeout=1800,
|
||
)
|
||
if scale_res.returncode == 0 and temp_scaled.exists():
|
||
encode_input = temp_scaled
|
||
scaled_size = temp_scaled.stat().st_size / 1_048_576
|
||
self.log_msg.emit(f" Pre-scale OK ({scaled_size:.1f} MB intermediate)")
|
||
else:
|
||
stderr_snip = (scale_res.stderr or "")[-200:]
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: pre-scale failed (rc={scale_res.returncode})"
|
||
)
|
||
if stderr_snip.strip():
|
||
self.log_msg.emit(f" ffmpeg stderr: {stderr_snip.strip()}")
|
||
temp_scaled.unlink(missing_ok=True)
|
||
self._cleanup_current_temps()
|
||
self.fail_count += 1
|
||
return None
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: pre-scale error: {e}"
|
||
)
|
||
self._cleanup_current_temps()
|
||
self.fail_count += 1
|
||
return None
|
||
|
||
# --- Ensure av1an work dir lands in the temp directory ---
|
||
# av1an creates its work dir as {input_path}.av1an by default.
|
||
# We do NOT use av1an's --temp flag because it causes "Error: End of file"
|
||
# during scene detection when the input file is in the same directory
|
||
# as --temp (av1an 0.5.2-unstable). Instead, we ensure the -i argument
|
||
# always points into the temp dir (pre-scaled files already live there;
|
||
# for no-scale we create a symlink).
|
||
if not encode_input.is_relative_to(self._temp_dir):
|
||
symlink_path = _temp_path_for(file_path, encode_input.suffix, worker_dir=self._temp_dir)
|
||
try:
|
||
symlink_path.unlink(missing_ok=True)
|
||
symlink_path.symlink_to(file_path.resolve())
|
||
self._current_temps.append(symlink_path)
|
||
encode_input = symlink_path
|
||
except OSError as e:
|
||
self.log_msg.emit(
|
||
f" WARN: Could not create symlink in temp dir: {e}. "
|
||
f"av1an work dir will be created next to source file."
|
||
)
|
||
# Track the work dir where av1an will actually create it
|
||
av1an_work = Path(f"{encode_input}.av1an")
|
||
self._current_temps.append(av1an_work)
|
||
|
||
# --- Build output path (preserve directory structure) ---
|
||
rel_path = file_path.relative_to(self.in_dir)
|
||
target_dir = self.out_dir / rel_path.parent
|
||
target_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
ext = self.container.ext
|
||
# Always add resolution suffix when a target resolution is selected
|
||
res_suffix = f"_{self.resolution.width}x{self.resolution.height}" if needs_scale else ""
|
||
output_f = target_dir / f"{file_path.stem}{res_suffix}_archived.{ext}"
|
||
|
||
return (encode_input, output_f)
|
||
|
||
def _check_disk_space(self, file_path: Path, output_f: Path, needs_scale: bool) -> None:
|
||
"""v4.4.0: Warn (not abort) if free disk space is less than the source size.
|
||
|
||
v4.4.1: warnings gated behind --verbose. Quiet mode = zero output.
|
||
"""
|
||
if not self.verbose:
|
||
return # v4.4.1: quiet mode — no disk-space warnings
|
||
try:
|
||
src_size = file_path.stat().st_size
|
||
except OSError:
|
||
return
|
||
if src_size < 1_073_741_824: # < 1 GB — skip check for small files
|
||
return
|
||
src_gb = src_size / 1_073_741_824
|
||
try:
|
||
out_usage = shutil.disk_usage(output_f.parent)
|
||
out_free_gb = out_usage.free / 1_073_741_824
|
||
if out_free_gb < src_gb:
|
||
self.log_msg.emit(
|
||
f" WARN: low disk space on output ({out_free_gb:.1f} GB free, "
|
||
f"source is {src_gb:.1f} GB) — encode may fail partway through"
|
||
)
|
||
except OSError:
|
||
pass
|
||
if needs_scale:
|
||
try:
|
||
tmp_usage = shutil.disk_usage(self._temp_dir)
|
||
tmp_free_gb = tmp_usage.free / 1_073_741_824
|
||
if tmp_free_gb < src_gb * 2:
|
||
self.log_msg.emit(
|
||
f" WARN: low disk space on temp ({tmp_free_gb:.1f} GB free, "
|
||
f"lossless intermediate may need ~{src_gb * 2:.1f} GB) — "
|
||
f"consider scaling to a smaller resolution or freeing space"
|
||
)
|
||
except OSError:
|
||
pass
|
||
|
||
def _output_already_encoded(self, file_path: Path, output_f: Path) -> bool:
|
||
"""v4.3.0: Check if output_f already exists with a matching codec.
|
||
|
||
Returns True (skip the encode) when ALL of the following hold:
|
||
- output_f exists on disk
|
||
- ffprobe can read it (not corrupt)
|
||
- video stream codec_name matches self.video_codec.ffprobe_codec_name
|
||
- audio stream codec_name matches self.audio_profile.ffprobe_codec_name
|
||
(when both the profile and the file have an audio stream)
|
||
- if scaling was requested, output resolution matches the target
|
||
"""
|
||
if not output_f.exists():
|
||
return False
|
||
if not self.env.ffprobe_path:
|
||
return False
|
||
info = ffprobe_validate(output_f, self.env.ffprobe_path)
|
||
if info is None:
|
||
return False
|
||
streams = info.get("streams", [])
|
||
vstream = next((s for s in streams if s.get("codec_type") == "video"), None)
|
||
astream = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
||
if not vstream:
|
||
return False
|
||
expected_v = self.video_codec.ffprobe_codec_name
|
||
if expected_v and vstream.get("codec_name") != expected_v:
|
||
return False
|
||
expected_a = self.audio_profile.ffprobe_codec_name
|
||
if expected_a and astream:
|
||
if astream.get("codec_name") != expected_a:
|
||
return False
|
||
if self.resolution.width is not None and self.resolution.height is not None:
|
||
actual_w = int(vstream.get("width", 0) or 0)
|
||
actual_h = int(vstream.get("height", 0) or 0)
|
||
if actual_w != self.resolution.width or actual_h != self.resolution.height:
|
||
return False
|
||
return True
|
||
|
||
def _can_ffmpeg_fallback(self) -> bool:
|
||
"""v6-01: Check if ffmpeg has the encoder for this codec.
|
||
|
||
Returns True if ffmpeg can encode with this codec's ffmpeg_encoder
|
||
(e.g. libsvtav1, libvpx-vp9, libx265), False otherwise.
|
||
Used to decide whether to retry a failed av1an encode with ffmpeg.
|
||
"""
|
||
ffmpeg_enc = self.video_codec.ffmpeg_encoder
|
||
lib_key = ffmpeg_lib_key_for(ffmpeg_enc)
|
||
return bool(self.env.ffmpeg_libs.get(lib_key, False))
|
||
|
||
def _encode_one(self, file_path, encode_input, output_f, worker_count,
|
||
chunk_method=None):
|
||
"""Dispatch to ffmpeg fallback or av1an. Returns True if encode succeeded.
|
||
|
||
ffmpeg fallback: delegates to _ffmpeg_fallback_encode (which itself
|
||
performs the size >=5% integrity check and unlinks bad output). No
|
||
temp cleanup or fail_count increment happens here for this path —
|
||
_process_one_file handles both at the call site, matching the original.
|
||
|
||
av1an: builds and runs the av1an command, performs the size >=5% check
|
||
inline, and wraps everything in try/except/finally so temps are always
|
||
cleaned up via _cleanup_current_temps() — matching the original. On
|
||
every failure path here, fail_count is incremented inside this method.
|
||
|
||
v4.0.0: *chunk_method* is an explicit override used by the y4m-pipe-break
|
||
retry path. When None, the method falls back to
|
||
``env.av1an_flags["chunk_method_override"]`` (set by env_probe or by
|
||
a previous retry) or av1an's auto-selection. When av1an fails with the
|
||
"Failed to read y4m frame delimiter" pattern (Hybrid chunk method on
|
||
phone-recorded MP4s with sparse keyframes), this method recursively
|
||
retries with ``chunk_method="select"`` and caches that choice so
|
||
subsequent files skip the wasted first attempt.
|
||
"""
|
||
# ── Choose encode path: av1an or ffmpeg fallback ──
|
||
if self.use_ffmpeg_fallback:
|
||
# ── Pure ffmpeg encode path ──
|
||
self.log_msg.emit(f" Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})")
|
||
return self._ffmpeg_fallback_encode(
|
||
file_path, encode_input, output_f,
|
||
)
|
||
|
||
# ── av1an encode path (original) ──
|
||
# Resolve encoder name with probe data
|
||
enc = self.video_codec.av1an_encoder
|
||
if enc in ("svt_av1", "svt") and "svt_name" in self.env.av1an_flags:
|
||
enc = self.env.av1an_flags["svt_name"]
|
||
|
||
# Build params via config table (no if/else).
|
||
# v4.1.2: do NOT inject --threads into av1an's --video-params.
|
||
# SvtAv1EncApp (the standalone CLI av1an invokes per-chunk) does
|
||
# not accept --threads — only --lp (logical processors). Injecting
|
||
# --threads produced "Unprocessed tokens: --threads" → every
|
||
# chunk failed 3x → no av1an output. Thread capping is done via
|
||
# av1an's --workers flag (chunk-parallel count) and via -threads
|
||
# in the ffmpeg fallback path (where libsvtav1 is a library).
|
||
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: explicit arg (retry) > env override > av1an auto.
|
||
# v4.0.0: when av1an auto-selects Hybrid (default when no VS source
|
||
# plugins are installed), phone-recorded MP4s with sparse keyframes
|
||
# fail with "Failed to read y4m frame delimiter". The retry path
|
||
# passes chunk_method="select" which uses VapourSynth's select()
|
||
# filter — slower but reliable.
|
||
effective_chunk_method = (
|
||
chunk_method
|
||
or self.env.av1an_flags.get("chunk_method_override")
|
||
)
|
||
if effective_chunk_method:
|
||
cmd.extend(["--chunk-method", effective_chunk_method])
|
||
self.log_msg.emit(
|
||
f" Chunking: {effective_chunk_method or 'auto'} "
|
||
f"(av1an default if no override)"
|
||
)
|
||
|
||
cmd.extend([
|
||
"--encoder", enc,
|
||
self.env.av1an_flags.get("video_params", "--video-params"), v_params,
|
||
self.env.av1an_flags.get("audio_params", "--audio-params"), audio_str,
|
||
"--concat", self.env.av1an_flags.get("concat_method", "ffmpeg"),
|
||
"-o", str(output_f),
|
||
])
|
||
|
||
# Log the full av1an command for debugging
|
||
self.log_msg.emit(f" CMD: {' '.join(cmd)}")
|
||
|
||
try:
|
||
result = self._run_with_stop_check(
|
||
cmd, env=_av1an_env(), timeout=self.encode_timeout, log_prefix=" ",
|
||
)
|
||
status, rc, stdout, stderr = result
|
||
|
||
if status == "stop":
|
||
# User requested STOP — do NOT increment fail_count (the
|
||
# user explicitly chose to abort, it isn't a transcode
|
||
# failure). Remove partial output. self._stop is already
|
||
# True (set by the UI thread), so the orchestrator's
|
||
# queue loop will break on the next iteration and emit
|
||
# "STOP: Aborted by user."
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
if status == "timeout":
|
||
self.fail_count += 1
|
||
self.log_msg.emit(f"{self._status_prefix()}FAIL: timeout (exceeded {self.encode_timeout}s limit)")
|
||
return False
|
||
|
||
# status == "ok" — wrap in CompletedProcess so the downstream
|
||
# returncode check, diagnostic dump, and pattern matching are
|
||
# byte-for-byte unchanged.
|
||
res = subprocess.CompletedProcess(cmd, rc, stdout, stderr)
|
||
|
||
if res.returncode == 0 and output_f.exists():
|
||
src_size = file_path.stat().st_size
|
||
out_size = output_f.stat().st_size
|
||
ratio = out_size / src_size if src_size > 0 else 0
|
||
|
||
# Integrity gate: 1KB absolute minimum. A valid container
|
||
# header alone is ~1KB; anything below is definitely corrupt.
|
||
# The duration check in _verify_and_finalize (≥95% of source
|
||
# duration) is the real quality gate for high-bitrate sources.
|
||
if out_size > 1024:
|
||
# Success — resolution/duration/subtitle/finalize happen
|
||
# in _verify_and_finalize (called by _process_one_file).
|
||
return True
|
||
else:
|
||
self.fail_count += 1
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: output too small ({out_size / 1024:.0f} KB)"
|
||
)
|
||
# Remove corrupt output
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
else:
|
||
stderr_full = res.stderr or ""
|
||
# v6: Don't increment fail_count yet — we may retry with
|
||
# ffmpeg fallback below. Only increment if the retry also
|
||
# fails (or no retry is possible).
|
||
# v4.4.2: move FAIL to _vlog. User only sees final outcome.
|
||
self._vlog(
|
||
f"{self._status_prefix()}av1an failed (exit code {res.returncode}) — attempting ffmpeg fallback"
|
||
)
|
||
self._vlog(" ─── av1an stderr (last 25 lines) ───")
|
||
stderr_lines = stderr_full.splitlines()
|
||
for line in stderr_lines[-25:]:
|
||
self._vlog(f" {line}")
|
||
self._vlog(" ────────────────────────────────────")
|
||
|
||
# Detect known av1an crash patterns and provide actionable fixes.
|
||
# Pattern table — add new patterns here, no nested ifs below.
|
||
# SEI CERT MSC04-C spirit: single source of truth for diagnostics.
|
||
#
|
||
# v5-03: Added "missing field `streams`" pattern — this is
|
||
# the error av1an emits when its internal ffprobe call
|
||
# returns JSON without a streams field, i.e. the input file
|
||
# is not a valid video. Also added `file` command output
|
||
# to the diagnostic so the user immediately sees "HTML
|
||
# document" (failed yt-dlp download) instead of guessing.
|
||
error_patterns: tuple[tuple[str, str, tuple[str, ...], bool], ...] = (
|
||
(
|
||
"Failed to get VSScript API",
|
||
"av1an cannot initialize VapourSynth — the binary was "
|
||
"compiled against a different VapourSynth version than "
|
||
"what is currently installed.",
|
||
(
|
||
" FIX (Arch): yay -S av1an OR cargo install av1an --force --locked",
|
||
" FIX (Debian): sudo apt install vapoursynth libvapoursynth-script-dev av1an",
|
||
" FIX (other): rebuild av1an against current VapourSynth",
|
||
" VapourSynth R77+ changed the VSScript API; av1an must be recompiled.",
|
||
),
|
||
True, # stop queue — every file will hit the same crash
|
||
),
|
||
(
|
||
"No usable encoder found",
|
||
"av1an cannot find the encoder binary (SvtAv1EncApp / vpxenc / x265).",
|
||
(
|
||
" Verify the encoder is installed and in PATH.",
|
||
" Arch: pacman -S svt-av1 libvpx-tools x265",
|
||
" Debian: apt install svt-av1 libvpx-tools x265",
|
||
),
|
||
True,
|
||
),
|
||
# v6-02: av1an scene-detection panic — per-file, not systematic.
|
||
(
|
||
"split scores is not empty",
|
||
"av1an panicked during scene detection (known av1an bug). "
|
||
"This is a per-file issue — the video content triggered a "
|
||
"Rust panic in av1an's split module. Will retry with ffmpeg.",
|
||
(
|
||
" This is an av1an internal bug, not a file corruption issue.",
|
||
" The file is a valid video — ffmpeg can encode it directly.",
|
||
),
|
||
False, # don't stop queue — retry with ffmpeg fallback
|
||
),
|
||
(
|
||
"missing field `streams`",
|
||
"av1an's internal ffprobe call could not parse this file — "
|
||
"the file is not a valid video container. This is NOT an "
|
||
"av1an or ffmpeg bug; the input file itself is invalid.",
|
||
(
|
||
" The file is likely a failed yt-dlp download (HTML error",
|
||
" page saved as .mp4), a truncated download, or not a video",
|
||
" at all. Run `file <filename>` to confirm.",
|
||
),
|
||
False, # don't stop queue — other files may be valid
|
||
),
|
||
(
|
||
"Invalid data found when processing input",
|
||
"ffmpeg cannot read this input file — the file is corrupt, "
|
||
"truncated, or not a valid video container.",
|
||
(
|
||
" Run `file <filename>` to see what the file actually is.",
|
||
" If it's 'HTML document' or 'ASCII text', it's a failed",
|
||
" yt-dlp download — re-download the source video.",
|
||
" If it's 'data', the file may be truncated or encrypted.",
|
||
),
|
||
False,
|
||
),
|
||
(
|
||
"Error: End of file",
|
||
"av1an hit EOF during scene detection — usually a VapourSynth "
|
||
"source plugin issue with the intermediate file.",
|
||
(
|
||
" Try a different --chunk-method (override via env probe).",
|
||
" If pre-scaling, ensure the intermediate is libx265 CRF 0 (not ffv1).",
|
||
),
|
||
False,
|
||
),
|
||
(
|
||
"could not open input",
|
||
"av1an cannot read this input file — possibly corrupt or "
|
||
"an unsupported codec for the VapourSynth source plugin.",
|
||
(
|
||
" Try playing the file with ffplay to verify it's not corrupt.",
|
||
" Run: ffmpeg -i <file> -f null - to see the decode error.",
|
||
),
|
||
False,
|
||
),
|
||
# v4.0.0: y4m pipe break — Hybrid chunk method can't handle
|
||
# files with sparse keyframes. This is the "works up until
|
||
# near the end, never saves chunks into a full file" bug.
|
||
# The encoder prints a SUMMARY block (it ran briefly on
|
||
# partial data before the pipe broke), which previously
|
||
# triggered the v6-03 "concat failure" misdiagnosis. The
|
||
# retry path switches to --chunk-method select which
|
||
# extracts frames one-by-one via VapourSynth's select()
|
||
# filter, avoiding the keyframe-alignment issue.
|
||
(
|
||
"Failed to read y4m frame delimiter",
|
||
"av1an's chunk extractor produced a broken y4m pipe — "
|
||
"the source's keyframe layout doesn't align with scene "
|
||
"boundaries. This is the Hybrid chunk method's known "
|
||
"failure mode for phone-recorded MP4s with sparse "
|
||
"keyframes (only I-frames every 5-10s). The encoder "
|
||
"printed a SUMMARY block because it ran briefly on "
|
||
"partial data before the pipe broke — this is NOT a "
|
||
"concat failure.",
|
||
(
|
||
" Will retry with --chunk-method select (VapourSynth",
|
||
" select() filter), which extracts frames one-by-one",
|
||
" and avoids the keyframe-alignment issue.",
|
||
" This is per-file, not systematic — subsequent files",
|
||
" use select automatically.",
|
||
),
|
||
False, # don't stop queue — retry with select chunk method
|
||
),
|
||
)
|
||
diagnosis_emitted = False
|
||
for marker, summary, fixes, stop_queue in error_patterns:
|
||
if marker.lower() in stderr_full.lower():
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(f"DIAGNOSIS: {summary}")
|
||
for fix in fixes:
|
||
self.log_msg.emit(fix)
|
||
# v5-03: run `file` on the input to tell the user
|
||
# what the file actually is. This is especially
|
||
# useful for "missing field streams" and "Invalid
|
||
# data found" — the user immediately sees "HTML
|
||
# document" instead of guessing.
|
||
if marker in ("missing field `streams`",
|
||
"Invalid data found when processing input",
|
||
"could not open input"):
|
||
file_type = _identify_file_type(file_path)
|
||
if file_type:
|
||
self.log_msg.emit(f" File type: {file_type}")
|
||
if "HTML" in file_type or "ASCII" in file_type or "text" in file_type:
|
||
self.log_msg.emit(
|
||
" → This is a TEXT file, not a video. "
|
||
"Failed yt-dlp download — re-download the source."
|
||
)
|
||
elif "data" in file_type and "ISO Media" not in file_type:
|
||
self.log_msg.emit(
|
||
" → File type is 'data' — truncated, encrypted, "
|
||
"or partial download."
|
||
)
|
||
if stop_queue:
|
||
self._stop = True
|
||
self.log_msg.emit(
|
||
f"STOP: Skipping remaining files (same {marker} issue)."
|
||
)
|
||
diagnosis_emitted = True
|
||
break
|
||
|
||
if not diagnosis_emitted:
|
||
# No known pattern matched — show the user where to look.
|
||
# v6-03: detect "encoder SUMMARY in stderr + non-zero exit"
|
||
# — the encoder succeeded but av1an failed to produce output.
|
||
# This is the "chunks but never saves a file" pattern caused
|
||
# by av1an's concat step failing.
|
||
# v4.0.0: only treat as concat failure when y4m break is NOT
|
||
# present. The y4m break pattern (above) emits its own
|
||
# diagnosis and triggers a retry with --chunk-method select.
|
||
# The SUMMARY block appears in both cases (encoder ran
|
||
# briefly before failing), so we must check for the y4m
|
||
# marker to avoid misdiagnosing chunk-extraction failures
|
||
# as concat failures.
|
||
if ("SUMMARY" in stderr_full
|
||
and "Average Speed" in stderr_full
|
||
and "Failed to read y4m frame delimiter" not in stderr_full):
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
"DIAGNOSIS: SVT-AV1 encoder completed successfully (SUMMARY"
|
||
" block found in stderr), but av1an failed to produce the"
|
||
" output file. This is an av1an concat failure — the encoder"
|
||
" did its job but av1an's post-encode merge step crashed."
|
||
)
|
||
self.log_msg.emit(
|
||
" This is a known av1an bug on short videos (1-2 scenes)"
|
||
" where concat of a single chunk fails. Will retry with"
|
||
" ffmpeg fallback."
|
||
)
|
||
else:
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
"DIAGNOSIS: Unknown av1an failure. Inspect the full stderr above."
|
||
)
|
||
# v5-03: run `file` on the input as a fallback diagnostic.
|
||
file_type = _identify_file_type(file_path)
|
||
if file_type:
|
||
self.log_msg.emit(f" File type: {file_type}")
|
||
self.log_msg.emit(
|
||
" Common causes: (1) out of disk space in temp dir, "
|
||
"(2) AV1 concat failed silently — try installing mkvtoolnix, "
|
||
"(3) av1an version too old for --concat flag — check av1an --help, "
|
||
"(4) input file is not a valid video (run `file <filename>`)."
|
||
)
|
||
|
||
# ── v4.0.0: y4m pipe break retry — switch to --chunk-method select ──
|
||
# If av1an failed with the y4m break pattern AND we're not
|
||
# already using select, retry with --chunk-method select. This
|
||
# is faster than the ffmpeg fallback (chunk-parallel still
|
||
# works) and produces identical-quality output (same encoder,
|
||
# same params). Cache the working method so subsequent files
|
||
# skip the wasted first attempt.
|
||
#
|
||
# NOTE: Do NOT clean up _current_temps before the retry —
|
||
# encode_input (symlink or pre-scaled file) is in
|
||
# _current_temps and the recursive _encode_one call needs it.
|
||
# The finally block below will clean up everything after the
|
||
# recursive call returns (its own finally clears the list
|
||
# first; our finally then runs on an empty list — no-op).
|
||
y4m_break = "Failed to read y4m frame delimiter" in stderr_full
|
||
if (not self._stop and y4m_break
|
||
and effective_chunk_method != "select"
|
||
and self.env.av1an_flags.get("has_chunk_method", True)):
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
f" RETRY: Re-encoding {file_path.name} with "
|
||
f"--chunk-method select (slower but reliable for "
|
||
f"files with sparse keyframes)..."
|
||
)
|
||
output_f.unlink(missing_ok=True)
|
||
# Cache for subsequent files — avoids the wasted first attempt
|
||
self.env.av1an_flags["chunk_method_override"] = "select"
|
||
return self._encode_one(
|
||
file_path, encode_input, output_f, worker_count,
|
||
chunk_method="select",
|
||
)
|
||
|
||
# ── v6-01: Per-file av1an→ffmpeg fallback ──
|
||
# If av1an failed for this file AND it's NOT a systematic issue
|
||
# (VSScript API, missing encoder — those set self._stop=True),
|
||
# AND ffmpeg has the encoder for this codec, retry with ffmpeg.
|
||
# This handles:
|
||
# - av1an concat failures (encoder succeeded but no output)
|
||
# - av1an scene-detection panics ("split scores is not empty")
|
||
# - Any other per-file av1an internal failure
|
||
#
|
||
# NOTE: Do NOT clean up _current_temps before the retry —
|
||
# encode_input (symlink or pre-scaled file) is in _current_temps
|
||
# and _ffmpeg_fallback_encode needs it. The finally block below
|
||
# will clean up everything after the retry completes.
|
||
if not self._stop and self._can_ffmpeg_fallback():
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit(
|
||
f" RETRY: Attempting ffmpeg fallback for {file_path.name} "
|
||
f"({self.video_codec.ffmpeg_encoder})..."
|
||
)
|
||
# Remove any partial output av1an may have left
|
||
output_f.unlink(missing_ok=True)
|
||
# Retry with ffmpeg — _ffmpeg_fallback_encode does NOT
|
||
# increment fail_count on failure (the caller does that).
|
||
# If it succeeds, we return True WITHOUT incrementing
|
||
# fail_count — the file was saved, just via a different path.
|
||
fb_ok = self._ffmpeg_fallback_encode(
|
||
file_path, encode_input, output_f,
|
||
)
|
||
if fb_ok:
|
||
self._vlog(
|
||
f" RETRY OK: ffmpeg fallback succeeded for {file_path.name}"
|
||
)
|
||
return True
|
||
else:
|
||
self.fail_count += 1
|
||
# v4.4.2: only user-facing FAIL for av1an path.
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: av1an + ffmpeg both failed"
|
||
)
|
||
self._vlog(
|
||
f" RETRY FAIL: ffmpeg fallback also failed for {file_path.name}"
|
||
)
|
||
return False
|
||
else:
|
||
# No retry possible — this is a systematic issue (stop_queue
|
||
# was set) or ffmpeg lacks the encoder.
|
||
self.fail_count += 1
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: av1an (no ffmpeg fallback available)"
|
||
)
|
||
return False
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
self.fail_count += 1
|
||
self.log_msg.emit(f"{self._status_prefix()}FAIL: system error: {e}")
|
||
return False
|
||
finally:
|
||
# Always clean this file's temps before moving to next
|
||
self._cleanup_current_temps()
|
||
|
||
def _verify_and_finalize(self, file_path, output_f, encode_input, needs_scale):
|
||
"""Post-encode verification + subtitle mux + source deletion deferral.
|
||
|
||
Runs after a successful _encode_one. Performs:
|
||
- output resolution verification (if scaling was requested)
|
||
- duration integrity check (>= 95% of source)
|
||
- subtitle mux (if requested)
|
||
- success_count increment + SUCCESS log
|
||
- source deletion deferral (if delete_source is set)
|
||
|
||
Returns True if the file was accepted, False if any check failed.
|
||
On failure, fail_count is incremented and output_f is unlinked before
|
||
returning False. Temp cleanup is the caller's responsibility — it
|
||
differs between the av1an path (already done in _encode_one's finally)
|
||
and the ffmpeg fallback path (done explicitly in _process_one_file).
|
||
"""
|
||
# Post-encode resolution verification
|
||
if needs_scale and self.env.ffprobe_path:
|
||
if not _verify_output_resolution(
|
||
output_f, self.env.ffprobe_path,
|
||
self.resolution.width, self.resolution.height,
|
||
):
|
||
self.fail_count += 1
|
||
self.log_msg.emit(
|
||
f"{self._status_prefix()}FAIL: resolution verification failed "
|
||
f"(expected {self.resolution.width}x{self.resolution.height})"
|
||
)
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
src_size = file_path.stat().st_size
|
||
out_size = output_f.stat().st_size
|
||
ratio = out_size / src_size if src_size > 0 else 0
|
||
|
||
# Duration integrity check (>= 95% of source)
|
||
dur_ok = True
|
||
dur_info = ""
|
||
if self.env.ffprobe_path:
|
||
src_dur = ffprobe_duration(file_path, self.env.ffprobe_path)
|
||
out_dur = ffprobe_duration(output_f, self.env.ffprobe_path)
|
||
if src_dur and out_dur:
|
||
dur_ratio = out_dur / src_dur
|
||
dur_ok = dur_ratio >= 0.95
|
||
dur_info = f", duration {out_dur:.1f}s/{src_dur:.1f}s ({dur_ratio * 100:.0f}%)"
|
||
|
||
if not dur_ok:
|
||
self.fail_count += 1
|
||
self.log_msg.emit(f"{self._status_prefix()}FAIL: duration mismatch{dur_info}")
|
||
output_f.unlink(missing_ok=True)
|
||
return False
|
||
|
||
# Mux subtitle if requested (needs source file intact)
|
||
if self.subtitle_lang:
|
||
self._mux_subtitle(file_path, output_f)
|
||
|
||
self.success_count += 1
|
||
# v4.4.0: combined single-line status with [N/total] prefix.
|
||
prefix = self._status_prefix()
|
||
if self.verbose:
|
||
self.log_msg.emit(
|
||
f"{prefix}SUCCESS: {src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB "
|
||
f"({ratio * 100:.0f}%{dur_info})"
|
||
)
|
||
else:
|
||
self.log_msg.emit(
|
||
f"{prefix}OK: {src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB "
|
||
f"({ratio * 100:.0f}%)"
|
||
)
|
||
# Defer source deletion until after final cleanup
|
||
if self.delete_source:
|
||
self._sources_to_delete.append(file_path)
|
||
return True
|
||
|
||
def _cleanup_current_temps(self):
|
||
"""Remove all tracked temp files/dirs for the current file.
|
||
|
||
Resilient: each removal is try/except'd individually so one bad path
|
||
doesn't block the rest. Clears the tracking list when done.
|
||
"""
|
||
for tf in self._current_temps:
|
||
try:
|
||
if tf.is_dir():
|
||
shutil.rmtree(str(tf), ignore_errors=True)
|
||
elif tf.exists():
|
||
tf.unlink()
|
||
except Exception:
|
||
pass
|
||
self._current_temps.clear()
|
||
|
||
def _final_cleanup_sweep(self):
|
||
"""Residual sweep to catch any orphaned temp files.
|
||
|
||
v3 (OTC-013): primary target is now the per-worker subdir
|
||
(``~/.cache/OpenTranscode/tmp/worker-<pid>/``), NOT the shared
|
||
app temp dir. This is safe because the subdir ONLY contains
|
||
this worker's intermediates — a concurrent worker has its own
|
||
subdir. The previous "nuclear" sweep of the entire app temp
|
||
dir was a race-condition risk that this eliminates.
|
||
Also scans in_dir/out_dir as a safety net for legacy temp files
|
||
written by older versions that placed temps next to source files.
|
||
"""
|
||
swept = 0
|
||
|
||
# v3: sweep ONLY this worker's per-PID subdir, not the shared parent.
|
||
# This is safe — the subdir contains only this worker's intermediates.
|
||
if self._temp_dir.is_dir():
|
||
for hit in self._temp_dir.iterdir():
|
||
try:
|
||
if hit.is_dir():
|
||
shutil.rmtree(str(hit), ignore_errors=True)
|
||
else:
|
||
hit.unlink(missing_ok=True)
|
||
swept += 1
|
||
except OSError:
|
||
# SEI CERT ERR01-C: narrow to OSError (file ops).
|
||
# Best-effort sweep must not crash on a single bad path.
|
||
pass
|
||
|
||
# Safety-net sweep of user directories (for legacy temp files
|
||
# written by older versions that placed temps next to source files)
|
||
legacy_patterns = ["*.scaled_tmp.mkv", "*.av1an", "*_encodes", "*.*.av1an"]
|
||
for search_dir in (self.in_dir, self.out_dir):
|
||
if not search_dir.is_dir():
|
||
continue
|
||
for pattern in legacy_patterns:
|
||
for hit in search_dir.rglob(pattern):
|
||
try:
|
||
if hit.is_dir():
|
||
shutil.rmtree(str(hit), ignore_errors=True)
|
||
else:
|
||
hit.unlink(missing_ok=True)
|
||
swept += 1
|
||
except OSError:
|
||
pass
|
||
# Also clean any orphans still in _current_temps (e.g. stop/crash mid-loop)
|
||
self._cleanup_current_temps()
|
||
# v3: remove the now-empty per-worker subdir itself.
|
||
try:
|
||
self._temp_dir.rmdir()
|
||
except OSError:
|
||
pass # not empty / not ours — leave it
|
||
if swept:
|
||
self.log_msg.emit(f"CLEANUP: Swept {swept} residual temp file(s)/dir(s).")
|
||
|
||
# ── Audio loudness analysis (dual-pass normalization) ──
|
||
|
||
def _analyze_audio_loudness(self, file_path: Path) -> float | None:
|
||
"""Dual-pass loudnorm analysis for a single file.
|
||
|
||
Pass 1: Run loudnorm in analysis-only mode to measure the file's current
|
||
integrated loudness (I) and true peak (TP).
|
||
|
||
Returns the dB gain to apply, or None if analysis fails (falls back to
|
||
the knob's static value).
|
||
"""
|
||
if not self.env.ffmpeg_path:
|
||
return None
|
||
if abs(self.audio_level_db) < 0.01:
|
||
return None # knob is at 0 — no normalization requested
|
||
|
||
target_lufs = self.audio_level_db # knob value IS the target LUFS
|
||
|
||
try:
|
||
# Pass 1: analyze current loudness
|
||
analysis_cmd = [
|
||
self.env.ffmpeg_path,
|
||
"-i", str(file_path),
|
||
"-af", (
|
||
f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11:"
|
||
f"print_format=json"
|
||
),
|
||
"-f", "null", "-",
|
||
]
|
||
res = subprocess.run(
|
||
analysis_cmd, capture_output=True, text=True, timeout=120,
|
||
)
|
||
|
||
# Parse the JSON stats from stderr (loudnorm prints to stderr)
|
||
stderr = res.stderr or ""
|
||
|
||
# Find the JSON block
|
||
json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', stderr, re.DOTALL)
|
||
if not json_match:
|
||
return None
|
||
|
||
stats = json.loads(json_match.group())
|
||
|
||
input_i = float(stats.get("input_i", "-99"))
|
||
input_tp = float(stats.get("input_tp", "-99"))
|
||
target_tp = float(stats.get("target_tp", "-1.5"))
|
||
|
||
# If file is already silent or near-silent, skip
|
||
if input_i <= -70:
|
||
return None
|
||
|
||
# Compute the gain loudnorm would apply
|
||
gain_db = target_lufs - input_i
|
||
|
||
# Pass 2 concept: check if applying this gain would push peaks
|
||
# above our ceiling. The ceiling is target_tp (default -1.5 dBTP).
|
||
# We want 15% headroom below that ceiling.
|
||
headroom_db = abs(target_tp) * 0.15
|
||
peak_ceiling = target_tp + headroom_db
|
||
|
||
# If the file's true peak + gain would exceed the ceiling, clamp
|
||
projected_peak = input_tp + gain_db
|
||
if projected_peak > peak_ceiling:
|
||
gain_db = peak_ceiling - input_tp
|
||
|
||
self.log_msg.emit(
|
||
f" Audio: {input_i:.1f} LUFS -> {target_lufs:.1f} LUFS "
|
||
f"(gain {gain_db:+.1f} dB, peak {input_tp:.1f} -> "
|
||
f"{input_tp + gain_db:.1f} dBTP)"
|
||
)
|
||
return gain_db
|
||
|
||
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
||
# ValueError covers json.JSONDecodeError and float() parse failures
|
||
self.log_msg.emit(f" Audio: loudnorm analysis failed ({e}), using knob value")
|
||
return None
|
||
|
||
# ── Subtitle extraction & muxing ──
|
||
|
||
def _find_subtitle_stream(self, source: Path, lang: str) -> tuple[int | None, str]:
|
||
"""Find subtitle stream in source matching language code.
|
||
Prefers forced disposition tracks. Returns (stream_index, codec_name)."""
|
||
if not self.env.ffprobe_path:
|
||
return (None, "")
|
||
|
||
info = ffprobe_validate(source, self.env.ffprobe_path)
|
||
if not info:
|
||
return (None, "")
|
||
|
||
forced_match = None
|
||
any_match = None
|
||
|
||
for stream in info.get("streams", []):
|
||
if stream.get("codec_type") != "subtitle":
|
||
continue
|
||
tags = stream.get("tags", {})
|
||
if tags.get("language", "").lower() != lang.lower():
|
||
continue
|
||
|
||
idx = stream.get("index")
|
||
codec = stream.get("codec_name", "")
|
||
disposition = stream.get("disposition", {})
|
||
|
||
if disposition.get("forced") and forced_match is None:
|
||
forced_match = (idx, codec)
|
||
if any_match is None:
|
||
any_match = (idx, codec)
|
||
|
||
return forced_match if forced_match else (any_match or (None, ""))
|
||
|
||
def _mux_subtitle(self, source: Path, output: Path):
|
||
"""Mux a subtitle track from source into the encoded output (soft sub).
|
||
Uses stream copy for MKV; converts to WebVTT for WebM containers."""
|
||
sub_idx, sub_codec = self._find_subtitle_stream(source, self.subtitle_lang)
|
||
|
||
if sub_idx is None:
|
||
self.log_msg.emit(f" SUBS: No {self.subtitle_lang} subtitle found in {source.name}")
|
||
return
|
||
|
||
# WebM only supports WebVTT natively; MKV carries any subtitle codec
|
||
is_webm = output.suffix.lower() == ".webm"
|
||
sub_codec_flag = "copy" if not is_webm else "webvtt"
|
||
|
||
tmp_out = output.with_suffix(output.suffix + ".submux_tmp")
|
||
try:
|
||
cmd = [
|
||
self.env.ffmpeg_path,
|
||
"-i", str(output), # encoded output (video + audio)
|
||
"-i", str(source), # original source (subtitle source)
|
||
"-map", "0", # all streams from encoded output
|
||
"-map", "-0:s", # strip any subtitle from output
|
||
"-map", f"1:{sub_idx}", # subtitle from source
|
||
"-c:v", "copy",
|
||
"-c:a", "copy",
|
||
"-c:s", sub_codec_flag,
|
||
"-y",
|
||
str(tmp_out),
|
||
]
|
||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||
|
||
if res.returncode == 0 and tmp_out.exists() and tmp_out.stat().st_size > 0:
|
||
output.unlink()
|
||
tmp_out.rename(output)
|
||
self.log_msg.emit(
|
||
f" SUBS: Muxed {self.subtitle_lang} sub ({sub_codec}) into {output.name}"
|
||
)
|
||
else:
|
||
tmp_out.unlink(missing_ok=True)
|
||
tail = (res.stderr or "")[-200:]
|
||
self.log_msg.emit(f" SUBS WARN: Remux failed for {output.name}: {tail}")
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
tmp_out.unlink(missing_ok=True)
|
||
self.log_msg.emit(f" SUBS ERROR: {e}")
|
||
|
||
def stop(self):
|
||
self._stop = True
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# SOURCE BUILD WORKER — compile VS + av1an from git
|
||
# ──────────────────────────────────────────────
|
||
|
||
class SourceBuildWorker(QThread):
|
||
"""Builds VapourSynth and/or av1an from git to resolve ABI mismatches.
|
||
|
||
Runs in a background thread. Emits progress via log_msg.
|
||
When done, emits build_done(success, message).
|
||
|
||
Everything installs to the user's home directory (no sudo for install):
|
||
VapourSynth → ~/.local/lib/ (av1an finds it via LD_LIBRARY_PATH)
|
||
av1an → ~/.cargo/bin/ (already in PATH)
|
||
Only build-dependency installation (pacman -S) may need sudo.
|
||
"""
|
||
log_msg = Signal(str)
|
||
build_done = Signal(bool, str) # (success, detail)
|
||
|
||
def __init__(self, build_vs: bool = True, build_av1an: bool = True,
|
||
build_ffmpeg_iamf: bool = False):
|
||
super().__init__()
|
||
self.build_vs = build_vs
|
||
self.build_av1an = build_av1an
|
||
self.build_ffmpeg_iamf = build_ffmpeg_iamf
|
||
self._stop = False
|
||
# Private per-worker environment snapshot. Mutating os.environ is
|
||
# process-global and leaks across threads/subsequent subprocesses;
|
||
# _build_env is local to this worker and passed via env= to every
|
||
# subprocess.run call below (see _run_cmd).
|
||
self._build_env: dict[str, str] = os.environ.copy()
|
||
|
||
def _extend_env(self, var: str, value: str, prepend: bool = False):
|
||
"""Add ``value`` to ``self._build_env[var]`` (NOT ``os.environ``).
|
||
|
||
``prepend=True`` places ``value`` first so it shadows any existing
|
||
entry (e.g. ~/.local/bin must shadow /usr/bin, libiamf's
|
||
PKG_CONFIG_PATH must shadow the system pkgconfig dir); default
|
||
appends (e.g. extending PATH with ~/.cargo/bin). Caller is
|
||
responsible for any idempotency check (matches the original
|
||
per-site ``if x not in existing:`` pattern). rstrip(":") on
|
||
prepend avoids a trailing colon when ``var`` was previously unset.
|
||
"""
|
||
existing = self._build_env.get(var, "")
|
||
if prepend:
|
||
self._build_env[var] = f"{value}:{existing}".rstrip(":")
|
||
else:
|
||
self._build_env[var] = f"{existing}:{value}" if existing else value
|
||
|
||
def _run_cmd(self, cmd, cwd=None, timeout=600, label=""):
|
||
"""Run a command, log output, return (returncode, combined_output)."""
|
||
self.log_msg.emit(f" $ {' '.join(cmd[:6])}{'...' if len(cmd)>6 else ''}")
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=timeout, cwd=cwd, env=self._build_env)
|
||
# Log last few lines of stderr for diagnostics
|
||
if r.stderr:
|
||
for line in r.stderr.strip().splitlines()[-5:]:
|
||
self.log_msg.emit(f" {line}")
|
||
if r.returncode != 0 and r.stdout:
|
||
for line in r.stdout.strip().splitlines()[-3:]:
|
||
self.log_msg.emit(f" {line}")
|
||
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
||
except subprocess.TimeoutExpired:
|
||
self.log_msg.emit(f" TIMEOUT ({timeout}s) running: {label or cmd[0]}")
|
||
return -1, f"timeout after {timeout}s"
|
||
except (OSError, subprocess.SubprocessError) as e:
|
||
self.log_msg.emit(f" ERROR: {e}")
|
||
return -1, str(e)
|
||
|
||
def _sudo_cmd(self, cmd, timeout=120, label=""):
|
||
"""Run a command with sudo (or pkexec as graphical fallback)."""
|
||
# Try pkexec first (graphical polkit prompt — works in desktop sessions)
|
||
pkexec = shutil.which("pkexec")
|
||
if pkexec:
|
||
return self._run_cmd([pkexec] + cmd, timeout=timeout, label=label or cmd[0])
|
||
# Fall back to sudo (needs a terminal; may fail silently)
|
||
return self._run_cmd(["sudo"] + cmd, timeout=timeout, label=label or cmd[0])
|
||
|
||
def run(self):
|
||
try:
|
||
# ── Install build dependencies (may need one sudo prompt) ──
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Installing build dependencies ===")
|
||
all_deps = [
|
||
"meson", "ninja", "gcc", "pkg-config", "git",
|
||
"nasm", "yasm", "cmake", "python", "make",
|
||
]
|
||
need_rust = self.build_av1an and not shutil.which("cargo")
|
||
if need_rust:
|
||
all_deps.append("rust")
|
||
|
||
# Only invoke sudo if at least one dep is missing
|
||
missing = [d for d in all_deps if not shutil.which(d)]
|
||
if missing:
|
||
self.log_msg.emit(f" Missing: {', '.join(missing)} — installing via pacman")
|
||
rc, _ = self._sudo_cmd(
|
||
["pacman", "-S", "--needed", "--noconfirm"] + all_deps,
|
||
timeout=300, label="pacman build-deps",
|
||
)
|
||
if rc != 0:
|
||
self.log_msg.emit(" (some deps may already be installed — continuing)")
|
||
else:
|
||
self.log_msg.emit(" All build dependencies already installed.")
|
||
|
||
# Ensure cargo is in PATH after potential install.
|
||
# NOTE: /root/.cargo/bin was dropped (OTC-015/v3-08) — root's
|
||
# cargo dir is not readable by a non-root user. ~/.cargo/bin
|
||
# covers the user's rustup install; /usr/bin is already in the
|
||
# default PATH and is appended here only to match the original
|
||
# mutation's intent (cargo from pacman lives there).
|
||
self._extend_env("PATH", "/usr/bin")
|
||
self._extend_env("PATH", str(Path.home() / ".cargo" / "bin"))
|
||
if not shutil.which("cargo") and self.build_av1an:
|
||
self.log_msg.emit(" FATAL: cargo not found after deps install. Aborting.")
|
||
self.build_done.emit(False, "Rust/cargo not available")
|
||
return
|
||
|
||
# ── Optional: ffmpeg build deps (libopus, libvorbis dev pkgs) ──
|
||
if self.build_ffmpeg_iamf:
|
||
self._install_ffmpeg_build_deps()
|
||
|
||
# ── Build & install VapourSynth to ~/.local (NO sudo needed) ──
|
||
if self.build_vs:
|
||
self._build_vapoursynth()
|
||
|
||
# ── Build av1an to ~/.cargo/bin (NO sudo needed) ──
|
||
if self.build_av1an:
|
||
self._build_av1an()
|
||
|
||
# ── Build libiamf + ffmpeg with --enable-libiamf to ~/.local ──
|
||
if self.build_ffmpeg_iamf:
|
||
self._build_libiamf()
|
||
self._build_ffmpeg_with_iamf()
|
||
|
||
# ── Ensure LD_LIBRARY_PATH includes local VS libs ──
|
||
local_lib = str(Path.home() / ".local" / "lib")
|
||
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
|
||
if local_lib not in existing_ld:
|
||
self._extend_env("LD_LIBRARY_PATH", local_lib, prepend=True)
|
||
self.log_msg.emit(f" Set LD_LIBRARY_PATH to include {local_lib}")
|
||
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Source build complete ===")
|
||
self.build_done.emit(True, "Build and install completed (local ~/.local/).")
|
||
|
||
except Exception as e:
|
||
# SEI CERT ERR01-C: justified — this method orchestrates a long
|
||
# multi-step build (git clone, meson, ninja, cargo install) whose
|
||
# helper methods signal failure by `raise Exception(msg)` (15
|
||
# sites). Catching Exception here converts any of those into a
|
||
# user-facing build_done(False, ...) signal instead of crashing
|
||
# the QThread. Narrowing would require refactoring all `raise
|
||
# Exception(...)` call sites — out of scope for ERR01-C pass.
|
||
self.log_msg.emit(f"BUILD FAILED: {e}")
|
||
self.build_done.emit(False, str(e))
|
||
|
||
def _build_vapoursynth(self):
|
||
"""Clone, build, and install VapourSynth to ~/.local/ (no sudo needed)."""
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Building VapourSynth from git ===")
|
||
self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)")
|
||
build_dir = Path("/tmp/vapoursynth-git-build")
|
||
local_prefix = str(Path.home() / ".local")
|
||
|
||
if build_dir.exists():
|
||
self.log_msg.emit(f" Cleaning old build directory...")
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
# Clone (shallow — faster)
|
||
rc, out = self._run_cmd(
|
||
["git", "clone", "--depth", "1",
|
||
"https://github.com/vapoursynth/vapoursynth.git",
|
||
str(build_dir)],
|
||
timeout=120, label="git clone vapoursynth",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"git clone VapourSynth failed: {out[-300:]}")
|
||
|
||
# Meson setup — install to ~/.local so it doesn't touch system dirs
|
||
self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...")
|
||
rc, out = self._run_cmd(
|
||
["meson", "setup", "build",
|
||
f"--prefix={local_prefix}", "--libdir=lib"],
|
||
cwd=str(build_dir), timeout=120, label="meson setup",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"meson setup failed: {out[-500:]}")
|
||
|
||
# Build
|
||
self.log_msg.emit(" Compiling VapourSynth (this may take a few minutes)...")
|
||
rc, out = self._run_cmd(
|
||
["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
|
||
cwd=str(build_dir), timeout=900, label="ninja build",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"ninja build failed: {out[-500:]}")
|
||
|
||
# Install to ~/.local/ — NO sudo needed (user owns this directory)
|
||
self.log_msg.emit(" Installing VapourSynth to ~/.local/ ...")
|
||
rc, out = self._run_cmd(
|
||
["ninja", "-C", "build", "install"],
|
||
cwd=str(build_dir), timeout=120, label="ninja install",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"ninja install failed: {out[-500:]}")
|
||
|
||
self.log_msg.emit(f" VapourSynth installed to {local_prefix}/ (libs in {local_prefix}/lib/)")
|
||
|
||
# Cleanup build directory
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
def _build_av1an(self):
|
||
"""Clone and build av1an from git. Installs to ~/.cargo/bin/ (no sudo needed)."""
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Building av1an from git ===")
|
||
self.log_msg.emit(" Install target: ~/.cargo/bin/ (no system-wide changes)")
|
||
|
||
# Ensure cargo is in PATH
|
||
cargo_bin = shutil.which("cargo")
|
||
if not cargo_bin:
|
||
# Common locations
|
||
for p in [Path.home() / ".cargo" / "bin" / "cargo", "/usr/bin/cargo"]:
|
||
if p.exists():
|
||
self._extend_env("PATH", str(p.parent))
|
||
cargo_bin = str(p)
|
||
break
|
||
if not cargo_bin:
|
||
raise Exception("cargo not found — cannot build av1an")
|
||
|
||
self.log_msg.emit(f" Using cargo at: {cargo_bin}")
|
||
self.log_msg.emit(" Compiling av1an (this may take 10-30 minutes)...")
|
||
|
||
rc, out = self._run_cmd(
|
||
["cargo", "install", "av1an",
|
||
"--git", "https://github.com/master-of-zen/av1an",
|
||
"--force", "--root", str(Path.home() / ".cargo")],
|
||
timeout=3600, label="cargo install av1an",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"cargo install av1an failed: {out[-500:]}")
|
||
|
||
new_av1an = Path.home() / ".cargo" / "bin" / "av1an"
|
||
if new_av1an.exists():
|
||
self.log_msg.emit(f" av1an installed: {new_av1an}")
|
||
else:
|
||
self.log_msg.emit(" WARNING: av1an binary not found at expected path after build.")
|
||
|
||
def _install_ffmpeg_build_deps(self):
|
||
"""Install ffmpeg build deps (libopus, libvorbis dev packages).
|
||
|
||
Uses pkg-config to detect missing libraries, then installs the
|
||
corresponding Arch/pacman packages. On other distros the user
|
||
must install these manually; the log will name them.
|
||
"""
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Checking ffmpeg build dependencies ===")
|
||
|
||
# (pkg-config name, Arch package name, Debian package name)
|
||
pkg_checks = [
|
||
("opus", "opus", "libopus-dev"),
|
||
("vorbis", "libvorbis", "libvorbis-dev"),
|
||
("ogg", "libogg", "libogg-dev"),
|
||
]
|
||
missing_arch = []
|
||
missing_debian = []
|
||
for pc_name, arch_pkg, debian_pkg in pkg_checks:
|
||
rc, _ = self._run_cmd(
|
||
["pkg-config", "--exists", pc_name],
|
||
timeout=10, label=f"pkg-config {pc_name}",
|
||
)
|
||
if rc != 0:
|
||
missing_arch.append(arch_pkg)
|
||
missing_debian.append(debian_pkg)
|
||
self.log_msg.emit(f" Missing: {arch_pkg} (pkg-config {pc_name})")
|
||
else:
|
||
self.log_msg.emit(f" OK: {pc_name}")
|
||
|
||
if not missing_arch:
|
||
self.log_msg.emit(" All ffmpeg build deps satisfied.")
|
||
return
|
||
|
||
# Try pacman (Arch) first since the rest of this app assumes Arch
|
||
if shutil.which("pacman"):
|
||
self.log_msg.emit(f" Installing via pacman: {', '.join(missing_arch)}")
|
||
rc, _ = self._sudo_cmd(
|
||
["pacman", "-S", "--needed", "--noconfirm"] + missing_arch,
|
||
timeout=300, label="pacman ffmpeg-deps",
|
||
)
|
||
if rc != 0:
|
||
self.log_msg.emit(" WARNING: pacman install failed — configure may fail.")
|
||
elif shutil.which("apt-get"):
|
||
self.log_msg.emit(f" Installing via apt: {', '.join(missing_debian)}")
|
||
rc, _ = self._sudo_cmd(
|
||
["apt-get", "install", "-y"] + missing_debian,
|
||
timeout=300, label="apt ffmpeg-deps",
|
||
)
|
||
if rc != 0:
|
||
self.log_msg.emit(" WARNING: apt install failed — configure may fail.")
|
||
else:
|
||
self.log_msg.emit(
|
||
f" No supported package manager found. Install manually: "
|
||
f"{', '.join(missing_arch)} (Arch) or {', '.join(missing_debian)} (Debian)."
|
||
)
|
||
|
||
def _build_libiamf(self):
|
||
"""Clone, build, and install libiamf to ~/.local/ (no sudo needed).
|
||
|
||
libiamf is the AOMedia Immersive Audio Model and Formats reference
|
||
library. ffmpeg links against it via --enable-libiamf.
|
||
"""
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Building libiamf from git ===")
|
||
self.log_msg.emit(" Source: https://github.com/AOMediaCodec/libiamf")
|
||
self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)")
|
||
|
||
build_dir = Path("/tmp/libiamf-git-build")
|
||
local_prefix = Path.home() / ".local"
|
||
|
||
if build_dir.exists():
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
# Clone (shallow)
|
||
self.log_msg.emit(" Cloning libiamf source (shallow)...")
|
||
rc, out = self._run_cmd(
|
||
["git", "clone", "--depth", "1",
|
||
"https://github.com/AOMediaCodec/libiamf.git",
|
||
str(build_dir)],
|
||
timeout=120, label="git clone libiamf",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"git clone libiamf failed: {out[-300:]}")
|
||
|
||
# CMake configure
|
||
cmake_build = build_dir / "build"
|
||
cmake_build.mkdir(exist_ok=True)
|
||
self.log_msg.emit(f" Configuring with cmake (--prefix={local_prefix})...")
|
||
rc, out = self._run_cmd(
|
||
["cmake", "-S", str(build_dir), "-B", str(cmake_build),
|
||
f"-DCMAKE_INSTALL_PREFIX={local_prefix}",
|
||
"-DCMAKE_BUILD_TYPE=Release",
|
||
"-DBUILD_SHARED_LIBS=ON"],
|
||
timeout=120, label="cmake configure libiamf",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"cmake configure libiamf failed:\n{out[-500:]}")
|
||
|
||
# Build
|
||
self.log_msg.emit(" Compiling libiamf...")
|
||
rc, out = self._run_cmd(
|
||
["cmake", "--build", str(cmake_build), "-j",
|
||
str(max(1, os.cpu_count() or 2))],
|
||
timeout=600, label="cmake build libiamf",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"cmake build libiamf failed:\n{out[-500:]}")
|
||
|
||
# Install
|
||
self.log_msg.emit(f" Installing libiamf to {local_prefix}/ ...")
|
||
rc, out = self._run_cmd(
|
||
["cmake", "--install", str(cmake_build)],
|
||
timeout=120, label="cmake install libiamf",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"cmake install libiamf failed:\n{out[-500:]}")
|
||
|
||
# Make libiamf discoverable: PKG_CONFIG_PATH and LD_LIBRARY_PATH
|
||
pc_dir = local_prefix / "lib" / "pkgconfig"
|
||
if pc_dir.exists():
|
||
existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
|
||
if str(pc_dir) not in existing_pkgs:
|
||
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
|
||
self.log_msg.emit(f" Added {pc_dir} to PKG_CONFIG_PATH")
|
||
|
||
lib_dir = local_prefix / "lib"
|
||
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
|
||
if str(lib_dir) not in existing_ld:
|
||
self._extend_env("LD_LIBRARY_PATH", str(lib_dir), prepend=True)
|
||
|
||
self.log_msg.emit(f" libiamf installed to {local_prefix}/")
|
||
|
||
# Cleanup
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
def _build_ffmpeg_with_iamf(self):
|
||
"""Rebuild ffmpeg from source with libiamf (and IAMF's Opus dep).
|
||
|
||
Strategy: detect the current ffmpeg's --enable-* configure flags,
|
||
reuse them, and append --enable-libiamf. This preserves all
|
||
existing functionality (libsvtav1, libvpx, libx265, etc.) while
|
||
adding IAMF support.
|
||
|
||
Installs to ~/.local/bin/ffmpeg so it shadows the system ffmpeg
|
||
without overwriting it. The user must restart the app for the
|
||
new ffmpeg to take effect (probe_environment re-runs on launch).
|
||
"""
|
||
self.log_msg.emit("")
|
||
self.log_msg.emit("=== Building ffmpeg from git with IAMF ===")
|
||
self.log_msg.emit(" Install target: ~/.local/bin/ (shadows system ffmpeg)")
|
||
|
||
# 1. Detect current ffmpeg configure flags
|
||
ffmpeg_bin = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
|
||
self.log_msg.emit(f" Probing current ffmpeg config: {ffmpeg_bin}")
|
||
rc, out = self._run_cmd(
|
||
[ffmpeg_bin, "-buildconf"],
|
||
timeout=30, label="ffmpeg -buildconf",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"ffmpeg -buildconf failed:\n{out[-300:]}")
|
||
|
||
# Parse --enable-* flags from output (one per line, sometimes with leading whitespace)
|
||
enables = re.findall(r"--enable-[a-z0-9_-]+", out)
|
||
# Dedupe while preserving order
|
||
seen = set()
|
||
enable_flags = []
|
||
for e in enables:
|
||
if e not in seen:
|
||
seen.add(e)
|
||
enable_flags.append(e)
|
||
|
||
# Make sure libiamf and libopus are in the list (core requirements)
|
||
if "--enable-libiamf" not in enable_flags:
|
||
enable_flags.append("--enable-libiamf")
|
||
if "--enable-libopus" not in enable_flags:
|
||
enable_flags.append("--enable-libopus")
|
||
|
||
self.log_msg.emit(f" Configure flags ({len(enable_flags)}):")
|
||
for f in enable_flags:
|
||
self.log_msg.emit(f" {f}")
|
||
|
||
# 2. Clone ffmpeg source
|
||
build_dir = Path("/tmp/ffmpeg-git-build")
|
||
if build_dir.exists():
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
self.log_msg.emit(" Cloning ffmpeg source (shallow)...")
|
||
rc, out = self._run_cmd(
|
||
["git", "clone", "--depth", "1",
|
||
"https://git.ffmpeg.org/ffmpeg.git",
|
||
str(build_dir)],
|
||
timeout=300, label="git clone ffmpeg",
|
||
)
|
||
if rc != 0:
|
||
# Fall back to GitHub mirror
|
||
self.log_msg.emit(" Primary mirror failed, trying github mirror...")
|
||
rc, out = self._run_cmd(
|
||
["git", "clone", "--depth", "1",
|
||
"https://github.com/FFmpeg/FFmpeg.git",
|
||
str(build_dir)],
|
||
timeout=300, label="git clone ffmpeg (github)",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"git clone ffmpeg failed:\n{out[-300:]}")
|
||
|
||
local_prefix = Path.home() / ".local"
|
||
|
||
# Make sure pkg-config finds the freshly-built libiamf
|
||
pc_dir = local_prefix / "lib" / "pkgconfig"
|
||
existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
|
||
if str(pc_dir) not in existing_pkgs:
|
||
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
|
||
|
||
# 3. Configure
|
||
self.log_msg.emit(" Running ./configure (this may take a minute)...")
|
||
configure_cmd = [
|
||
"./configure",
|
||
f"--prefix={local_prefix}",
|
||
"--enable-shared",
|
||
"--enable-pic",
|
||
"--enable-version3",
|
||
] + enable_flags
|
||
|
||
rc, out = self._run_cmd(
|
||
configure_cmd,
|
||
cwd=str(build_dir), timeout=300, label="ffmpeg configure",
|
||
)
|
||
if rc != 0:
|
||
# Show the actual error — usually a missing -dev package
|
||
raise Exception(
|
||
"ffmpeg configure failed. This usually means a dev library\n"
|
||
"is missing. Install the corresponding -dev package and retry.\n"
|
||
f"Output:\n{out[-800:]}"
|
||
)
|
||
|
||
# 4. Build
|
||
self.log_msg.emit(" Compiling ffmpeg (this may take 10-20 minutes)...")
|
||
rc, out = self._run_cmd(
|
||
["make", "-j", str(max(1, os.cpu_count() or 2))],
|
||
cwd=str(build_dir), timeout=2400, label="make ffmpeg",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"ffmpeg make failed:\n{out[-500:]}")
|
||
|
||
# 5. Install to ~/.local
|
||
self.log_msg.emit(f" Installing ffmpeg to {local_prefix}/ ...")
|
||
rc, out = self._run_cmd(
|
||
["make", "install"],
|
||
cwd=str(build_dir), timeout=300, label="make install ffmpeg",
|
||
)
|
||
if rc != 0:
|
||
raise Exception(f"make install ffmpeg failed:\n{out[-500:]}")
|
||
|
||
# 6. Ensure ~/.local/bin is in PATH so new ffmpeg shadows system one
|
||
local_bin = local_prefix / "bin"
|
||
existing_path = self._build_env.get("PATH", "")
|
||
if str(local_bin) not in existing_path:
|
||
self._extend_env("PATH", str(local_bin), prepend=True)
|
||
self.log_msg.emit(f" Prepended {local_bin} to PATH (shadows system ffmpeg)")
|
||
|
||
new_ffmpeg = local_bin / "ffmpeg"
|
||
if new_ffmpeg.exists():
|
||
self.log_msg.emit(f" ffmpeg installed: {new_ffmpeg}")
|
||
self.log_msg.emit(
|
||
" IMPORTANT: Restart the app for the new ffmpeg (with libiamf)\n"
|
||
" to be detected and used. The IAMF audio entry will then\n"
|
||
" be selectable (not greyed out)."
|
||
)
|
||
else:
|
||
self.log_msg.emit(" WARNING: ffmpeg binary not found at expected path after build.")
|
||
|
||
# Cleanup build dir (keep source for re-runs? No — disk is cheap, time isn't, but
|
||
# a clean clone is more reliable than a stale tree.)
|
||
shutil.rmtree(build_dir, ignore_errors=True)
|
||
|
||
def stop(self):
|
||
self._stop = True
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# MAIN WINDOW (merged UI from all 3)
|
||
# ──────────────────────────────────────────────
|
||
|
||
# ──────────────────────────────────────────────
|
||
# RETRO-FUTURISTIC MEDIA CONSOLE THEME
|
||
# ──────────────────────────────────────────────
|
||
# Brushed aluminum, amber/green LED displays,
|
||
# beveled metallic panels, VU meters, spectrum bars.
|
||
# Modernized with: rounded corners, subtle glow, glassmorphism hints,
|
||
# information-dense DAW-style layout.
|
||
|
||
MMD3_QSS = """
|
||
/* ── Global ── */
|
||
QMainWindow, QWidget#central {
|
||
background-color: #1a1a1e;
|
||
}
|
||
|
||
/* ── Group Boxes — brushed aluminum panels ── */
|
||
QGroupBox {
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
color: #8a8a8a;
|
||
border: 1px solid #3a3a40;
|
||
border-radius: 8px;
|
||
margin-top: 14px;
|
||
padding: 14px 10px 10px 10px;
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #2c2c32, stop:0.5 #27272c, stop:1 #222228);
|
||
}
|
||
QGroupBox::title {
|
||
subcontrol-origin: margin;
|
||
subcontrol-position: top left;
|
||
padding: 2px 10px;
|
||
color: #666;
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #2c2c32, stop:1 #222228);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* ── Labels ── */
|
||
QLabel {
|
||
color: #999;
|
||
font-size: 10px;
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
}
|
||
|
||
/* ── Line Edits — recessed aluminum wells ── */
|
||
QLineEdit {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #18181c, stop:1 #141418);
|
||
border: 1px solid #333;
|
||
border-radius: 4px;
|
||
padding: 5px 8px;
|
||
color: #d4aa50; /* amber LED */
|
||
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
|
||
font-size: 11px;
|
||
selection-background-color: #d4aa50;
|
||
selection-color: #000;
|
||
}
|
||
QLineEdit:focus {
|
||
border-color: #d4aa50;
|
||
}
|
||
|
||
/* ── Combo Boxes ── */
|
||
QComboBox {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1e1e24, stop:1 #1a1a20);
|
||
border: 1px solid #3a3a40;
|
||
border-radius: 4px;
|
||
padding: 4px 8px;
|
||
color: #c8c8c8;
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
font-size: 11px;
|
||
min-height: 24px;
|
||
}
|
||
QComboBox:hover {
|
||
border-color: #555;
|
||
}
|
||
QComboBox:focus {
|
||
border-color: #d4aa50;
|
||
}
|
||
QComboBox::drop-down {
|
||
border: none;
|
||
width: 22px;
|
||
}
|
||
QComboBox::down-arrow {
|
||
image: none;
|
||
border-left: 4px solid transparent;
|
||
border-right: 4px solid transparent;
|
||
border-top: 6px solid #888;
|
||
margin-right: 6px;
|
||
}
|
||
QComboBox QAbstractItemView {
|
||
background: #1e1e24;
|
||
border: 1px solid #3a3a40;
|
||
border-radius: 4px;
|
||
color: #c8c8c8;
|
||
selection-background-color: #3a3a48;
|
||
selection-color: #d4aa50;
|
||
padding: 4px;
|
||
}
|
||
QComboBox item {
|
||
min-height: 22px;
|
||
padding: 2px 8px;
|
||
}
|
||
|
||
/* ── Buttons — beveled metallic (MMD3 transport style) ── */
|
||
QPushButton {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #404048, stop:0.15 #38383f,
|
||
stop:0.85 #2e2e35, stop:1 #28282e);
|
||
border: 1px solid #4a4a52;
|
||
border-bottom-color: #1a1a1e;
|
||
border-radius: 5px;
|
||
padding: 6px 16px;
|
||
color: #d0d0d0;
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
QPushButton:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #4a4a54, stop:0.15 #424248,
|
||
stop:0.85 #363640, stop:1 #303038);
|
||
border-color: #5a5a64;
|
||
color: #fff;
|
||
}
|
||
QPushButton:pressed {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #28282e, stop:1 #3a3a42);
|
||
border-bottom-color: #4a4a52;
|
||
border-top-color: #1a1a1e;
|
||
}
|
||
QPushButton:disabled {
|
||
background: #222228;
|
||
border-color: #2a2a30;
|
||
color: #555;
|
||
}
|
||
|
||
/* Primary action button — amber glow */
|
||
QPushButton#btnRun {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #3a3428, stop:0.15 #332e22,
|
||
stop:0.85 #2a261c, stop:1 #221e16);
|
||
border: 1px solid #5a4a30;
|
||
border-bottom-color: #1a1608;
|
||
color: #d4aa50;
|
||
font-size: 13px;
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton#btnRun:hover {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #4a4030, stop:0.15 #423828,
|
||
stop:0.85 #3a3020, stop:1 #322a1a);
|
||
border-color: #d4aa50;
|
||
color: #f0d080;
|
||
}
|
||
QPushButton#btnRun:disabled {
|
||
background: #22201a;
|
||
border-color: #2a2820;
|
||
color: #5a4a30;
|
||
}
|
||
|
||
/* Stop button — red danger */
|
||
QPushButton#btnStop {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #3a2222, stop:0.15 #321c1c,
|
||
stop:0.85 #2a1616, stop:1 #221010);
|
||
border: 1px solid #5a3030;
|
||
border-bottom-color: #1a0808;
|
||
color: #e05050;
|
||
font-size: 13px;
|
||
letter-spacing: 2px;
|
||
}
|
||
QPushButton#btnStop:hover {
|
||
border-color: #e05050;
|
||
color: #ff7070;
|
||
}
|
||
QPushButton#btnStop:disabled {
|
||
background: #221a1a;
|
||
border-color: #2a2020;
|
||
color: #5a3030;
|
||
}
|
||
|
||
/* Rebuild-from-git button — muted teal */
|
||
QPushButton#btnRebuild {
|
||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||
stop:0 #1e2e2e, stop:0.15 #1a2a2a,
|
||
stop:0.85 #162424, stop:1 #121e1e);
|
||
border: 1px solid #2a5050;
|
||
border-bottom-color: #0e1818;
|
||
color: #50b0b0;
|
||
font-size: 10px;
|
||
letter-spacing: 1px;
|
||
}
|
||
QPushButton#btnRebuild:hover {
|
||
border-color: #50b0b0;
|
||
color: #70d0d0;
|
||
}
|
||
QPushButton#btnRebuild:disabled {
|
||
background: #1a1e1e;
|
||
border-color: #222828;
|
||
color: #304040;
|
||
}
|
||
|
||
/* Browse buttons — small, subdued */
|
||
QPushButton#btnBrowse {
|
||
font-size: 9px;
|
||
padding: 4px 10px;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* ── Check Boxes ── */
|
||
QCheckBox {
|
||
color: #999;
|
||
font-size: 10px;
|
||
spacing: 8px;
|
||
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
|
||
}
|
||
QCheckBox::indicator {
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 3px;
|
||
border: 1px solid #444;
|
||
background: #1a1a1e;
|
||
}
|
||
QCheckBox::indicator:checked {
|
||
background: #d4aa50;
|
||
border-color: #b8903a;
|
||
}
|
||
QCheckBox#dangerCheck {
|
||
color: #c05050;
|
||
font-weight: bold;
|
||
}
|
||
QCheckBox#dangerCheck::indicator:checked {
|
||
background: #c04040;
|
||
border-color: #a03030;
|
||
}
|
||
|
||
/* ── Text Edit (log) — LED terminal display ── */
|
||
QTextEdit#logBox {
|
||
background: #0a0a0c;
|
||
border: 2px solid #1e1e24;
|
||
border-radius: 6px;
|
||
color: #40d060; /* green phosphor LED */
|
||
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
|
||
font-size: 11px;
|
||
padding: 8px;
|
||
}
|
||
|
||
/* ── Status Bar — LED readout strip ── */
|
||
QStatusBar {
|
||
background: #0e0e12;
|
||
border-top: 1px solid #2a2a30;
|
||
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
|
||
font-size: 10px;
|
||
color: #d4aa50;
|
||
padding: 2px 8px;
|
||
}
|
||
QStatusBar QLabel {
|
||
color: #d4aa50;
|
||
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
|
||
font-size: 10px;
|
||
}
|
||
|
||
/* ── Tooltips ── */
|
||
QToolTip {
|
||
background: #2a2a30;
|
||
color: #c8c8c8;
|
||
border: 1px solid #444;
|
||
border-radius: 4px;
|
||
padding: 6px;
|
||
font-size: 10px;
|
||
}
|
||
|
||
/* ── Scrollbars — thin, dark ── */
|
||
QScrollBar:vertical {
|
||
background: #141418;
|
||
width: 10px;
|
||
border-radius: 5px;
|
||
margin: 0;
|
||
}
|
||
QScrollBar::handle:vertical {
|
||
background: #3a3a42;
|
||
border-radius: 5px;
|
||
min-height: 30px;
|
||
}
|
||
QScrollBar::handle:vertical:hover {
|
||
background: #4a4a54;
|
||
}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||
height: 0;
|
||
}
|
||
QScrollBar:horizontal {
|
||
background: #141418;
|
||
height: 10px;
|
||
border-radius: 5px;
|
||
}
|
||
QScrollBar::handle:horizontal {
|
||
background: #3a3a42;
|
||
border-radius: 5px;
|
||
min-width: 30px;
|
||
}
|
||
QScrollBar::handle:horizontal:hover {
|
||
background: #4a4a54;
|
||
}
|
||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||
width: 0;
|
||
}
|
||
"""
|
||
|
||
|
||
class OpenCodecMaster(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("OpenTranscode — dcos.net")
|
||
self.resize(1100, 920)
|
||
self.worker: EncoderWorker | None = None
|
||
self.env: EnvProbe | None = None
|
||
self._pending_deletes: list[Path] = []
|
||
|
||
self._apply_mmd3_theme()
|
||
self._build_ui()
|
||
|
||
# Probe environment after UI is up
|
||
QTimer.singleShot(500, self._probe_and_init)
|
||
|
||
# ── UI Construction ──
|
||
|
||
def _build_ui(self):
|
||
central = QWidget()
|
||
central.setObjectName("central")
|
||
self.setCentralWidget(central)
|
||
root = QVBoxLayout(central)
|
||
root.setContentsMargins(10, 6, 10, 4)
|
||
root.setSpacing(4)
|
||
|
||
# ── Header ──
|
||
header = QWidget()
|
||
header_lay = QVBoxLayout(header)
|
||
header_lay.setContentsMargins(0, 0, 0, 0)
|
||
header_lay.setSpacing(0)
|
||
|
||
title = QLabel("OpenTranscode")
|
||
title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold))
|
||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;")
|
||
header_lay.addWidget(title)
|
||
|
||
subtitle = QLabel('dcos.net // concurrent open-source transcoding')
|
||
subtitle.setFont(QFont("Consolas", 8))
|
||
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
subtitle.setStyleSheet("color: #555; letter-spacing: 2px;")
|
||
header_lay.addWidget(subtitle)
|
||
|
||
accent = QWidget()
|
||
accent.setFixedHeight(1)
|
||
accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
|
||
"stop:0 transparent, stop:0.15 #d4aa5044,"
|
||
"stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);")
|
||
header_lay.addWidget(accent)
|
||
|
||
root.addWidget(header)
|
||
|
||
# ── Paths ──
|
||
path_grp = QGroupBox("Paths")
|
||
path_lay = QVBoxLayout(path_grp)
|
||
path_lay.setSpacing(2)
|
||
path_lay.setContentsMargins(10, 14, 10, 8)
|
||
|
||
self.in_path_edit = QLineEdit(str(Path.home() / "Videos" / "INCOMING"))
|
||
self.out_path_edit = QLineEdit(str(Path.home() / "Videos" / "ARCHIVE"))
|
||
for label_text, line_edit in [
|
||
("IN:", self.in_path_edit),
|
||
("OUT:", self.out_path_edit),
|
||
]:
|
||
row = QHBoxLayout()
|
||
row.setSpacing(6)
|
||
lbl = QLabel(label_text)
|
||
lbl.setFixedWidth(28)
|
||
lbl.setStyleSheet("color: #d4aa50; font-family: 'Consolas', monospace; font-weight: bold; font-size: 10px;")
|
||
row.addWidget(lbl)
|
||
row.addWidget(line_edit, 1)
|
||
btn_browse = QPushButton("...")
|
||
btn_browse.setObjectName("btnBrowse")
|
||
btn_browse.setFixedSize(30, 22)
|
||
btn_browse.setToolTip("Browse")
|
||
btn_browse.clicked.connect(
|
||
lambda checked, le=line_edit, is_dir=True: self._browse(le, is_dir)
|
||
)
|
||
row.addWidget(btn_browse)
|
||
path_lay.addLayout(row)
|
||
|
||
root.addWidget(path_grp)
|
||
|
||
# ── Encoder Chain ──
|
||
codec_grp = QGroupBox("Encoder Chain")
|
||
codec_lay = QHBoxLayout(codec_grp)
|
||
codec_lay.setSpacing(8)
|
||
codec_lay.setContentsMargins(10, 14, 10, 8)
|
||
|
||
for col_idx, (label, combo_items, slot) in enumerate([
|
||
("VIDEO", [vc.label for vc in VIDEO_CODECS], self._on_codec_changed),
|
||
("PRESET", [], None),
|
||
("AUDIO", [ap.label for ap in AUDIO_PROFILES], self._on_audio_changed),
|
||
("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed),
|
||
("RESOLUTION", [], self._on_resolution_changed),
|
||
("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None),
|
||
]):
|
||
col = QVBoxLayout()
|
||
col.setSpacing(1)
|
||
lbl = QLabel(label)
|
||
lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
|
||
col.addWidget(lbl)
|
||
|
||
combo = QComboBox()
|
||
combo.setFixedHeight(24)
|
||
if combo_items:
|
||
combo.addItems(combo_items)
|
||
if slot:
|
||
combo.currentIndexChanged.connect(slot)
|
||
col.addWidget(combo)
|
||
codec_lay.addLayout(col)
|
||
|
||
if label == "VIDEO":
|
||
self.codec_combo = combo
|
||
elif label == "PRESET":
|
||
self.preset_combo = combo
|
||
self._populate_presets(0)
|
||
self.preset_combo.setCurrentIndex(1)
|
||
elif label == "AUDIO":
|
||
self.audio_combo = combo
|
||
elif label == "CONTAINER":
|
||
self.container_combo = combo
|
||
elif label == "RESOLUTION":
|
||
self.resolution_combo = combo
|
||
self._populate_resolution_combo()
|
||
elif label == "SUBS":
|
||
self.subs_combo = combo
|
||
|
||
root.addWidget(codec_grp)
|
||
|
||
# ── Side panel: compact knobs ──
|
||
knobs_panel = QWidget()
|
||
knobs_panel.setFixedWidth(170)
|
||
knobs_lay = QVBoxLayout(knobs_panel)
|
||
knobs_lay.setContentsMargins(6, 8, 6, 8)
|
||
knobs_lay.setSpacing(6)
|
||
knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
# CRF Knob — amber
|
||
self.crf_knob = RadioKnob(
|
||
min_val=18, max_val=52, default_val=32,
|
||
label="Quality",
|
||
unit="CRF",
|
||
color=(212, 170, 80),
|
||
num_ticks=18,
|
||
tick_labels=["18", "28", "38", "52"],
|
||
snap_ticks=True,
|
||
compact=True,
|
||
)
|
||
self.crf_knob.valueChanged.connect(self._on_crf_knob_changed)
|
||
knobs_lay.addWidget(self.crf_knob, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
# Volume Knob — green (dual-pass loudnorm target)
|
||
self.vol_knob = RadioKnob(
|
||
min_val=-20.0, max_val=6.0, default_val=0.0,
|
||
label="LUFS",
|
||
unit="dB",
|
||
color=(64, 208, 96),
|
||
num_ticks=27,
|
||
tick_labels=["-20", "-10", "0", "+6"],
|
||
snap_ticks=True,
|
||
compact=True,
|
||
)
|
||
self.vol_knob.setToolTip(
|
||
"Dual-pass audio normalization target (EBU R128 LUFS).\n"
|
||
"0 = off (pass-through).\n"
|
||
"Each file is analyzed individually: loudnorm measures its\n"
|
||
"current LUFS and true peak, then computes the exact gain\n"
|
||
"to hit this target. If the gain would push peaks above\n"
|
||
"-1.5 dBTP, gain is reduced to keep 15%% headroom.\n"
|
||
"Common targets: -14 (streaming), -16 (broadcast), -23 (cinema)."
|
||
)
|
||
self.vol_knob.valueChanged.connect(self._on_vol_knob_changed)
|
||
knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter)
|
||
|
||
# ── Options row ──
|
||
opt_row = QHBoxLayout()
|
||
opt_row.setSpacing(8)
|
||
opt_lbl = QLabel("FILTER")
|
||
opt_lbl.setFixedWidth(44)
|
||
opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
|
||
opt_row.addWidget(opt_lbl)
|
||
self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS)))
|
||
self.ext_edit.setFixedHeight(22)
|
||
self.ext_edit.setToolTip("File extensions to process. Separate with commas.")
|
||
opt_row.addWidget(self.ext_edit)
|
||
|
||
self.del_check = QCheckBox("Delete source after verify")
|
||
self.del_check.setObjectName("dangerCheck")
|
||
self.del_check.setToolTip(
|
||
"Sources are only deleted after all files finish and cleanup passes.\n"
|
||
"If any file fails, you will be prompted before deletion."
|
||
)
|
||
opt_row.addWidget(self.del_check)
|
||
|
||
# v5-01: Force checkbox — skip ffprobe validation and attempt encode
|
||
# even for files ffprobe cannot read. Use for the rare edge case where
|
||
# ffprobe fails but the file is actually valid. Default OFF — most
|
||
# "ffprobe can't read" files are genuinely invalid (failed downloads,
|
||
# HTML saved as .mp4, truncated files, etc.).
|
||
self.force_check = QCheckBox("Force (skip validation)")
|
||
self.force_check.setToolTip(
|
||
"Skip ffprobe pre-validation and attempt encode even for files\n"
|
||
"ffprobe cannot read. Useful for the rare case where ffprobe\n"
|
||
"fails but the file is actually valid (rare codec, broken\n"
|
||
"container metadata). WARNING: with this enabled, invalid files\n"
|
||
"(failed downloads, HTML, truncated) will waste the full\n"
|
||
"per-file timeout before failing."
|
||
)
|
||
opt_row.addWidget(self.force_check)
|
||
|
||
# v4.4.3: av1an toggle — UI equivalent of --use-av1an.
|
||
self.av1an_check = QCheckBox("av1an (chunk-parallel)")
|
||
self.av1an_check.setToolTip(
|
||
"Use av1an chunk-parallel encoding instead of single-pass ffmpeg.\n"
|
||
"Faster on multi-core machines WITH working VapourSynth setup,\n"
|
||
"but more fragile (y4m pipe breaks, concat failures on phone\n"
|
||
"videos with sparse keyframes). Default OFF = ffmpeg-only,\n"
|
||
"which is more reliable across distros."
|
||
)
|
||
opt_row.addWidget(self.av1an_check)
|
||
root.addLayout(opt_row)
|
||
|
||
# ── Log + Knobs: horizontal split ──
|
||
mid_split = QHBoxLayout()
|
||
mid_split.setSpacing(6)
|
||
|
||
# Log: LED terminal (takes remaining space)
|
||
self.log_box = QTextEdit()
|
||
self.log_box.setObjectName("logBox")
|
||
self.log_box.setReadOnly(True)
|
||
mid_split.addWidget(self.log_box, 1)
|
||
|
||
# Knobs panel on the right
|
||
mid_split.addWidget(knobs_panel)
|
||
|
||
root.addLayout(mid_split, 1)
|
||
|
||
# ── Status Bar: LED readout ──
|
||
self.status = QStatusBar()
|
||
self.setStatusBar(self.status)
|
||
self.status_label = QLabel(" INITIALIZING...")
|
||
self.status_label.setStyleSheet(
|
||
"color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; font-size: 10px;"
|
||
)
|
||
self.status.addWidget(self.status_label, 1)
|
||
|
||
# ── Transport Buttons ──
|
||
btn_lay = QHBoxLayout()
|
||
btn_lay.setSpacing(8)
|
||
|
||
self.btn_run = QPushButton(" > ENCODE")
|
||
self.btn_run.setObjectName("btnRun")
|
||
self.btn_run.setFixedHeight(40)
|
||
self.btn_run.setEnabled(False)
|
||
self.btn_run.clicked.connect(self._start_process)
|
||
btn_lay.addWidget(self.btn_run)
|
||
|
||
self.btn_stop = QPushButton(" [] STOP")
|
||
self.btn_stop.setObjectName("btnStop")
|
||
self.btn_stop.setFixedHeight(40)
|
||
self.btn_stop.clicked.connect(self._stop_process)
|
||
self.btn_stop.setEnabled(False)
|
||
btn_lay.addWidget(self.btn_stop)
|
||
|
||
self.btn_rebuild = QPushButton(" <> REBUILD FROM GIT")
|
||
self.btn_rebuild.setObjectName("btnRebuild")
|
||
self.btn_rebuild.setFixedHeight(40)
|
||
self.btn_rebuild.setToolTip(
|
||
"Compile VapourSynth + av1an from git source.\n"
|
||
"Resolves ABI/version mismatch when package managers\n"
|
||
"install incompatible versions."
|
||
)
|
||
self.btn_rebuild.clicked.connect(self._manual_rebuild)
|
||
self.btn_rebuild.setEnabled(False)
|
||
btn_lay.addWidget(self.btn_rebuild)
|
||
|
||
self.btn_about = QPushButton(" ? ABOUT / LICENSES")
|
||
self.btn_about.setObjectName("btnAbout")
|
||
self.btn_about.setFixedHeight(40)
|
||
self.btn_about.setToolTip(
|
||
"Show open-source license attributions for all\n"
|
||
"third-party components invoked by this application."
|
||
)
|
||
self.btn_about.clicked.connect(self._show_license_dialog)
|
||
btn_lay.addWidget(self.btn_about)
|
||
root.addLayout(btn_lay)
|
||
|
||
# ── Footer ──
|
||
footer = QWidget()
|
||
footer_lay = QHBoxLayout(footer)
|
||
footer_lay.setContentsMargins(6, 4, 6, 2)
|
||
footer_lay.setSpacing(0)
|
||
|
||
link_lbl = QLabel(
|
||
'<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 via index lookup — no for-loop, no break.
|
||
# next(..., None) returns the first match or None; the if guards the
|
||
# block so we only touch container_combo when a match was found.
|
||
match = next(
|
||
(i for i, cp in enumerate(CONTAINER_PROFILES)
|
||
if cp.ext == profile.container),
|
||
None,
|
||
)
|
||
if match is not None:
|
||
self.container_combo.blockSignals(True)
|
||
self.container_combo.setCurrentIndex(match)
|
||
self.container_combo.blockSignals(False)
|
||
# Re-evaluate compatibility after auto-container change.
|
||
self._check_combo_compatibility()
|
||
|
||
def _populate_presets(self, codec_idx: int):
|
||
self.preset_combo.blockSignals(True)
|
||
self.preset_combo.clear()
|
||
if 0 <= codec_idx < len(VIDEO_CODECS):
|
||
self.preset_combo.addItems(VIDEO_CODECS[codec_idx].presets)
|
||
self.preset_combo.blockSignals(False)
|
||
|
||
@Slot()
|
||
def _on_container_changed(self, idx: int):
|
||
if idx >= 0:
|
||
ext = CONTAINER_PROFILES[idx].ext
|
||
self._log(f"Container set to: {ext}")
|
||
self._check_combo_compatibility()
|
||
|
||
@Slot()
|
||
def _on_audio_changed(self, idx: int):
|
||
if idx >= 0:
|
||
self._log(f"Audio set to: {AUDIO_PROFILES[idx].label}")
|
||
self._check_combo_compatibility()
|
||
|
||
def _check_combo_compatibility(self) -> list[str]:
|
||
"""Check current video/audio/container combination for known
|
||
incompatibilities. Logs every warning and returns the full list
|
||
(empty if clean). Hard incompatibilities (which would fail at
|
||
encode/mux time) are prefixed ``INCOMPATIBLE:`` and also block
|
||
the Start button via _start_process. Soft warnings are prefixed
|
||
``WARNING:`` and only appear in the log.
|
||
|
||
Safe to call during __init__ — every attribute is guarded.
|
||
|
||
Refactored to table-driven dispatch: every rule is a tuple of
|
||
(predicate, severity, message-fn), evaluated by a single loop.
|
||
Adding a new rule is a one-line table change; no nested ifs.
|
||
|
||
SEI CERT STR09-C spirit: predicates return plain bool, never None;
|
||
messages are produced only when their predicate fires, so the
|
||
severity prefix is always consistent with the predicate outcome.
|
||
"""
|
||
# Resolve current selection with full defensive validation.
|
||
# All four early returns return the same value ([]), so this
|
||
# block reads as a flat guard rather than a nested decision tree.
|
||
if not all(hasattr(self, attr) for attr in
|
||
("codec_combo", "audio_combo", "container_combo")):
|
||
return []
|
||
|
||
codec_idx = self.codec_combo.currentIndex()
|
||
audio_idx = self.audio_combo.currentIndex()
|
||
container_idx = self.container_combo.currentIndex()
|
||
|
||
if min(codec_idx, audio_idx, container_idx) < 0:
|
||
return []
|
||
|
||
if not (codec_idx < len(VIDEO_CODECS)
|
||
and audio_idx < len(AUDIO_PROFILES)
|
||
and container_idx < len(CONTAINER_PROFILES)):
|
||
return []
|
||
|
||
video_codec = VIDEO_CODECS[codec_idx]
|
||
audio_profile = AUDIO_PROFILES[audio_idx]
|
||
container = CONTAINER_PROFILES[container_idx]
|
||
|
||
# ── Compatibility rule table ──
|
||
# Each rule: (predicate, severity, message)
|
||
# predicate: callable(video_codec, audio_profile, container) -> bool
|
||
# severity: "INCOMPATIBLE" or "WARNING"
|
||
# message: str (already-formatted)
|
||
#
|
||
# To add a new rule, append a tuple here. No code below changes.
|
||
def _is_hevc(vc, _ap, c) -> bool:
|
||
return vc.ffmpeg_encoder == "libx265" and c.ext == "webm"
|
||
|
||
# v3 (OTC-012, SEI CERT STR09-C): compare against the
|
||
# AudioProfile.ffmpeg_encoder_name field directly, not via
|
||
# substring match on params (which could false-match a
|
||
# hypothetical `-libiamf-mode` argument).
|
||
def _is_iamf_non_mp4(_vc, ap, c) -> bool:
|
||
return ap.ffmpeg_encoder_name == "libiamf" and c.ext != "mp4"
|
||
|
||
def _is_vorbis_in_mp4(_vc, ap, c) -> bool:
|
||
return ap.ffmpeg_encoder_name == "libvorbis" and c.ext == "mp4"
|
||
|
||
def _is_flac_in_webm(_vc, ap, c) -> bool:
|
||
return ap.ffmpeg_encoder_name == "flac" and c.ext == "webm"
|
||
|
||
def _is_vp9_in_mp4(vc, _ap, c) -> bool:
|
||
return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4"
|
||
|
||
rules: tuple[tuple, ...] = (
|
||
(_is_hevc, "INCOMPATIBLE",
|
||
"x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."),
|
||
(_is_iamf_non_mp4, "INCOMPATIBLE",
|
||
f"IAMF audio requires the MP4 container — cannot mux into "
|
||
f"{container.ext.upper()}. Switch container to MP4."),
|
||
(_is_vorbis_in_mp4, "WARNING",
|
||
"Vorbis in MP4 has limited player support. Consider Opus or MKV/WebM."),
|
||
(_is_flac_in_webm, "WARNING",
|
||
"FLAC in WebM is rarely supported by players. Consider MKV instead."),
|
||
(_is_vp9_in_mp4, "WARNING",
|
||
"VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."),
|
||
)
|
||
|
||
# Single-pass evaluation: build the warnings list by filtering
|
||
# the rule table through each predicate. No nested if/elif.
|
||
warnings: list[str] = [
|
||
f"{severity}: {message}"
|
||
for predicate, severity, message in rules
|
||
if predicate(video_codec, audio_profile, container)
|
||
]
|
||
|
||
for w in warnings:
|
||
self._log(w)
|
||
|
||
return warnings
|
||
|
||
def _populate_resolution_combo(self):
|
||
"""Populate resolution dropdown with separator headers per category.
|
||
|
||
Refactored with PEP 634/868 structural pattern matching: the
|
||
category-transition decision is expressed as a single match
|
||
statement instead of nested ifs. The match value is a 2-tuple
|
||
of (current_category, previous_category); each case is a flat
|
||
pattern, no nesting.
|
||
"""
|
||
# Maps combo box position -> RESOLUTION_PRESETS index.
|
||
# Separators occupy combo positions too, so we must track them.
|
||
self._res_preset_indices: dict[int, int] = {}
|
||
last_cat: str | None = None
|
||
combo_pos = 0
|
||
|
||
for i, rp in enumerate(RESOLUTION_PRESETS):
|
||
# Single-level decision: insert separator only when transitioning
|
||
# to a new category AND we are not on the first category.
|
||
match (rp.category, last_cat):
|
||
case (cat, prev) if cat != prev and prev is not None:
|
||
self.resolution_combo.insertSeparator(combo_pos)
|
||
combo_pos += 1 # separator takes a slot
|
||
|
||
last_cat = rp.category
|
||
self.resolution_combo.addItem(rp.label)
|
||
self._res_preset_indices[combo_pos] = i
|
||
combo_pos += 1
|
||
|
||
def _get_current_resolution(self) -> ResolutionProfile:
|
||
"""Get the ResolutionProfile for the current combo selection, handling separators."""
|
||
combo_idx = self.resolution_combo.currentIndex()
|
||
preset_i = self._res_preset_indices.get(combo_idx)
|
||
if preset_i is not None:
|
||
return RESOLUTION_PRESETS[preset_i]
|
||
return RESOLUTION_PRESETS[0]
|
||
|
||
@Slot()
|
||
def _on_resolution_changed(self, idx: int):
|
||
rp = self._get_current_resolution()
|
||
if rp.width is not None:
|
||
self._log(
|
||
f"Resolution: {rp.width}x{rp.height} ({rp.aspect_label}) — "
|
||
f"files will be pre-scaled with ffmpeg before encoding."
|
||
)
|
||
else:
|
||
self._log("Resolution: Original (no scaling).")
|
||
|
||
@Slot(float)
|
||
def _on_crf_knob_changed(self, val: float):
|
||
direction = "higher quality" if val < 28 else ("balanced" if val < 38 else "smaller file")
|
||
self._log(f"CRF: {val:.0f} ({direction})")
|
||
|
||
@Slot(float)
|
||
def _on_vol_knob_changed(self, val: float):
|
||
if abs(val) < 0.01:
|
||
self._log("Audio normalization: OFF (pass-through)")
|
||
else:
|
||
direction = "louder" if val > 0 else "quieter"
|
||
self._log(f"Audio normalization: {val:+.1f} dB ({direction})")
|
||
|
||
|
||
@Slot()
|
||
def _browse(self, line_edit: QLineEdit, is_dir: bool = True):
|
||
if is_dir:
|
||
path = QFileDialog.getExistingDirectory(self, "Select Directory")
|
||
if path:
|
||
line_edit.setText(path)
|
||
|
||
def _log(self, msg: str):
|
||
# Guard against signals (combo currentIndexChanged, knob valueChanged,
|
||
# etc.) firing during __init__ before self.log_box has been
|
||
# constructed. Without this, the first addItem() on any combo
|
||
# triggers its slot, which calls _log(), which dereferences
|
||
# self.log_box while it is still None -> AttributeError -> crashes
|
||
# the app on launch. Also buffer messages so they aren't lost.
|
||
if not hasattr(self, "log_box") or self.log_box is None:
|
||
buffered = getattr(self, "_log_buffer", None)
|
||
if buffered is None:
|
||
buffered = self._log_buffer = []
|
||
buffered.append(msg)
|
||
return
|
||
# Flush any messages that arrived before log_box existed.
|
||
buffered = getattr(self, "_log_buffer", None)
|
||
if buffered:
|
||
for m in buffered:
|
||
self.log_box.append(f"> {m}")
|
||
self._log_buffer = []
|
||
self.log_box.append(f"> {msg}")
|
||
sb = self.log_box.verticalScrollBar()
|
||
sb.setValue(sb.maximum())
|
||
|
||
# ── Environment Probe ──
|
||
|
||
def _probe_and_init(self):
|
||
self.env = probe_environment()
|
||
|
||
# --- Distro banner ---
|
||
distro = self.env.distro
|
||
self._log(f"Distro: {distro.name} (family={distro.family}, v{distro.version_id})")
|
||
self._log(f"Package manager: {distro.pkg_manager}")
|
||
|
||
# --- Warnings (info-level, not errors) ---
|
||
for w in self.env.warnings:
|
||
self._log(f"INFO: {w}")
|
||
|
||
# --- Hard errors ---
|
||
if not self.env.av1an_path:
|
||
self._log("CRITICAL: 'av1an' not found in PATH or distro-specific paths.")
|
||
if self.env.install_hint:
|
||
self._log(f" TRY: {self.env.install_hint}")
|
||
self.status_label.setText(f"NOT READY — missing av1an ({distro.family})")
|
||
return
|
||
if not self.env.ffmpeg_path:
|
||
self._log("CRITICAL: 'ffmpeg' not found in PATH or distro-specific paths.")
|
||
if self.env.install_hint:
|
||
self._log(f" TRY: {self.env.install_hint}")
|
||
self.status_label.setText(f"NOT READY — missing ffmpeg ({distro.family})")
|
||
return
|
||
|
||
if self.env.errors:
|
||
for e in self.env.errors:
|
||
self._log(f"ERROR: {e}")
|
||
|
||
# If there are still errors after logging (e.g. missing runtime deps), block start
|
||
if self.env.errors:
|
||
dep_count = len(self.env.missing_dep_pkgs)
|
||
if dep_count:
|
||
self.status_label.setText(
|
||
f"NOT READY — {dep_count} runtime dep(s) missing. See log."
|
||
)
|
||
return
|
||
|
||
# --- Probe results ---
|
||
flag_info = ", ".join(f"{k}={v}" for k, v in self.env.av1an_flags.items() if k != "has_chunk_method" and k != "has_scenes")
|
||
self._log(f"av1an: {self.env.av1an_path} (v{self.env.av1an_version or '?'})")
|
||
if flag_info:
|
||
self._log(f" Flags: {flag_info}")
|
||
|
||
if self.env.ffmpeg_version:
|
||
self._log(f"ffmpeg: {self.env.ffmpeg_path} (v{self.env.ffmpeg_version})")
|
||
|
||
# --- FFmpeg encoder library summary (audio-relevant only for our purposes) ---
|
||
available_libs = [name for name, present in self.env.ffmpeg_libs.items() if present]
|
||
missing_audio = [name for name, present in self.env.ffmpeg_libs.items()
|
||
if not present and name in ("libopus", "libvorbis", "flac")]
|
||
if available_libs:
|
||
self._log(f" FFmpeg encoders available: {', '.join(available_libs)}")
|
||
if missing_audio:
|
||
self._log(f" FFmpeg audio encoders MISSING: {', '.join(missing_audio)}")
|
||
self._log(f" Some audio codec options may fail. Check distro package: {distro.ffmpeg_pkg}")
|
||
|
||
# --- Disable unavailable codec options in UI ---
|
||
self._disable_unavailable_codecs()
|
||
|
||
# v4.1.0: show the intelligent worker-count math in the env-probe
|
||
# banner so the user can verify the thread budget before clicking
|
||
# START. The same math runs again in EncoderWorker.run() to set
|
||
# the actual values used per-encode.
|
||
cpu = self.env.cpu
|
||
# Read --max-workers / --threads-per-worker overrides from
|
||
# env.av1an_flags (set by cli.main before launch_gui runs).
|
||
cli_max_workers = (
|
||
self.env.av1an_flags.get("max_workers")
|
||
if isinstance(self.env.av1an_flags.get("max_workers"), int)
|
||
else None
|
||
)
|
||
cli_tpw = (
|
||
self.env.av1an_flags.get("threads_per_worker")
|
||
if isinstance(self.env.av1an_flags.get("threads_per_worker"), int)
|
||
else None
|
||
)
|
||
wc, tpw = _compute_intelligent_worker_count_for(
|
||
self.env, max_workers=cli_max_workers,
|
||
threads_per_worker_override=cli_tpw,
|
||
)
|
||
active = wc * tpw
|
||
reserved = max(0, cpu.logical_threads - active)
|
||
self._log(
|
||
f"Chunk-parallel mode: {wc} workers × {tpw} threads = {active} active "
|
||
f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, "
|
||
f"{cpu.threads_per_core}T/core — {reserved} reserved for OS/UI)"
|
||
)
|
||
|
||
self.btn_run.setEnabled(True)
|
||
self.btn_run.setText("START PROCESSING")
|
||
self.btn_rebuild.setEnabled(True) # available after successful probe
|
||
vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
|
||
# Show ffmpeg video encoder availability (for fallback)
|
||
fb_encs = []
|
||
for vc in VIDEO_CODECS:
|
||
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
||
if self.env.ffmpeg_libs.get(lib_key, False):
|
||
fb_encs.append(vc.ffmpeg_encoder)
|
||
fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
|
||
self.status_label.setText(
|
||
f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
|
||
f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
|
||
)
|
||
|
||
# --- License attribution banner (shown once after successful probe) ---
|
||
# POSIX-friendly: log plain text, no escape codes, no decorative box chars
|
||
# that might confuse terminals. Each tool is named with its SPDX id so
|
||
# the user can audit obligations at a glance.
|
||
self._show_license_banner()
|
||
|
||
def _show_license_banner(self) -> None:
|
||
"""Log the active-component license summary once at startup.
|
||
|
||
SEI CERT MSC04-C: license text lives in exactly one canonical
|
||
location (LICENSE_NOTICES); this method only formats it.
|
||
"""
|
||
notices = active_license_notices(self.env)
|
||
self._log("")
|
||
self._log("=== Open Source License Attribution ===")
|
||
self._log("This application invokes the following third-party tools.")
|
||
self._log("Source code of these tools is NOT bundled; licenses flow")
|
||
self._log("through from upstream. See About > Licenses for full text.")
|
||
self._log("")
|
||
for n in notices:
|
||
self._log(f" • {n.name} — {n.spdx}")
|
||
self._log(f" {n.home_url}")
|
||
self._log("")
|
||
self._log("End of license summary.")
|
||
self._log("")
|
||
|
||
def _show_license_dialog(self) -> None:
|
||
"""Open a modal dialog with the full license text.
|
||
|
||
Triggered from the menu / button so the user can review the
|
||
complete attribution text at any time.
|
||
"""
|
||
notices = active_license_notices(self.env)
|
||
text = license_banner_full(notices)
|
||
dlg = QMessageBox(self)
|
||
dlg.setWindowTitle("About — Open Source Licenses")
|
||
dlg.setText("This application invokes the following open-source tools:")
|
||
dlg.setInformativeText(text)
|
||
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||
dlg.exec()
|
||
|
||
def _show_pre_transcode_license_summary(self) -> None:
|
||
"""One-line license reminder logged at the start of each batch.
|
||
|
||
Keeps the legal notice adjacent to the act of transcode, which is
|
||
where redistribution-relevant output is produced.
|
||
"""
|
||
notices = active_license_notices(self.env)
|
||
self._log(f"LICENSES: {license_banner_short(notices)}")
|
||
|
||
def _disable_unavailable_codecs(self):
|
||
"""Grey out AUDIO codec combos whose FFmpeg library is missing.
|
||
|
||
Video codecs are NOT disabled here because av1an uses its own
|
||
encoder binaries (svt_av1, vpx, x265) — it does not rely on
|
||
ffmpeg's encoder list for video.
|
||
|
||
v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now
|
||
carries its ffmpeg encoder name as the `ffmpeg_encoder_name`
|
||
field (e.g. "libopus"). We look up that name in env.ffmpeg_libs
|
||
directly. This replaces the v2 approach of indexing into
|
||
`params[1]`, which assumed a fixed params layout and would
|
||
silently break if a profile ever used a different argument order.
|
||
|
||
SEI CERT MSC04-C spirit: the source of truth for which library
|
||
each profile needs is the profile itself, not a parallel table.
|
||
"""
|
||
libs = self.env.ffmpeg_libs
|
||
|
||
for idx, profile in enumerate(AUDIO_PROFILES):
|
||
if idx >= self.audio_combo.count():
|
||
break # combo not yet populated, defensive
|
||
|
||
# v3: use the dedicated field instead of indexing into params.
|
||
lib_name = profile.ffmpeg_encoder_name
|
||
if not lib_name:
|
||
continue # passthrough profile, no encoder dependency
|
||
if not libs.get(lib_name, False):
|
||
item = self.audio_combo.model().item(idx)
|
||
if item is not None:
|
||
item.setEnabled(False)
|
||
item.setToolTip(
|
||
f"DISABLED: FFmpeg missing {lib_name} encoder. "
|
||
f"Use Rebuild from Git > ffmpeg + IAMF to enable."
|
||
)
|
||
# If the currently-selected item is the one we disabled,
|
||
# fall back to the first enabled entry.
|
||
if self.audio_combo.currentIndex() == idx:
|
||
self.audio_combo.setCurrentIndex(0)
|
||
|
||
# ── Process Control ──
|
||
|
||
def _parse_extensions(self) -> set[str]:
|
||
raw = self.ext_edit.text()
|
||
exts = set()
|
||
for part in raw.split(","):
|
||
part = part.strip().lower()
|
||
if not part.startswith("."):
|
||
part = "." + part
|
||
if part:
|
||
exts.add(part)
|
||
return exts or DEFAULT_INPUT_EXTENSIONS
|
||
|
||
@Slot()
|
||
def _start_process(self):
|
||
in_dir = Path(self.in_path_edit.text())
|
||
out_dir = Path(self.out_path_edit.text())
|
||
|
||
if not in_dir.is_dir():
|
||
self._log(f"ERROR: Source directory does not exist: {in_dir}")
|
||
return
|
||
if in_dir == out_dir:
|
||
self._log("ERROR: Source and output directories must be different.")
|
||
return
|
||
|
||
# ── Pre-flight: codec/container/audio compatibility check ──
|
||
# Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode.
|
||
warnings = self._check_combo_compatibility()
|
||
hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")]
|
||
if hard_blocks:
|
||
self._log("ERROR: Aborting — incompatible combination selected.")
|
||
QMessageBox.critical(
|
||
self, "Incompatible Codec Combination",
|
||
"The selected video/audio/container combination cannot be encoded:\n\n"
|
||
+ "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks)
|
||
+ "\n\nFix the selection and try again."
|
||
)
|
||
return
|
||
|
||
# Pre-transcode license reminder — adjacent to the act of transcode
|
||
# so obligations are visible at the moment redistribution-relevant
|
||
# output is produced.
|
||
self._show_pre_transcode_license_summary()
|
||
|
||
# If delete is enabled, collect files first for batch confirmation
|
||
if self.del_check.isChecked():
|
||
extensions = self._parse_extensions()
|
||
candidates = [f for f in in_dir.rglob("*") if f.is_file() and f.suffix.lower() in extensions and not f.name.endswith(".scaled_tmp.mkv")]
|
||
if candidates:
|
||
total_size = sum(f.stat().st_size for f in candidates)
|
||
reply = QMessageBox.question(
|
||
self, "Confirm Batch Delete",
|
||
f"This will delete {len(candidates)} source file(s) after successful transcode.\n"
|
||
f"Total size: {total_size / 1_073_741_824:.2f} GB\n\n"
|
||
f"Proceed?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
self._log("Cancelled: Delete not confirmed.")
|
||
return
|
||
|
||
# ── v4.2.0: av1an is opt-in. Default is ffmpeg-only. ──
|
||
# v4.4.3: flag can come from CLI (--use-av1an) OR UI toggle.
|
||
cli_use_av1an = bool(self.env.av1an_flags.get("use_av1an", False))
|
||
ui_use_av1an = (
|
||
hasattr(self, "av1an_check") and self.av1an_check.isChecked()
|
||
)
|
||
use_av1an = ui_use_av1an or cli_use_av1an
|
||
|
||
# ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ──
|
||
use_ffmpeg_fallback = False
|
||
skip_encode = False
|
||
if use_av1an and 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:
|
||
# Probe mismatch — re-detect the encoder name and retry once.
|
||
self._log(f" WARN: Encoder name probe mismatch. Re-detecting...")
|
||
QApplication.processEvents()
|
||
new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
|
||
if new_name:
|
||
self.env.av1an_flags["svt_name"] = new_name
|
||
self._log(f" Re-detected SVT-AV1 encoder name: '{new_name}'")
|
||
# Retry smoke test with corrected name
|
||
ok2, detail2 = _av1an_vsscript_smoke_test(
|
||
self.env.av1an_path, self.env.ffmpeg_path,
|
||
self.env.av1an_flags, svt_name=new_name,
|
||
)
|
||
if ok2:
|
||
self._log(" OK: av1an + VapourSynth working correctly.")
|
||
else:
|
||
self._log(f" FAIL: Still failing after re-detect: {detail2}")
|
||
return
|
||
else:
|
||
self._log(" FAIL: Could not determine valid encoder name. Check av1an --help manually.")
|
||
return
|
||
elif ok:
|
||
self._log(" OK: av1an + VapourSynth working correctly.")
|
||
else:
|
||
# Smoke test failed for an unexpected reason (encoder binary
|
||
# missing, concat method unsupported, av1an panicked, etc.).
|
||
# Previously this was logged as "non-fatal" and the encode
|
||
# proceeded anyway — which produced the "chunks but never
|
||
# saves a file" symptom because every file then failed at
|
||
# the same point. Now we treat unknown smoke failures as
|
||
# hard blocks and offer the user ffmpeg fallback if the
|
||
# selected codec is available, otherwise abort.
|
||
self._log(f" FAIL: av1an smoke test failed:")
|
||
for line in detail.splitlines()[:12]:
|
||
self._log(f" {line}")
|
||
# If ffmpeg has the matching encoder, offer fallback;
|
||
# otherwise abort with an actionable message.
|
||
codec_idx_pre = self.codec_combo.currentIndex()
|
||
if 0 <= codec_idx_pre < len(VIDEO_CODECS):
|
||
vc = VIDEO_CODECS[codec_idx_pre]
|
||
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
||
if self.env.ffmpeg_libs.get(lib_key, False):
|
||
self._log(f" FFmpeg has {vc.ffmpeg_encoder} — offering fallback.")
|
||
use_ffmpeg_fallback = self._handle_vs_incompat()
|
||
if not use_ffmpeg_fallback:
|
||
return
|
||
else:
|
||
self._log(
|
||
f" ABORT: ffmpeg also lacks {vc.ffmpeg_encoder}. "
|
||
f"Install the encoder binary (e.g. SvtAv1EncApp, vpxenc, x265) "
|
||
f"or use the REBUILD FROM GIT button."
|
||
)
|
||
return
|
||
else:
|
||
self._log(" ABORT: invalid codec selection.")
|
||
return
|
||
elif not use_av1an:
|
||
# v4.4.3: default path — skip av1an entirely, use ffmpeg.
|
||
self._log("Encode mode: ffmpeg-only (default). Toggle 'av1an (chunk-parallel)' to enable av1an.")
|
||
use_ffmpeg_fallback = True
|
||
|
||
codec_idx = self.codec_combo.currentIndex()
|
||
audio_idx = self.audio_combo.currentIndex()
|
||
container_idx = self.container_combo.currentIndex()
|
||
|
||
# Safety: clamp codec_idx to valid range
|
||
if not (0 <= codec_idx < len(VIDEO_CODECS)):
|
||
self._log(f"ERROR: Invalid codec index {codec_idx}. Resetting to AV1 (SVT-AV1).")
|
||
codec_idx = 0
|
||
self.codec_combo.blockSignals(True)
|
||
self.codec_combo.setCurrentIndex(0)
|
||
self.codec_combo.blockSignals(False)
|
||
|
||
selected_codec = VIDEO_CODECS[codec_idx]
|
||
self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})")
|
||
|
||
self.worker = EncoderWorker(
|
||
in_dir=in_dir,
|
||
out_dir=out_dir,
|
||
video_codec=selected_codec,
|
||
audio_profile=AUDIO_PROFILES[audio_idx],
|
||
container=CONTAINER_PROFILES[container_idx],
|
||
crf=self.crf_knob.intValue(),
|
||
preset_label=self.preset_combo.currentText(),
|
||
delete_source=self.del_check.isChecked(),
|
||
env=self.env,
|
||
extensions=self._parse_extensions(),
|
||
resolution=self._get_current_resolution(),
|
||
audio_level_db=self.vol_knob.value(),
|
||
use_ffmpeg_fallback=use_ffmpeg_fallback,
|
||
subtitle_lang=SUBTITLE_OPTIONS[self.subs_combo.currentIndex()][1],
|
||
force=self.force_check.isChecked(), # v5-01
|
||
)
|
||
self.worker.log_msg.connect(self._log)
|
||
self.worker.progress_msg.connect(self._on_progress)
|
||
self.worker.finished_queue.connect(self._on_finished)
|
||
|
||
self.btn_run.setEnabled(False)
|
||
self.btn_run.setText("RUNNING...")
|
||
self.btn_stop.setEnabled(True)
|
||
self.btn_rebuild.setEnabled(False)
|
||
self.worker.start()
|
||
|
||
def _handle_vs_incompat(self) -> bool:
|
||
"""Handle detected VSScript ABI incompatibility.
|
||
|
||
Shows a dialog with options:
|
||
1. Rebuild VapourSynth + av1an from git (resolves root cause)
|
||
2. Use ffmpeg fallback (works now, no chunk-parallel)
|
||
3. Cancel
|
||
|
||
Returns True if we should use ffmpeg fallback (option 2),
|
||
False if user cancelled or chose to rebuild (rebuild
|
||
starts async and does NOT return here — the user
|
||
will click ENCODE again after it completes).
|
||
"""
|
||
self._log(" FAIL: av1an cannot initialize VSScript API.")
|
||
self._log(" The av1an binary was compiled against a different VapourSynth version.")
|
||
|
||
# Check ffmpeg fallback availability
|
||
codec_idx = self.codec_combo.currentIndex()
|
||
video_codec = VIDEO_CODECS[codec_idx]
|
||
ffmpeg_enc = video_codec.ffmpeg_encoder
|
||
ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc) # v3: OTC-007
|
||
fallback_possible = self.env.ffmpeg_libs.get(ffmpeg_lib_key, False)
|
||
|
||
if fallback_possible:
|
||
btn_rebuild = QPushButton(" Rebuild from Git ")
|
||
btn_rebuild.setObjectName("btnRebuild")
|
||
btn_fallback = QPushButton(" Use ffmpeg Fallback ")
|
||
btn_fallback.setObjectName("btnRun")
|
||
btn_cancel = QPushButton(" Cancel ")
|
||
btn_cancel.setObjectName("btnStop")
|
||
|
||
dlg = QMessageBox(self)
|
||
dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
|
||
dlg.setText(
|
||
"av1an cannot initialize VapourSynth — the installed versions\n"
|
||
"have an ABI incompatibility (common with distro packages).\n\n"
|
||
f"Choose how to proceed:"
|
||
)
|
||
dlg.setInformativeText(
|
||
"• Rebuild from Git — compiles both from source (~10-30 min).\n"
|
||
" Fixes the root cause. Requires sudo for install.\n"
|
||
f"• ffmpeg Fallback — encode with ffmpeg ({ffmpeg_enc}) now.\n"
|
||
" No chunk-parallel mode but output quality is identical."
|
||
)
|
||
dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
|
||
dlg.addButton(btn_fallback, QMessageBox.ButtonRole.YesRole)
|
||
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
||
|
||
dlg.exec()
|
||
clicked = dlg.clickedButton()
|
||
|
||
if clicked == btn_rebuild:
|
||
self._log("")
|
||
self._log("User chose: Rebuild VapourSynth + av1an from git.")
|
||
self._start_git_rebuild()
|
||
return False # don't start encoding — user will retry after build
|
||
elif clicked == btn_fallback:
|
||
self._log("")
|
||
self._log(f"FALLBACK: Switching to pure ffmpeg ({ffmpeg_enc}) encoding.")
|
||
self._log(
|
||
" Note: ffmpeg single-pass mode (no chunk-parallel). "
|
||
"Slower for large files but produces identical output."
|
||
)
|
||
self._log(" Use the REBUILD FROM GIT button to fix av1an for chunk-parallel mode.")
|
||
self._log("")
|
||
return True
|
||
else:
|
||
# Cancel
|
||
self._log("Cancelled by user.")
|
||
return False
|
||
else:
|
||
# No ffmpeg fallback available — offer rebuild or hard cancel
|
||
btn_rebuild = QPushButton(" Rebuild from Git ")
|
||
btn_rebuild.setObjectName("btnRebuild")
|
||
btn_cancel = QPushButton(" Cancel ")
|
||
btn_cancel.setObjectName("btnStop")
|
||
|
||
dlg = QMessageBox(self)
|
||
dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
|
||
dlg.setText(
|
||
"av1an cannot initialize VapourSynth — ABI incompatibility.\n\n"
|
||
f"ffmpeg also lacks '{ffmpeg_enc}' — no fallback possible.\n"
|
||
"You must rebuild to proceed."
|
||
)
|
||
dlg.setIcon(QMessageBox.Icon.Critical)
|
||
dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
|
||
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
||
|
||
dlg.exec()
|
||
clicked = dlg.clickedButton()
|
||
|
||
if clicked == btn_rebuild:
|
||
self._log("")
|
||
self._log("User chose: Rebuild VapourSynth + av1an from git (no fallback available).")
|
||
self._start_git_rebuild()
|
||
else:
|
||
self._log("Cancelled by user.")
|
||
return False
|
||
|
||
def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True,
|
||
build_ffmpeg_iamf: bool = False):
|
||
"""Start the SourceBuildWorker thread."""
|
||
components = []
|
||
if build_vs: components.append("VapourSynth")
|
||
if build_av1an: components.append("av1an")
|
||
if build_ffmpeg_iamf: components.append("ffmpeg+libiamf")
|
||
self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...")
|
||
self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.")
|
||
if build_ffmpeg_iamf:
|
||
self._log(" NOTE: ffmpeg build takes 10-20 min. App must be restarted after.")
|
||
self.btn_run.setEnabled(False)
|
||
self.btn_rebuild.setEnabled(False)
|
||
self.btn_stop.setEnabled(False)
|
||
self.status_label.setText("Building from git... (see log)")
|
||
|
||
self._build_worker = SourceBuildWorker(
|
||
build_vs=build_vs, build_av1an=build_av1an,
|
||
build_ffmpeg_iamf=build_ffmpeg_iamf,
|
||
)
|
||
self._build_worker.log_msg.connect(self._log)
|
||
self._build_worker.build_done.connect(self._on_build_done)
|
||
self._build_worker.start()
|
||
|
||
@Slot(bool, str)
|
||
def _on_build_done(self, success: bool, message: str):
|
||
"""Called when SourceBuildWorker finishes."""
|
||
self._log("")
|
||
if success:
|
||
self._log(f"BUILD SUCCESS: {message}")
|
||
self._log("Re-probing environment to pick up new binaries...")
|
||
QApplication.processEvents()
|
||
|
||
# Ensure LD_LIBRARY_PATH is set in the main process too.
|
||
#
|
||
# INTENTIONAL os.environ mutation (the ONE kept after the
|
||
# v3-08 refactor). SourceBuildWorker no longer mutates
|
||
# os.environ — it accumulates env changes in its private
|
||
# self._build_env dict and passes that to subprocess.run.
|
||
# But that dict dies with the worker thread. The UI thread
|
||
# must update its OWN os.environ so the next
|
||
# probe_environment() call — which spawns ffmpeg/av1an
|
||
# subprocesses that inherit os.environ — can dlopen the
|
||
# freshly-built VapourSynth / libiamf shared libraries from
|
||
# ~/.local/lib. Without this, the rebuilt binaries would
|
||
# fail to load their dependent libs.
|
||
local_lib = str(Path.home() / ".local" / "lib")
|
||
existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
|
||
if local_lib not in existing_ld:
|
||
os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":")
|
||
|
||
# Re-probe environment with fresh data
|
||
self.env = probe_environment()
|
||
|
||
# Run smoke test again to verify the fix
|
||
if self.env.av1an_path and self.env.ffmpeg_path:
|
||
svt_name = self.env.av1an_flags.get("svt_name", "svt_av1")
|
||
ok, detail = _av1an_vsscript_smoke_test(
|
||
self.env.av1an_path,
|
||
self.env.ffmpeg_path,
|
||
self.env.av1an_flags,
|
||
svt_name=svt_name,
|
||
)
|
||
if ok:
|
||
self._log("VERIFIED: av1an + VapourSynth now working correctly!")
|
||
self._log("Click START PROCESSING to encode.")
|
||
elif "INVALID_ENCODER" in detail:
|
||
# Re-probe encoder name with the fresh binary
|
||
self._log(" Re-detecting encoder name from fresh build...")
|
||
new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
|
||
if new_name and new_name != svt_name:
|
||
self.env.av1an_flags["svt_name"] = new_name
|
||
self._log(f" Corrected encoder name: '{svt_name}' -> '{new_name}'")
|
||
ok2, detail2 = _av1an_vsscript_smoke_test(
|
||
self.env.av1an_path, self.env.ffmpeg_path,
|
||
self.env.av1an_flags, svt_name=new_name,
|
||
)
|
||
if ok2:
|
||
self._log("VERIFIED: av1an + VapourSynth now working correctly!")
|
||
self._log("Click START PROCESSING to encode.")
|
||
else:
|
||
self._log(f"WARNING: Smoke test still fails: {detail2}")
|
||
else:
|
||
self._log(f"WARNING: Could not auto-fix encoder name. Smoke test: {detail}")
|
||
else:
|
||
self._log(f"WARNING: Build completed but smoke test still fails: {detail}")
|
||
self._log("You may need to log out/in or restart the app for library changes to take effect.")
|
||
|
||
# Update status bar
|
||
distro = self.env.distro
|
||
cpu = self.env.cpu
|
||
vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
|
||
fb_encs = []
|
||
for vc in VIDEO_CODECS:
|
||
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
||
if self.env.ffmpeg_libs.get(lib_key, False):
|
||
fb_encs.append(vc.ffmpeg_encoder)
|
||
fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
|
||
self.status_label.setText(
|
||
f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
|
||
f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
|
||
)
|
||
else:
|
||
self._log(f"BUILD FAILED: {message}")
|
||
self._log("Try running the build manually in a terminal, or use ffmpeg fallback.")
|
||
self.status_label.setText("Build failed — check log")
|
||
|
||
self.btn_run.setEnabled(True)
|
||
self.btn_rebuild.setEnabled(True)
|
||
|
||
@Slot()
|
||
def _manual_rebuild(self):
|
||
"""Handle the REBUILD FROM GIT button click (manual trigger)."""
|
||
btn_vs_av1an = QPushButton(" VapourSynth + av1an ")
|
||
btn_vs_av1an.setObjectName("btnRebuild")
|
||
btn_vs_only = QPushButton(" VapourSynth only ")
|
||
btn_vs_only.setObjectName("btnRebuild")
|
||
btn_av1an_only = QPushButton(" av1an only ")
|
||
btn_av1an_only.setObjectName("btnRebuild")
|
||
btn_ffmpeg_iamf = QPushButton(" ffmpeg + IAMF ")
|
||
btn_ffmpeg_iamf.setObjectName("btnRebuild")
|
||
btn_cancel = QPushButton(" Cancel ")
|
||
btn_cancel.setObjectName("btnStop")
|
||
|
||
dlg = QMessageBox(self)
|
||
dlg.setWindowTitle("Rebuild from Git")
|
||
dlg.setText(
|
||
"Select which components to rebuild from git source.\n\n"
|
||
"• VapourSynth — installs to ~/.local (needs sudo for build deps)\n"
|
||
"• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n"
|
||
"• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n"
|
||
" installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n"
|
||
" Required to use the IAMF audio codec. ~10-20 min build time.\n\n"
|
||
"Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min"
|
||
)
|
||
dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole)
|
||
dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole)
|
||
dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole)
|
||
dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole)
|
||
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
||
|
||
dlg.exec()
|
||
clicked = dlg.clickedButton()
|
||
|
||
if clicked == btn_vs_av1an:
|
||
self._start_git_rebuild(build_vs=True, build_av1an=True)
|
||
elif clicked == btn_vs_only:
|
||
self._start_git_rebuild(build_vs=True, build_av1an=False)
|
||
elif clicked == btn_av1an_only:
|
||
self._start_git_rebuild(build_vs=False, build_av1an=True)
|
||
elif clicked == btn_ffmpeg_iamf:
|
||
self._start_git_rebuild(build_vs=False, build_av1an=False,
|
||
build_ffmpeg_iamf=True)
|
||
|
||
@Slot(str, int, int)
|
||
def _on_progress(self, filename: str, current: int, total: int):
|
||
self.status_label.setText(f"Processing {current}/{total}: {filename}")
|
||
|
||
@Slot(int, int)
|
||
def _on_finished(self, ok: int, fail: int):
|
||
self.btn_run.setEnabled(True)
|
||
self.btn_run.setText("START PROCESSING")
|
||
self.btn_stop.setEnabled(False)
|
||
self.status_label.setText(f"Done — {ok} succeeded, {fail} failed")
|
||
|
||
if fail > 0:
|
||
self._log(f"WARNING: {fail} file(s) failed. Check log above for details.")
|
||
if ok > 0:
|
||
self._log(f"All {ok} file(s) archived successfully.")
|
||
|
||
@Slot()
|
||
def _stop_process(self):
|
||
if self.worker and self.worker.isRunning():
|
||
self._log("STOP: Exiting queue after current file finishes...")
|
||
self.worker.stop()
|
||
self.btn_stop.setEnabled(False)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ENTRY POINT
|
||
# ──────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
app = QApplication(sys.argv)
|
||
window = OpenCodecMaster()
|
||
window.show()
|
||
sys.exit(app.exec()) |