1292 lines
57 KiB
Python
1292 lines
57 KiB
Python
"""OpenCodecMaster (QMainWindow) — the main GUI window.
|
|
|
|
The top-level window that wires together every other module: codec
|
|
profiles (combo boxes), env probe (startup), encoder worker (queue
|
|
execution), source builder (rebuild-from-git button), license notices
|
|
(About dialog), the MMD3 stylesheet, and the RadioKnob widget.
|
|
|
|
Also exposes ``launch_gui()``, which is the ``QApplication`` entry
|
|
point invoked by ``cli.main()`` and ``python -m opentranscode``.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Slot
|
|
from PySide6.QtGui import QFont, QPalette, QColor
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
|
|
QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox,
|
|
QStyleFactory,
|
|
)
|
|
|
|
from .codec_profiles import (
|
|
AUDIO_PROFILES,
|
|
CONTAINER_PROFILES,
|
|
DEFAULT_INPUT_EXTENSIONS,
|
|
RESOLUTION_PRESETS,
|
|
SUBTITLE_OPTIONS,
|
|
VIDEO_CODECS,
|
|
AudioProfile,
|
|
ResolutionProfile,
|
|
ffmpeg_lib_key_for,
|
|
)
|
|
from .encoder_worker import EncoderWorker
|
|
from .env_probe import (
|
|
EnvProbe,
|
|
_av1an_vsscript_smoke_test,
|
|
_detect_av1an_svt_encoder,
|
|
probe_environment,
|
|
)
|
|
from .license_registry import (
|
|
LICENSE_NOTICES,
|
|
active_license_notices,
|
|
license_banner_full,
|
|
license_banner_short,
|
|
)
|
|
from .source_builder import SourceBuildWorker
|
|
from .ui_theme import MMD3_QSS
|
|
from .widgets import RadioKnob
|
|
|
|
class OpenCodecMaster(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("OpenTranscode — dcos.net")
|
|
self.resize(1100, 920)
|
|
self.worker: EncoderWorker | None = None
|
|
self.env: EnvProbe | None = None
|
|
self._pending_deletes: list[Path] = []
|
|
|
|
self._apply_mmd3_theme()
|
|
self._build_ui()
|
|
|
|
# Probe environment after UI is up
|
|
QTimer.singleShot(500, self._probe_and_init)
|
|
|
|
# ── UI Construction ──
|
|
|
|
def _build_ui(self):
|
|
central = QWidget()
|
|
central.setObjectName("central")
|
|
self.setCentralWidget(central)
|
|
root = QVBoxLayout(central)
|
|
root.setContentsMargins(10, 6, 10, 4)
|
|
root.setSpacing(4)
|
|
|
|
# ── Header ──
|
|
header = QWidget()
|
|
header_lay = QVBoxLayout(header)
|
|
header_lay.setContentsMargins(0, 0, 0, 0)
|
|
header_lay.setSpacing(0)
|
|
|
|
title = QLabel("OpenTranscode")
|
|
title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold))
|
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;")
|
|
header_lay.addWidget(title)
|
|
|
|
subtitle = QLabel('dcos.net // concurrent open-source transcoding')
|
|
subtitle.setFont(QFont("Consolas", 8))
|
|
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
subtitle.setStyleSheet("color: #555; letter-spacing: 2px;")
|
|
header_lay.addWidget(subtitle)
|
|
|
|
accent = QWidget()
|
|
accent.setFixedHeight(1)
|
|
accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
|
|
"stop:0 transparent, stop:0.15 #d4aa5044,"
|
|
"stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);")
|
|
header_lay.addWidget(accent)
|
|
|
|
root.addWidget(header)
|
|
|
|
# ── Paths ──
|
|
path_grp = QGroupBox("Paths")
|
|
path_lay = QVBoxLayout(path_grp)
|
|
path_lay.setSpacing(2)
|
|
path_lay.setContentsMargins(10, 14, 10, 8)
|
|
|
|
self.in_path_edit = QLineEdit(str(Path.home() / "Videos" / "INCOMING"))
|
|
self.out_path_edit = QLineEdit(str(Path.home() / "Videos" / "ARCHIVE"))
|
|
for label_text, line_edit in [
|
|
("IN:", self.in_path_edit),
|
|
("OUT:", self.out_path_edit),
|
|
]:
|
|
row = QHBoxLayout()
|
|
row.setSpacing(6)
|
|
lbl = QLabel(label_text)
|
|
lbl.setFixedWidth(28)
|
|
lbl.setStyleSheet("color: #d4aa50; font-family: 'Consolas', monospace; font-weight: bold; font-size: 10px;")
|
|
row.addWidget(lbl)
|
|
row.addWidget(line_edit, 1)
|
|
btn_browse = QPushButton("...")
|
|
btn_browse.setObjectName("btnBrowse")
|
|
btn_browse.setFixedSize(30, 22)
|
|
btn_browse.setToolTip("Browse")
|
|
btn_browse.clicked.connect(
|
|
lambda checked, le=line_edit, is_dir=True: self._browse(le, is_dir)
|
|
)
|
|
row.addWidget(btn_browse)
|
|
path_lay.addLayout(row)
|
|
|
|
root.addWidget(path_grp)
|
|
|
|
# ── Encoder Chain ──
|
|
codec_grp = QGroupBox("Encoder Chain")
|
|
codec_lay = QHBoxLayout(codec_grp)
|
|
codec_lay.setSpacing(8)
|
|
codec_lay.setContentsMargins(10, 14, 10, 8)
|
|
|
|
for col_idx, (label, combo_items, slot) in enumerate([
|
|
("VIDEO", [vc.label for vc in VIDEO_CODECS], self._on_codec_changed),
|
|
("PRESET", [], None),
|
|
("AUDIO", [ap.label for ap in AUDIO_PROFILES], self._on_audio_changed),
|
|
("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed),
|
|
("RESOLUTION", [], self._on_resolution_changed),
|
|
("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None),
|
|
]):
|
|
col = QVBoxLayout()
|
|
col.setSpacing(1)
|
|
lbl = QLabel(label)
|
|
lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
|
|
col.addWidget(lbl)
|
|
|
|
combo = QComboBox()
|
|
combo.setFixedHeight(24)
|
|
if combo_items:
|
|
combo.addItems(combo_items)
|
|
if slot:
|
|
combo.currentIndexChanged.connect(slot)
|
|
col.addWidget(combo)
|
|
codec_lay.addLayout(col)
|
|
|
|
if label == "VIDEO":
|
|
self.codec_combo = combo
|
|
elif label == "PRESET":
|
|
self.preset_combo = combo
|
|
self._populate_presets(0)
|
|
self.preset_combo.setCurrentIndex(1)
|
|
elif label == "AUDIO":
|
|
self.audio_combo = combo
|
|
elif label == "CONTAINER":
|
|
self.container_combo = combo
|
|
elif label == "RESOLUTION":
|
|
self.resolution_combo = combo
|
|
self._populate_resolution_combo()
|
|
elif label == "SUBS":
|
|
self.subs_combo = combo
|
|
|
|
root.addWidget(codec_grp)
|
|
|
|
# ── Side panel: compact knobs ──
|
|
knobs_panel = QWidget()
|
|
knobs_panel.setFixedWidth(170)
|
|
knobs_lay = QVBoxLayout(knobs_panel)
|
|
knobs_lay.setContentsMargins(6, 8, 6, 8)
|
|
knobs_lay.setSpacing(6)
|
|
knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
# CRF Knob — amber
|
|
self.crf_knob = RadioKnob(
|
|
min_val=18, max_val=52, default_val=32,
|
|
label="Quality",
|
|
unit="CRF",
|
|
color=(212, 170, 80),
|
|
num_ticks=18,
|
|
tick_labels=["18", "28", "38", "52"],
|
|
snap_ticks=True,
|
|
compact=True,
|
|
)
|
|
self.crf_knob.valueChanged.connect(self._on_crf_knob_changed)
|
|
knobs_lay.addWidget(self.crf_knob, 0, Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
# Volume Knob — green (dual-pass loudnorm target)
|
|
self.vol_knob = RadioKnob(
|
|
min_val=-20.0, max_val=6.0, default_val=0.0,
|
|
label="LUFS",
|
|
unit="dB",
|
|
color=(64, 208, 96),
|
|
num_ticks=27,
|
|
tick_labels=["-20", "-10", "0", "+6"],
|
|
snap_ticks=True,
|
|
compact=True,
|
|
)
|
|
self.vol_knob.setToolTip(
|
|
"Dual-pass audio normalization target (EBU R128 LUFS).\n"
|
|
"0 = off (pass-through).\n"
|
|
"Each file is analyzed individually: loudnorm measures its\n"
|
|
"current LUFS and true peak, then computes the exact gain\n"
|
|
"to hit this target. If the gain would push peaks above\n"
|
|
"-1.5 dBTP, gain is reduced to keep 15%% headroom.\n"
|
|
"Common targets: -14 (streaming), -16 (broadcast), -23 (cinema)."
|
|
)
|
|
self.vol_knob.valueChanged.connect(self._on_vol_knob_changed)
|
|
knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter)
|
|
|
|
# ── Options row ──
|
|
opt_row = QHBoxLayout()
|
|
opt_row.setSpacing(8)
|
|
opt_lbl = QLabel("FILTER")
|
|
opt_lbl.setFixedWidth(44)
|
|
opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
|
|
opt_row.addWidget(opt_lbl)
|
|
self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS)))
|
|
self.ext_edit.setFixedHeight(22)
|
|
self.ext_edit.setToolTip("File extensions to process. Separate with commas.")
|
|
opt_row.addWidget(self.ext_edit)
|
|
|
|
self.del_check = QCheckBox("Delete source after verify")
|
|
self.del_check.setObjectName("dangerCheck")
|
|
self.del_check.setToolTip(
|
|
"Sources are only deleted after all files finish and cleanup passes.\n"
|
|
"If any file fails, you will be prompted before deletion."
|
|
)
|
|
opt_row.addWidget(self.del_check)
|
|
|
|
# v5-01: Force checkbox — skip ffprobe validation and attempt encode
|
|
# even for files ffprobe cannot read. Use for the rare edge case where
|
|
# ffprobe fails but the file is actually valid. Default OFF — most
|
|
# "ffprobe can't read" files are genuinely invalid (failed downloads,
|
|
# HTML saved as .mp4, truncated files, etc.).
|
|
self.force_check = QCheckBox("Force (skip validation)")
|
|
self.force_check.setToolTip(
|
|
"Skip ffprobe pre-validation and attempt encode even for files\n"
|
|
"ffprobe cannot read. Useful for the rare case where ffprobe\n"
|
|
"fails but the file is actually valid (rare codec, broken\n"
|
|
"container metadata). WARNING: with this enabled, invalid files\n"
|
|
"(failed downloads, HTML, truncated) will waste the full\n"
|
|
"per-file timeout before failing."
|
|
)
|
|
opt_row.addWidget(self.force_check)
|
|
root.addLayout(opt_row)
|
|
|
|
# ── Log + Knobs: horizontal split ──
|
|
mid_split = QHBoxLayout()
|
|
mid_split.setSpacing(6)
|
|
|
|
# Log: LED terminal (takes remaining space)
|
|
self.log_box = QTextEdit()
|
|
self.log_box.setObjectName("logBox")
|
|
self.log_box.setReadOnly(True)
|
|
mid_split.addWidget(self.log_box, 1)
|
|
|
|
# Knobs panel on the right
|
|
mid_split.addWidget(knobs_panel)
|
|
|
|
root.addLayout(mid_split, 1)
|
|
|
|
# ── Status Bar: LED readout ──
|
|
self.status = QStatusBar()
|
|
self.setStatusBar(self.status)
|
|
self.status_label = QLabel(" INITIALIZING...")
|
|
self.status_label.setStyleSheet(
|
|
"color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; font-size: 10px;"
|
|
)
|
|
self.status.addWidget(self.status_label, 1)
|
|
|
|
# ── Transport Buttons ──
|
|
btn_lay = QHBoxLayout()
|
|
btn_lay.setSpacing(8)
|
|
|
|
self.btn_run = QPushButton(" > ENCODE")
|
|
self.btn_run.setObjectName("btnRun")
|
|
self.btn_run.setFixedHeight(40)
|
|
self.btn_run.setEnabled(False)
|
|
self.btn_run.clicked.connect(self._start_process)
|
|
btn_lay.addWidget(self.btn_run)
|
|
|
|
self.btn_stop = QPushButton(" [] STOP")
|
|
self.btn_stop.setObjectName("btnStop")
|
|
self.btn_stop.setFixedHeight(40)
|
|
self.btn_stop.clicked.connect(self._stop_process)
|
|
self.btn_stop.setEnabled(False)
|
|
btn_lay.addWidget(self.btn_stop)
|
|
|
|
self.btn_rebuild = QPushButton(" <> REBUILD FROM GIT")
|
|
self.btn_rebuild.setObjectName("btnRebuild")
|
|
self.btn_rebuild.setFixedHeight(40)
|
|
self.btn_rebuild.setToolTip(
|
|
"Compile VapourSynth + av1an from git source.\n"
|
|
"Resolves ABI/version mismatch when package managers\n"
|
|
"install incompatible versions."
|
|
)
|
|
self.btn_rebuild.clicked.connect(self._manual_rebuild)
|
|
self.btn_rebuild.setEnabled(False)
|
|
btn_lay.addWidget(self.btn_rebuild)
|
|
|
|
self.btn_about = QPushButton(" ? ABOUT / LICENSES")
|
|
self.btn_about.setObjectName("btnAbout")
|
|
self.btn_about.setFixedHeight(40)
|
|
self.btn_about.setToolTip(
|
|
"Show open-source license attributions for all\n"
|
|
"third-party components invoked by this application."
|
|
)
|
|
self.btn_about.clicked.connect(self._show_license_dialog)
|
|
btn_lay.addWidget(self.btn_about)
|
|
root.addLayout(btn_lay)
|
|
|
|
# ── Footer ──
|
|
footer = QWidget()
|
|
footer_lay = QHBoxLayout(footer)
|
|
footer_lay.setContentsMargins(6, 4, 6, 2)
|
|
footer_lay.setSpacing(0)
|
|
|
|
link_lbl = QLabel(
|
|
'<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()
|
|
|
|
worker_count = max(1, self.env.cpu.physical_cores - 1)
|
|
cpu = self.env.cpu
|
|
self._log(
|
|
f"Chunk-parallel mode: {worker_count} av1an workers "
|
|
f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, "
|
|
f"{cpu.threads_per_core}T/core)"
|
|
)
|
|
|
|
self.btn_run.setEnabled(True)
|
|
self.btn_run.setText("START PROCESSING")
|
|
self.btn_rebuild.setEnabled(True) # available after successful probe
|
|
vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
|
|
# Show ffmpeg video encoder availability (for fallback)
|
|
fb_encs = []
|
|
for vc in VIDEO_CODECS:
|
|
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
|
if self.env.ffmpeg_libs.get(lib_key, False):
|
|
fb_encs.append(vc.ffmpeg_encoder)
|
|
fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
|
|
self.status_label.setText(
|
|
f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
|
|
f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
|
|
)
|
|
|
|
# --- License attribution banner (shown once after successful probe) ---
|
|
# POSIX-friendly: log plain text, no escape codes, no decorative box chars
|
|
# that might confuse terminals. Each tool is named with its SPDX id so
|
|
# the user can audit obligations at a glance.
|
|
self._show_license_banner()
|
|
|
|
def _show_license_banner(self) -> None:
|
|
"""Log the active-component license summary once at startup.
|
|
|
|
SEI CERT MSC04-C: license text lives in exactly one canonical
|
|
location (LICENSE_NOTICES); this method only formats it.
|
|
"""
|
|
notices = active_license_notices(self.env)
|
|
self._log("")
|
|
self._log("=== Open Source License Attribution ===")
|
|
self._log("This application invokes the following third-party tools.")
|
|
self._log("Source code of these tools is NOT bundled; licenses flow")
|
|
self._log("through from upstream. See About > Licenses for full text.")
|
|
self._log("")
|
|
for n in notices:
|
|
self._log(f" • {n.name} — {n.spdx}")
|
|
self._log(f" {n.home_url}")
|
|
self._log("")
|
|
self._log("End of license summary.")
|
|
self._log("")
|
|
|
|
def _show_license_dialog(self) -> None:
|
|
"""Open a modal dialog with the full license text.
|
|
|
|
Triggered from the menu / button so the user can review the
|
|
complete attribution text at any time.
|
|
"""
|
|
notices = active_license_notices(self.env)
|
|
text = license_banner_full(notices)
|
|
dlg = QMessageBox(self)
|
|
dlg.setWindowTitle("About — Open Source Licenses")
|
|
dlg.setText("This application invokes the following open-source tools:")
|
|
dlg.setInformativeText(text)
|
|
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
|
|
dlg.exec()
|
|
|
|
def _show_pre_transcode_license_summary(self) -> None:
|
|
"""One-line license reminder logged at the start of each batch.
|
|
|
|
Keeps the legal notice adjacent to the act of transcode, which is
|
|
where redistribution-relevant output is produced.
|
|
"""
|
|
notices = active_license_notices(self.env)
|
|
self._log(f"LICENSES: {license_banner_short(notices)}")
|
|
|
|
def _disable_unavailable_codecs(self):
|
|
"""Grey out AUDIO codec combos whose FFmpeg library is missing.
|
|
|
|
Video codecs are NOT disabled here because av1an uses its own
|
|
encoder binaries (svt_av1, vpx, x265) — it does not rely on
|
|
ffmpeg's encoder list for video.
|
|
|
|
v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now
|
|
carries its ffmpeg encoder name as the `ffmpeg_encoder_name`
|
|
field (e.g. "libopus"). We look up that name in env.ffmpeg_libs
|
|
directly. This replaces the v2 approach of indexing into
|
|
`params[1]`, which assumed a fixed params layout and would
|
|
silently break if a profile ever used a different argument order.
|
|
|
|
SEI CERT MSC04-C spirit: the source of truth for which library
|
|
each profile needs is the profile itself, not a parallel table.
|
|
"""
|
|
libs = self.env.ffmpeg_libs
|
|
|
|
for idx, profile in enumerate(AUDIO_PROFILES):
|
|
if idx >= self.audio_combo.count():
|
|
break # combo not yet populated, defensive
|
|
|
|
# v3: use the dedicated field instead of indexing into params.
|
|
lib_name = profile.ffmpeg_encoder_name
|
|
if not lib_name:
|
|
continue # passthrough profile, no encoder dependency
|
|
if not libs.get(lib_name, False):
|
|
item = self.audio_combo.model().item(idx)
|
|
if item is not None:
|
|
item.setEnabled(False)
|
|
item.setToolTip(
|
|
f"DISABLED: FFmpeg missing {lib_name} encoder. "
|
|
f"Use Rebuild from Git > ffmpeg + IAMF to enable."
|
|
)
|
|
# If the currently-selected item is the one we disabled,
|
|
# fall back to the first enabled entry.
|
|
if self.audio_combo.currentIndex() == idx:
|
|
self.audio_combo.setCurrentIndex(0)
|
|
|
|
# ── Process Control ──
|
|
|
|
def _parse_extensions(self) -> set[str]:
|
|
raw = self.ext_edit.text()
|
|
exts = set()
|
|
for part in raw.split(","):
|
|
part = part.strip().lower()
|
|
if not part.startswith("."):
|
|
part = "." + part
|
|
if part:
|
|
exts.add(part)
|
|
return exts or DEFAULT_INPUT_EXTENSIONS
|
|
|
|
@Slot()
|
|
def _start_process(self):
|
|
in_dir = Path(self.in_path_edit.text())
|
|
out_dir = Path(self.out_path_edit.text())
|
|
|
|
if not in_dir.is_dir():
|
|
self._log(f"ERROR: Source directory does not exist: {in_dir}")
|
|
return
|
|
if in_dir == out_dir:
|
|
self._log("ERROR: Source and output directories must be different.")
|
|
return
|
|
|
|
# ── Pre-flight: codec/container/audio compatibility check ──
|
|
# Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode.
|
|
warnings = self._check_combo_compatibility()
|
|
hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")]
|
|
if hard_blocks:
|
|
self._log("ERROR: Aborting — incompatible combination selected.")
|
|
QMessageBox.critical(
|
|
self, "Incompatible Codec Combination",
|
|
"The selected video/audio/container combination cannot be encoded:\n\n"
|
|
+ "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks)
|
|
+ "\n\nFix the selection and try again."
|
|
)
|
|
return
|
|
|
|
# Pre-transcode license reminder — adjacent to the act of transcode
|
|
# so obligations are visible at the moment redistribution-relevant
|
|
# output is produced.
|
|
self._show_pre_transcode_license_summary()
|
|
|
|
# If delete is enabled, collect files first for batch confirmation
|
|
if self.del_check.isChecked():
|
|
extensions = self._parse_extensions()
|
|
candidates = [f for f in in_dir.rglob("*") if f.is_file() and f.suffix.lower() in extensions and not f.name.endswith(".scaled_tmp.mkv")]
|
|
if candidates:
|
|
total_size = sum(f.stat().st_size for f in candidates)
|
|
reply = QMessageBox.question(
|
|
self, "Confirm Batch Delete",
|
|
f"This will delete {len(candidates)} source file(s) after successful transcode.\n"
|
|
f"Total size: {total_size / 1_073_741_824:.2f} GB\n\n"
|
|
f"Proceed?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No,
|
|
)
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
self._log("Cancelled: Delete not confirmed.")
|
|
return
|
|
|
|
# ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ──
|
|
use_ffmpeg_fallback = False
|
|
skip_encode = False
|
|
if self.env.av1an_path and self.env.ffmpeg_path:
|
|
self._log("Pre-flight: testing av1an + VapourSynth compatibility...")
|
|
QApplication.processEvents() # keep UI responsive
|
|
svt_name = self.env.av1an_flags.get("svt_name", "svt_av1")
|
|
ok, detail = _av1an_vsscript_smoke_test(
|
|
self.env.av1an_path,
|
|
self.env.ffmpeg_path,
|
|
self.env.av1an_flags,
|
|
svt_name=svt_name,
|
|
)
|
|
if not ok and "VSScript_API_INCOMPAT" in detail:
|
|
# VSScript ABI mismatch detected — offer rebuild or fallback
|
|
use_ffmpeg_fallback = self._handle_vs_incompat()
|
|
if not use_ffmpeg_fallback:
|
|
# User chose rebuild or cancel — don't start encoding
|
|
return
|
|
elif not ok and "INVALID_ENCODER" in detail:
|
|
# Encoder name detection was wrong — re-probe and retry once
|
|
self._log(f" WARN: Encoder name probe was incorrect. Re-detecting...")
|
|
QApplication.processEvents()
|
|
new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
|
|
if new_name:
|
|
self.env.av1an_flags["svt_name"] = new_name
|
|
self._log(f" Re-detected SVT-AV1 encoder name: '{new_name}'")
|
|
# Retry smoke test with corrected name
|
|
ok2, detail2 = _av1an_vsscript_smoke_test(
|
|
self.env.av1an_path, self.env.ffmpeg_path,
|
|
self.env.av1an_flags, svt_name=new_name,
|
|
)
|
|
if ok2:
|
|
self._log(" OK: av1an + VapourSynth working correctly.")
|
|
else:
|
|
self._log(f" FAIL: Still failing after re-detect: {detail2}")
|
|
return
|
|
else:
|
|
self._log(" FAIL: Could not determine valid encoder name. Check av1an --help manually.")
|
|
return
|
|
elif ok:
|
|
self._log(" OK: av1an + VapourSynth working correctly.")
|
|
else:
|
|
# Smoke test failed for an unexpected reason (encoder binary
|
|
# missing, concat method unsupported, av1an panicked, etc.).
|
|
# Previously this was logged as "non-fatal" and the encode
|
|
# proceeded anyway — which produced the "chunks but never
|
|
# saves a file" symptom because every file then failed at
|
|
# the same point. Now we treat unknown smoke failures as
|
|
# hard blocks and offer the user ffmpeg fallback if the
|
|
# selected codec is available, otherwise abort.
|
|
self._log(f" FAIL: av1an smoke test failed:")
|
|
for line in detail.splitlines()[:12]:
|
|
self._log(f" {line}")
|
|
# If ffmpeg has the matching encoder, offer fallback;
|
|
# otherwise abort with an actionable message.
|
|
codec_idx_pre = self.codec_combo.currentIndex()
|
|
if 0 <= codec_idx_pre < len(VIDEO_CODECS):
|
|
vc = VIDEO_CODECS[codec_idx_pre]
|
|
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
|
if self.env.ffmpeg_libs.get(lib_key, False):
|
|
self._log(f" FFmpeg has {vc.ffmpeg_encoder} — offering fallback.")
|
|
use_ffmpeg_fallback = self._handle_vs_incompat()
|
|
if not use_ffmpeg_fallback:
|
|
return
|
|
else:
|
|
self._log(
|
|
f" ABORT: ffmpeg also lacks {vc.ffmpeg_encoder}. "
|
|
f"Install the encoder binary (e.g. SvtAv1EncApp, vpxenc, x265) "
|
|
f"or use the REBUILD FROM GIT button."
|
|
)
|
|
return
|
|
else:
|
|
self._log(" ABORT: invalid codec selection.")
|
|
return
|
|
|
|
codec_idx = self.codec_combo.currentIndex()
|
|
audio_idx = self.audio_combo.currentIndex()
|
|
container_idx = self.container_combo.currentIndex()
|
|
|
|
# Safety: clamp codec_idx to valid range
|
|
if not (0 <= codec_idx < len(VIDEO_CODECS)):
|
|
self._log(f"ERROR: Invalid codec index {codec_idx}. Resetting to AV1 (SVT-AV1).")
|
|
codec_idx = 0
|
|
self.codec_combo.blockSignals(True)
|
|
self.codec_combo.setCurrentIndex(0)
|
|
self.codec_combo.blockSignals(False)
|
|
|
|
selected_codec = VIDEO_CODECS[codec_idx]
|
|
self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})")
|
|
|
|
self.worker = EncoderWorker(
|
|
in_dir=in_dir,
|
|
out_dir=out_dir,
|
|
video_codec=selected_codec,
|
|
audio_profile=AUDIO_PROFILES[audio_idx],
|
|
container=CONTAINER_PROFILES[container_idx],
|
|
crf=self.crf_knob.intValue(),
|
|
preset_label=self.preset_combo.currentText(),
|
|
delete_source=self.del_check.isChecked(),
|
|
env=self.env,
|
|
extensions=self._parse_extensions(),
|
|
resolution=self._get_current_resolution(),
|
|
audio_level_db=self.vol_knob.value(),
|
|
use_ffmpeg_fallback=use_ffmpeg_fallback,
|
|
subtitle_lang=SUBTITLE_OPTIONS[self.subs_combo.currentIndex()][1],
|
|
force=self.force_check.isChecked(), # v5-01
|
|
)
|
|
self.worker.log_msg.connect(self._log)
|
|
self.worker.progress_msg.connect(self._on_progress)
|
|
self.worker.finished_queue.connect(self._on_finished)
|
|
|
|
self.btn_run.setEnabled(False)
|
|
self.btn_run.setText("RUNNING...")
|
|
self.btn_stop.setEnabled(True)
|
|
self.btn_rebuild.setEnabled(False)
|
|
self.worker.start()
|
|
|
|
def _handle_vs_incompat(self) -> bool:
|
|
"""Handle detected VSScript ABI incompatibility.
|
|
|
|
Shows a dialog with options:
|
|
1. Rebuild VapourSynth + av1an from git (resolves root cause)
|
|
2. Use ffmpeg fallback (works now, no chunk-parallel)
|
|
3. Cancel
|
|
|
|
Returns True if we should use ffmpeg fallback (option 2),
|
|
False if user cancelled or chose to rebuild (rebuild
|
|
starts async and does NOT return here — the user
|
|
will click ENCODE again after it completes).
|
|
"""
|
|
self._log(" FAIL: av1an cannot initialize VSScript API.")
|
|
self._log(" The av1an binary was compiled against a different VapourSynth version.")
|
|
|
|
# Check ffmpeg fallback availability
|
|
codec_idx = self.codec_combo.currentIndex()
|
|
video_codec = VIDEO_CODECS[codec_idx]
|
|
ffmpeg_enc = video_codec.ffmpeg_encoder
|
|
ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc) # v3: OTC-007
|
|
fallback_possible = self.env.ffmpeg_libs.get(ffmpeg_lib_key, False)
|
|
|
|
if fallback_possible:
|
|
btn_rebuild = QPushButton(" Rebuild from Git ")
|
|
btn_rebuild.setObjectName("btnRebuild")
|
|
btn_fallback = QPushButton(" Use ffmpeg Fallback ")
|
|
btn_fallback.setObjectName("btnRun")
|
|
btn_cancel = QPushButton(" Cancel ")
|
|
btn_cancel.setObjectName("btnStop")
|
|
|
|
dlg = QMessageBox(self)
|
|
dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
|
|
dlg.setText(
|
|
"av1an cannot initialize VapourSynth — the installed versions\n"
|
|
"have an ABI incompatibility (common with distro packages).\n\n"
|
|
f"Choose how to proceed:"
|
|
)
|
|
dlg.setInformativeText(
|
|
"• Rebuild from Git — compiles both from source (~10-30 min).\n"
|
|
" Fixes the root cause. Requires sudo for install.\n"
|
|
f"• ffmpeg Fallback — encode with ffmpeg ({ffmpeg_enc}) now.\n"
|
|
" No chunk-parallel mode but output quality is identical."
|
|
)
|
|
dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
|
|
dlg.addButton(btn_fallback, QMessageBox.ButtonRole.YesRole)
|
|
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
|
|
|
dlg.exec()
|
|
clicked = dlg.clickedButton()
|
|
|
|
if clicked == btn_rebuild:
|
|
self._log("")
|
|
self._log("User chose: Rebuild VapourSynth + av1an from git.")
|
|
self._start_git_rebuild()
|
|
return False # don't start encoding — user will retry after build
|
|
elif clicked == btn_fallback:
|
|
self._log("")
|
|
self._log(f"FALLBACK: Switching to pure ffmpeg ({ffmpeg_enc}) encoding.")
|
|
self._log(
|
|
" Note: ffmpeg single-pass mode (no chunk-parallel). "
|
|
"Slower for large files but produces identical output."
|
|
)
|
|
self._log(" Use the REBUILD FROM GIT button to fix av1an for chunk-parallel mode.")
|
|
self._log("")
|
|
return True
|
|
else:
|
|
# Cancel
|
|
self._log("Cancelled by user.")
|
|
return False
|
|
else:
|
|
# No ffmpeg fallback available — offer rebuild or hard cancel
|
|
btn_rebuild = QPushButton(" Rebuild from Git ")
|
|
btn_rebuild.setObjectName("btnRebuild")
|
|
btn_cancel = QPushButton(" Cancel ")
|
|
btn_cancel.setObjectName("btnStop")
|
|
|
|
dlg = QMessageBox(self)
|
|
dlg.setWindowTitle("av1an + VapourSynth Version Mismatch")
|
|
dlg.setText(
|
|
"av1an cannot initialize VapourSynth — ABI incompatibility.\n\n"
|
|
f"ffmpeg also lacks '{ffmpeg_enc}' — no fallback possible.\n"
|
|
"You must rebuild to proceed."
|
|
)
|
|
dlg.setIcon(QMessageBox.Icon.Critical)
|
|
dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole)
|
|
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
|
|
|
dlg.exec()
|
|
clicked = dlg.clickedButton()
|
|
|
|
if clicked == btn_rebuild:
|
|
self._log("")
|
|
self._log("User chose: Rebuild VapourSynth + av1an from git (no fallback available).")
|
|
self._start_git_rebuild()
|
|
else:
|
|
self._log("Cancelled by user.")
|
|
return False
|
|
|
|
def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True,
|
|
build_ffmpeg_iamf: bool = False):
|
|
"""Start the SourceBuildWorker thread."""
|
|
components = []
|
|
if build_vs: components.append("VapourSynth")
|
|
if build_av1an: components.append("av1an")
|
|
if build_ffmpeg_iamf: components.append("ffmpeg+libiamf")
|
|
self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...")
|
|
self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.")
|
|
if build_ffmpeg_iamf:
|
|
self._log(" NOTE: ffmpeg build takes 10-20 min. App must be restarted after.")
|
|
self.btn_run.setEnabled(False)
|
|
self.btn_rebuild.setEnabled(False)
|
|
self.btn_stop.setEnabled(False)
|
|
self.status_label.setText("Building from git... (see log)")
|
|
|
|
self._build_worker = SourceBuildWorker(
|
|
build_vs=build_vs, build_av1an=build_av1an,
|
|
build_ffmpeg_iamf=build_ffmpeg_iamf,
|
|
)
|
|
self._build_worker.log_msg.connect(self._log)
|
|
self._build_worker.build_done.connect(self._on_build_done)
|
|
self._build_worker.start()
|
|
|
|
@Slot(bool, str)
|
|
def _on_build_done(self, success: bool, message: str):
|
|
"""Called when SourceBuildWorker finishes."""
|
|
self._log("")
|
|
if success:
|
|
self._log(f"BUILD SUCCESS: {message}")
|
|
self._log("Re-probing environment to pick up new binaries...")
|
|
QApplication.processEvents()
|
|
|
|
# Ensure LD_LIBRARY_PATH is set in the main process too.
|
|
#
|
|
# INTENTIONAL os.environ mutation (the ONE kept after the
|
|
# v3-08 refactor). SourceBuildWorker no longer mutates
|
|
# os.environ — it accumulates env changes in its private
|
|
# self._build_env dict and passes that to subprocess.run.
|
|
# But that dict dies with the worker thread. The UI thread
|
|
# must update its OWN os.environ so the next
|
|
# probe_environment() call — which spawns ffmpeg/av1an
|
|
# subprocesses that inherit os.environ — can dlopen the
|
|
# freshly-built VapourSynth / libiamf shared libraries from
|
|
# ~/.local/lib. Without this, the rebuilt binaries would
|
|
# fail to load their dependent libs.
|
|
local_lib = str(Path.home() / ".local" / "lib")
|
|
existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
|
|
if local_lib not in existing_ld:
|
|
os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":")
|
|
|
|
# Re-probe environment with fresh data
|
|
self.env = probe_environment()
|
|
|
|
# Run smoke test again to verify the fix
|
|
if self.env.av1an_path and self.env.ffmpeg_path:
|
|
svt_name = self.env.av1an_flags.get("svt_name", "svt_av1")
|
|
ok, detail = _av1an_vsscript_smoke_test(
|
|
self.env.av1an_path,
|
|
self.env.ffmpeg_path,
|
|
self.env.av1an_flags,
|
|
svt_name=svt_name,
|
|
)
|
|
if ok:
|
|
self._log("VERIFIED: av1an + VapourSynth now working correctly!")
|
|
self._log("Click START PROCESSING to encode.")
|
|
elif "INVALID_ENCODER" in detail:
|
|
# Re-probe encoder name with the fresh binary
|
|
self._log(" Re-detecting encoder name from fresh build...")
|
|
new_name = _detect_av1an_svt_encoder(self.env.av1an_path)
|
|
if new_name and new_name != svt_name:
|
|
self.env.av1an_flags["svt_name"] = new_name
|
|
self._log(f" Corrected encoder name: '{svt_name}' -> '{new_name}'")
|
|
ok2, detail2 = _av1an_vsscript_smoke_test(
|
|
self.env.av1an_path, self.env.ffmpeg_path,
|
|
self.env.av1an_flags, svt_name=new_name,
|
|
)
|
|
if ok2:
|
|
self._log("VERIFIED: av1an + VapourSynth now working correctly!")
|
|
self._log("Click START PROCESSING to encode.")
|
|
else:
|
|
self._log(f"WARNING: Smoke test still fails: {detail2}")
|
|
else:
|
|
self._log(f"WARNING: Could not auto-fix encoder name. Smoke test: {detail}")
|
|
else:
|
|
self._log(f"WARNING: Build completed but smoke test still fails: {detail}")
|
|
self._log("You may need to log out/in or restart the app for library changes to take effect.")
|
|
|
|
# Update status bar
|
|
distro = self.env.distro
|
|
cpu = self.env.cpu
|
|
vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else ""
|
|
fb_encs = []
|
|
for vc in VIDEO_CODECS:
|
|
lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007
|
|
if self.env.ffmpeg_libs.get(lib_key, False):
|
|
fb_encs.append(vc.ffmpeg_encoder)
|
|
fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else ""
|
|
self.status_label.setText(
|
|
f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | "
|
|
f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}"
|
|
)
|
|
else:
|
|
self._log(f"BUILD FAILED: {message}")
|
|
self._log("Try running the build manually in a terminal, or use ffmpeg fallback.")
|
|
self.status_label.setText("Build failed — check log")
|
|
|
|
self.btn_run.setEnabled(True)
|
|
self.btn_rebuild.setEnabled(True)
|
|
|
|
@Slot()
|
|
def _manual_rebuild(self):
|
|
"""Handle the REBUILD FROM GIT button click (manual trigger)."""
|
|
btn_vs_av1an = QPushButton(" VapourSynth + av1an ")
|
|
btn_vs_av1an.setObjectName("btnRebuild")
|
|
btn_vs_only = QPushButton(" VapourSynth only ")
|
|
btn_vs_only.setObjectName("btnRebuild")
|
|
btn_av1an_only = QPushButton(" av1an only ")
|
|
btn_av1an_only.setObjectName("btnRebuild")
|
|
btn_ffmpeg_iamf = QPushButton(" ffmpeg + IAMF ")
|
|
btn_ffmpeg_iamf.setObjectName("btnRebuild")
|
|
btn_cancel = QPushButton(" Cancel ")
|
|
btn_cancel.setObjectName("btnStop")
|
|
|
|
dlg = QMessageBox(self)
|
|
dlg.setWindowTitle("Rebuild from Git")
|
|
dlg.setText(
|
|
"Select which components to rebuild from git source.\n\n"
|
|
"• VapourSynth — installs to ~/.local (needs sudo for build deps)\n"
|
|
"• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n"
|
|
"• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n"
|
|
" installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n"
|
|
" Required to use the IAMF audio codec. ~10-20 min build time.\n\n"
|
|
"Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min"
|
|
)
|
|
dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole)
|
|
dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole)
|
|
dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole)
|
|
dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole)
|
|
dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole)
|
|
|
|
dlg.exec()
|
|
clicked = dlg.clickedButton()
|
|
|
|
if clicked == btn_vs_av1an:
|
|
self._start_git_rebuild(build_vs=True, build_av1an=True)
|
|
elif clicked == btn_vs_only:
|
|
self._start_git_rebuild(build_vs=True, build_av1an=False)
|
|
elif clicked == btn_av1an_only:
|
|
self._start_git_rebuild(build_vs=False, build_av1an=True)
|
|
elif clicked == btn_ffmpeg_iamf:
|
|
self._start_git_rebuild(build_vs=False, build_av1an=False,
|
|
build_ffmpeg_iamf=True)
|
|
|
|
@Slot(str, int, int)
|
|
def _on_progress(self, filename: str, current: int, total: int):
|
|
self.status_label.setText(f"Processing {current}/{total}: {filename}")
|
|
|
|
@Slot(int, int)
|
|
def _on_finished(self, ok: int, fail: int):
|
|
self.btn_run.setEnabled(True)
|
|
self.btn_run.setText("START PROCESSING")
|
|
self.btn_stop.setEnabled(False)
|
|
self.status_label.setText(f"Done — {ok} succeeded, {fail} failed")
|
|
|
|
if fail > 0:
|
|
self._log(f"WARNING: {fail} file(s) failed. Check log above for details.")
|
|
if ok > 0:
|
|
self._log(f"All {ok} file(s) archived successfully.")
|
|
|
|
@Slot()
|
|
def _stop_process(self):
|
|
if self.worker and self.worker.isRunning():
|
|
self._log("STOP: Exiting queue after current file finishes...")
|
|
self.worker.stop()
|
|
self.btn_stop.setEnabled(False)
|
|
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────
|
|
# GUI ENTRY POINT
|
|
# ──────────────────────────────────────────────
|
|
|
|
def launch_gui(argv: list[str] | None = None, force: bool = False,
|
|
chunk_method: str | None = None) -> int:
|
|
"""Create the QApplication, show the OpenCodecMaster window, run the Qt event loop.
|
|
|
|
This is the GUI entry point invoked by ``cli.main()`` when no
|
|
``--version`` / ``--dry-run`` / ``--verify-only`` flag is given,
|
|
and by ``python -m opentranscode``. Returns the Qt event-loop
|
|
exit code (0 on clean shutdown).
|
|
|
|
v5-01: *force* pre-checks the "Force (skip validation)" checkbox
|
|
in the UI. This is a convenience for users who want to skip ffprobe
|
|
validation on launch (e.g. for the rare edge case where ffprobe
|
|
fails but the file is actually valid). The checkbox can still be
|
|
toggled manually in the UI.
|
|
|
|
v4.0.0: *chunk_method* overrides av1an's chunk-method selection.
|
|
When not None, the value is written to ``env.av1an_flags[
|
|
"chunk_method_override"]`` after the environment probe runs.
|
|
"auto" clears any override the probe set; other values ("select",
|
|
"hybrid", "ffms2", etc.) force that method. Useful for forcing
|
|
"select" to avoid the Hybrid chunk method's failure on phone-
|
|
recorded MP4s with sparse keyframes (the "works up until near the
|
|
end, never saves chunks into a full file" bug).
|
|
|
|
Equivalent to the v3 ``if __name__ == "__main__":`` block.
|
|
"""
|
|
app = QApplication(sys.argv if argv is None else argv)
|
|
window = OpenCodecMaster()
|
|
if force and hasattr(window, "force_check"):
|
|
window.force_check.setChecked(True)
|
|
# v4.0.0: apply --chunk-method override AFTER the window's env probe
|
|
# has run (in __init__). The override is written to env.av1an_flags
|
|
# so every EncoderWorker spawned from this point picks it up.
|
|
if chunk_method is not None and hasattr(window, "env"):
|
|
if chunk_method == "auto":
|
|
window.env.av1an_flags.pop("chunk_method_override", None)
|
|
window._log(f"CLI override: chunk method = auto (cleared probe setting)")
|
|
else:
|
|
window.env.av1an_flags["chunk_method_override"] = chunk_method
|
|
window._log(f"CLI override: chunk method = {chunk_method}")
|
|
window.show()
|
|
return sys.exit(app.exec())
|