Files
VFXProxy/gui.py
T
twotalesanimation 1a2ece6f12 Initial Commit
2026-07-04 15:54:19 +02:00

945 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
gui.py — PySide6 main window for VFX Pull Processor.
Dark, Nuke-adjacent theme with a single amber accent.
All pipeline behaviour (scan, jobs file, Nuke launch) is unchanged from
the original implementation — this module only changes presentation:
* top bar: pull folder + Scan, with rarely-used paths in a
collapsible settings panel behind the gear button
* sequences table with colour-coded status pills and a
missing-frames warning in the Frames column
* primary Render button with a live selection count
* progress bar with a "n of m · shot" label
* collapsible, timestamped, colour-coded log that auto-opens
on the first error
* drag-and-drop a folder anywhere on the window to set Pull Folder
"""
from __future__ import annotations
import html
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QFileDialog,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QProgressBar,
QPushButton,
QSizePolicy,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from jobs import build_jobs_payload, write_jobs_file
from nuke_runner import NukeRunner
from parser import Shot
from scanner import ScanWorker
# ──────────────────────────────────────────────────────────────────────────────
# Palette / stylesheet
# ──────────────────────────────────────────────────────────────────────────────
ACCENT = "#f5a623"
ACCENT_HOVER = "#ffb840"
COL_TEXT = "#e4e6e8"
COL_DIM = "#9a9ea3"
COL_FAINT = "#6b6f74"
COL_GREEN = "#4caf7d"
COL_BLUE = "#4a9eda"
COL_AMBER = "#e0a83c"
COL_RED = "#e05c5c"
_QSS = f"""
QMainWindow {{
background-color: #232527;
}}
QWidget {{
color: {COL_TEXT};
font-size: 13px;
}}
QLabel {{
background: transparent;
}}
QLabel#sectionLabel {{
color: {COL_FAINT};
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
}}
QLabel#fieldLabel {{
color: {COL_DIM};
font-size: 12px;
}}
QLabel#hintLabel {{
color: {COL_FAINT};
font-size: 12px;
}}
QLineEdit {{
background-color: #1c1e20;
border: 1px solid #3a3d40;
border-radius: 6px;
padding: 7px 10px;
color: {COL_TEXT};
selection-background-color: {ACCENT};
selection-color: #1a1a1a;
}}
QLineEdit:focus {{
border-color: {ACCENT};
}}
QPushButton {{
background-color: #2b2d30;
border: 1px solid #3a3d40;
border-radius: 6px;
padding: 8px 18px;
color: {COL_TEXT};
}}
QPushButton:hover {{
background-color: #333639;
border-color: #46494d;
}}
QPushButton:pressed {{
background-color: #1c1e20;
}}
QPushButton:disabled {{
color: {COL_FAINT};
background-color: #26282a;
border-color: #313437;
}}
QPushButton#primaryButton {{
background-color: {ACCENT};
border: 1px solid {ACCENT};
color: #1a1a1a;
font-weight: 600;
padding: 9px 22px;
}}
QPushButton#primaryButton:hover {{
background-color: {ACCENT_HOVER};
border-color: {ACCENT_HOVER};
}}
QPushButton#primaryButton:disabled {{
background-color: #4d4a42;
border-color: #4d4a42;
color: #8a8a8a;
}}
QToolButton {{
background-color: #2b2d30;
border: 1px solid #3a3d40;
border-radius: 6px;
padding: 7px 10px;
color: {COL_DIM};
font-size: 14px;
}}
QToolButton:hover {{
background-color: #333639;
}}
QToolButton:checked {{
background-color: #333639;
border-color: {ACCENT};
color: {ACCENT};
}}
QToolButton#logToggle {{
background: transparent;
border: none;
color: {COL_DIM};
font-size: 12px;
font-weight: 600;
padding: 4px 6px;
}}
QToolButton#logToggle:hover {{
color: {COL_TEXT};
}}
QToolButton#logToggle:checked {{
background: transparent;
border: none;
color: {COL_TEXT};
}}
QFrame#card {{
background-color: #2b2d30;
border: 1px solid #313437;
border-radius: 8px;
}}
QFrame#settingsCard {{
background-color: #26282b;
border: 1px solid #313437;
border-radius: 8px;
}}
QTableWidget {{
background-color: #2b2d30;
alternate-background-color: #292b2e;
border: none;
gridline-color: transparent;
outline: none;
selection-background-color: #3a3d42;
selection-color: {COL_TEXT};
}}
QTableWidget::item {{
padding: 4px 10px;
border: none;
}}
QTableWidget::item:selected {{
background-color: #3a3d42;
}}
QTableWidget::indicator {{
width: 16px;
height: 16px;
border: 1px solid #4a4d51;
border-radius: 4px;
background-color: #1c1e20;
}}
QTableWidget::indicator:checked {{
background-color: {ACCENT};
border-color: {ACCENT};
}}
QHeaderView::section {{
background-color: #2b2d30;
color: {COL_FAINT};
border: none;
border-bottom: 1px solid #3a3d40;
padding: 8px 10px;
font-size: 11px;
font-weight: 600;
}}
QCheckBox {{
color: {COL_DIM};
font-size: 12px;
spacing: 7px;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 1px solid #4a4d51;
border-radius: 4px;
background-color: #1c1e20;
}}
QCheckBox::indicator:checked {{
background-color: {ACCENT};
border-color: {ACCENT};
}}
QProgressBar {{
background-color: #1c1e20;
border: none;
border-radius: 3px;
max-height: 6px;
text-align: center;
}}
QProgressBar::chunk {{
background-color: {ACCENT};
border-radius: 3px;
}}
QTextEdit#logView {{
background-color: #151617;
border: none;
border-top: 1px solid #313437;
color: {COL_DIM};
padding: 6px;
}}
QScrollBar:vertical {{
background: transparent;
width: 10px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background: #46494d;
border-radius: 5px;
min-height: 24px;
}}
QScrollBar::handle:vertical:hover {{
background: #55585c;
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: transparent;
}}
QMessageBox {{
background-color: #2b2d30;
}}
QToolTip {{
background-color: #1c1e20;
color: {COL_TEXT};
border: 1px solid #3a3d40;
padding: 4px 8px;
}}
"""
# Status → (text colour, pill background)
_BADGE_STYLES: dict[str, tuple[str, str]] = {
"Ready": (COL_DIM, "rgba(154, 158, 163, 30)"),
"Queued": (COL_AMBER, "rgba(224, 168, 60, 33)"),
"Rendering": (COL_BLUE, "rgba(74, 158, 218, 33)"),
"Done": (COL_GREEN, "rgba(76, 175, 125, 33)"),
"Failed": (COL_RED, "rgba(224, 92, 92, 33)"),
}
class StatusBadge(QLabel):
"""Small rounded pill showing a job status in its own colour."""
def __init__(self, status: str = "Ready", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status = status
self.set_status(status)
@property
def status(self) -> str:
return self._status
def set_status(self, status: str) -> None:
self._status = status
colour, bg = _BADGE_STYLES.get(status, _BADGE_STYLES["Ready"])
self.setText(("" if status == "Rendering" else "") + status)
self.setStyleSheet(
f"color: {colour}; background-color: {bg};"
"border-radius: 9px; padding: 2px 10px;"
"font-size: 11px; font-weight: 600;"
)
def _badge_cell(status: str) -> tuple[QWidget, StatusBadge]:
"""Wrap a StatusBadge in a container so it sits left-aligned in a cell."""
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(8, 3, 8, 3)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
badge = StatusBadge(status)
layout.addWidget(badge)
return container, badge
# ──────────────────────────────────────────────────────────────────────────────
# Config helpers
# ──────────────────────────────────────────────────────────────────────────────
_CONFIG_PATH = Path(__file__).parent / "config.json"
_DEFAULTS: dict = {
"pull_folder": "",
"output_root": r"V:\_FOOTAGE",
"nuke_executable": r"C:\Program Files\Nuke16.0v1\Nuke16.0.exe",
"template_script": str(Path(__file__).parent / "proxy_template.nk"),
}
def _load_config() -> dict:
if _CONFIG_PATH.exists():
try:
with _CONFIG_PATH.open(encoding="utf-8") as fh:
return {**_DEFAULTS, **json.load(fh)}
except Exception:
pass
return dict(_DEFAULTS)
def _save_config(cfg: dict) -> None:
try:
with _CONFIG_PATH.open("w", encoding="utf-8") as fh:
json.dump(cfg, fh, indent=2)
except Exception:
pass
# ──────────────────────────────────────────────────────────────────────────────
# Main window
# ──────────────────────────────────────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("VFX Pull Processor")
self.resize(1000, 800)
app = QApplication.instance()
if app is not None:
app.setStyle("Fusion")
self.setStyleSheet(_QSS)
self.setAcceptDrops(True)
self._shots: list[Shot] = []
self._basename_row: dict[str, int] = {}
self._badges: dict[str, StatusBadge] = {}
self._runner: NukeRunner | None = None
self._scan_worker: ScanWorker | None = None
self._config = _load_config()
self._updating_checks = False
self._error_count = 0
self._build_ui()
self._apply_config()
# ── UI construction ───────────────────────────────────────────────
def _build_ui(self) -> None:
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setSpacing(12)
root.setContentsMargins(16, 16, 16, 16)
root.addLayout(self._build_top_bar())
root.addWidget(self._build_settings_card())
root.addWidget(self._build_table_card(), stretch=1)
root.addLayout(self._build_action_bar())
root.addWidget(self._build_log_card())
def _build_top_bar(self) -> QHBoxLayout:
bar = QHBoxLayout()
bar.setSpacing(8)
lbl = QLabel("Pull Folder")
lbl.setObjectName("fieldLabel")
bar.addWidget(lbl)
self._pull_field = QLineEdit()
self._pull_field.setPlaceholderText(
"Folder to scan for EXR sequences — or drop a folder here…"
)
bar.addWidget(self._pull_field, stretch=1)
browse = QPushButton("Browse")
browse.clicked.connect(
lambda: self._browse(self._pull_field, is_dir=True, file_filter="")
)
bar.addWidget(browse)
self._scan_btn = QPushButton("Scan")
self._scan_btn.clicked.connect(self._on_scan)
bar.addWidget(self._scan_btn)
self._settings_btn = QToolButton()
self._settings_btn.setText("") # gear
self._settings_btn.setToolTip("Settings")
self._settings_btn.setCheckable(True)
self._settings_btn.toggled.connect(
lambda on: self._settings_card.setVisible(on)
)
bar.addWidget(self._settings_btn)
return bar
def _build_settings_card(self) -> QFrame:
self._settings_card = QFrame()
self._settings_card.setObjectName("settingsCard")
self._settings_card.setVisible(False)
layout = QVBoxLayout(self._settings_card)
layout.setSpacing(8)
layout.setContentsMargins(14, 12, 14, 12)
self._output_field = self._add_path_row(
layout, "Output Root", is_dir=True
)
self._nuke_field = self._add_path_row(
layout,
"Nuke Executable",
is_dir=False,
file_filter="Executables (*.exe);;All Files (*)",
)
self._tmpl_field = self._add_path_row(
layout,
"Template Script",
is_dir=False,
file_filter="Nuke Scripts (*.nk);;All Files (*)",
)
return self._settings_card
def _build_table_card(self) -> QFrame:
card = QFrame()
card.setObjectName("card")
layout = QVBoxLayout(card)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# Header bar
head = QHBoxLayout()
head.setContentsMargins(14, 10, 14, 10)
title = QLabel("SEQUENCES FOUND")
title.setObjectName("sectionLabel")
head.addWidget(title)
head.addStretch(1)
self._select_all = QCheckBox("Select all")
self._select_all.clicked.connect(self._on_select_all)
self._select_all.setVisible(False)
head.addWidget(self._select_all)
self._count_label = QLabel("")
self._count_label.setObjectName("hintLabel")
head.addWidget(self._count_label)
layout.addLayout(head)
# Stacked: empty state ↔ table
self._stack = QStackedWidget()
empty = QLabel(
"Choose a pull folder and hit Scan to find EXR sequences."
)
empty.setObjectName("hintLabel")
empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._stack.addWidget(empty)
self._table = QTableWidget(0, 5)
self._table.setHorizontalHeaderLabels(
["", "SHOT", "FRAMES", "RESOLUTION", "STATUS"]
)
hh = self._table.horizontalHeader()
hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
hh.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
hh.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
hh.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
hh.setHighlightSections(False)
self._table.setColumnWidth(0, 34)
self._table.setAlternatingRowColors(True)
self._table.setShowGrid(False)
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._table.verticalHeader().setVisible(False)
self._table.verticalHeader().setDefaultSectionSize(34)
self._table.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._table.itemChanged.connect(self._on_item_changed)
self._stack.addWidget(self._table)
self._stack.setCurrentIndex(0)
layout.addWidget(self._stack, stretch=1)
return card
def _build_action_bar(self) -> QHBoxLayout:
bar = QHBoxLayout()
bar.setSpacing(12)
self._render_btn = QPushButton("Render Selected")
self._render_btn.setObjectName("primaryButton")
self._render_btn.setEnabled(False)
self._render_btn.clicked.connect(self._on_render)
bar.addWidget(self._render_btn)
self._progress = QProgressBar()
self._progress.setTextVisible(False)
self._progress.setValue(0)
self._progress.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
bar.addWidget(self._progress, stretch=1)
self._progress_label = QLabel("")
self._progress_label.setObjectName("hintLabel")
self._progress_label.setMinimumWidth(220)
self._progress_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
bar.addWidget(self._progress_label)
return bar
def _build_log_card(self) -> QFrame:
card = QFrame()
card.setObjectName("card")
layout = QVBoxLayout(card)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
head = QHBoxLayout()
head.setContentsMargins(8, 4, 14, 4)
self._log_toggle = QToolButton()
self._log_toggle.setObjectName("logToggle")
self._log_toggle.setCheckable(True)
self._log_toggle.setChecked(False)
self._log_toggle.setText("▸ Log")
self._log_toggle.toggled.connect(self._on_log_toggled)
head.addWidget(self._log_toggle)
head.addStretch(1)
self._log_hint = QLabel("")
self._log_hint.setObjectName("hintLabel")
head.addWidget(self._log_hint)
layout.addLayout(head)
self._log = QTextEdit()
self._log.setObjectName("logView")
self._log.setReadOnly(True)
mono = QFont("Cascadia Code")
mono.setStyleHint(QFont.StyleHint.Monospace)
mono.setPointSize(9)
self._log.setFont(mono)
self._log.setFixedHeight(170)
self._log.setVisible(False)
layout.addWidget(self._log)
return card
def _add_path_row(
self,
layout: QVBoxLayout,
label: str,
is_dir: bool,
file_filter: str = "",
) -> QLineEdit:
"""Add a labelled path field + Browse button row to *layout*."""
row = QHBoxLayout()
row.setSpacing(8)
lbl = QLabel(label)
lbl.setObjectName("fieldLabel")
lbl.setFixedWidth(130)
row.addWidget(lbl)
edit = QLineEdit()
row.addWidget(edit)
btn = QPushButton("Browse")
# Use default-argument binding to capture loop variables correctly
btn.clicked.connect(
lambda _checked=False, e=edit, d=is_dir, f=file_filter:
self._browse(e, d, f)
)
row.addWidget(btn)
layout.addLayout(row)
return edit
# ── Drag & drop ───────────────────────────────────────────────────
def dragEnterEvent(self, event) -> None: # noqa: N802 (Qt naming)
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if os.path.isdir(url.toLocalFile()):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event) -> None: # noqa: N802 (Qt naming)
for url in event.mimeData().urls():
path = url.toLocalFile()
if os.path.isdir(path):
self._pull_field.setText(path)
self._save_config()
break
# ── Config / browsing ─────────────────────────────────────────────
def _browse(self, edit: QLineEdit, is_dir: bool, file_filter: str) -> None:
if is_dir:
path = QFileDialog.getExistingDirectory(
self, "Select Folder", edit.text()
)
else:
path, _ = QFileDialog.getOpenFileName(
self, "Select File", edit.text(), file_filter
)
if path:
edit.setText(path)
self._save_config()
def _apply_config(self) -> None:
self._pull_field.setText(self._config.get("pull_folder", ""))
self._output_field.setText(self._config.get("output_root", ""))
self._nuke_field.setText(self._config.get("nuke_executable", ""))
self._tmpl_field.setText(self._config.get("template_script", ""))
def _save_config(self) -> None:
self._config.update(
{
"pull_folder": self._pull_field.text(),
"output_root": self._output_field.text(),
"nuke_executable": self._nuke_field.text(),
"template_script": self._tmpl_field.text(),
}
)
_save_config(self._config)
# ── Scan ──────────────────────────────────────────────────────────
@Slot()
def _on_scan(self) -> None:
folder = self._pull_field.text().strip()
if not folder or not os.path.isdir(folder):
QMessageBox.warning(self, "Invalid Folder", "Please set a valid Pull Folder.")
return
output_root = self._output_field.text().strip() or r"V:\_FOOTAGE"
# Reset state
self._shots.clear()
self._basename_row.clear()
self._badges.clear()
self._table.setRowCount(0)
self._render_btn.setEnabled(False)
self._log.clear()
self._error_count = 0
self._log_hint.setText("")
self._scan_btn.setEnabled(False)
self._save_config()
# Indeterminate bar while scanning
self._progress.setRange(0, 0)
self._progress_label.setText("Scanning…")
self._scan_worker = ScanWorker([folder], output_root)
self._scan_worker.log_message.connect(self._append_log)
self._scan_worker.scan_complete.connect(self._on_scan_complete)
self._scan_worker.start()
@Slot(list)
def _on_scan_complete(self, shots: list) -> None:
self._shots = shots
self._updating_checks = True
self._table.setRowCount(len(shots))
for row, shot in enumerate(shots):
self._basename_row[shot.basename] = row
# Column 0 — checkbox
chk = QTableWidgetItem()
chk.setFlags(
Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
)
chk.setCheckState(Qt.CheckState.Checked)
self._table.setItem(row, 0, chk)
# Column 1 — shot basename
self._table.setItem(row, 1, QTableWidgetItem(shot.basename))
# Column 2 — frame range (amber when frames are missing)
frames_item = QTableWidgetItem(shot.display_frames)
if shot.missing_frames:
frames_item.setForeground(QColor(COL_AMBER))
frames_item.setToolTip(
f"Missing frames: {_summarise_frames(shot.missing_frames)}"
)
self._table.setItem(row, 2, frames_item)
# Column 3 — resolution
self._table.setItem(row, 3, QTableWidgetItem(shot.display_resolution))
# Column 4 — status badge
cell, badge = _badge_cell(shot.status)
self._badges[shot.basename] = badge
self._table.setCellWidget(row, 4, cell)
self._updating_checks = False
self._scan_btn.setEnabled(True)
self._progress.setRange(0, 1)
self._progress.setValue(0)
self._progress_label.setText(
f"{len(shots)} sequence(s) found" if shots else "No sequences found"
)
self._stack.setCurrentIndex(1 if shots else 0)
self._select_all.setVisible(bool(shots))
self._update_selection_ui()
# ── Selection bookkeeping ─────────────────────────────────────────
def _checked_rows(self) -> list[int]:
return [
row
for row in range(self._table.rowCount())
if self._table.item(row, 0)
and self._table.item(row, 0).checkState() == Qt.CheckState.Checked
]
@Slot(QTableWidgetItem)
def _on_item_changed(self, item: QTableWidgetItem) -> None:
if item.column() == 0 and not self._updating_checks:
self._update_selection_ui()
@Slot(bool)
def _on_select_all(self, checked: bool) -> None:
self._updating_checks = True
state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
for row in range(self._table.rowCount()):
item = self._table.item(row, 0)
if item:
item.setCheckState(state)
self._updating_checks = False
self._update_selection_ui()
def _update_selection_ui(self) -> None:
total = self._table.rowCount()
selected = len(self._checked_rows())
self._count_label.setText(
f"{total} sequence(s) · {selected} selected" if total else ""
)
self._select_all.setChecked(bool(total) and selected == total)
rendering = self._runner is not None and self._runner.isRunning()
self._render_btn.setText(
f"Render Selected ({selected})" if selected else "Render Selected"
)
self._render_btn.setEnabled(selected > 0 and not rendering)
# ── Render ────────────────────────────────────────────────────────
@Slot()
def _on_render(self) -> None:
selected = [self._shots[row] for row in self._checked_rows()]
if not selected:
QMessageBox.warning(self, "Nothing Selected", "No sequences are checked.")
return
nuke_exe = self._nuke_field.text().strip()
template = self._tmpl_field.text().strip()
if not os.path.isfile(nuke_exe):
QMessageBox.warning(
self,
"Nuke Not Found",
f"Nuke executable not found:\n{nuke_exe}",
)
return
if not os.path.isfile(template):
QMessageBox.warning(
self,
"Template Not Found",
f"Template script not found:\n{template}",
)
return
process_script = str(Path(__file__).parent / "process.py")
tmp_dir = tempfile.mkdtemp(prefix="vfxproxy_")
jobs_file = os.path.join(tmp_dir, "jobs.json")
payload = build_jobs_payload(selected, template)
write_jobs_file(payload, jobs_file)
self._append_log(f"Jobs file: {jobs_file} ({len(selected)} job(s))")
self._progress.setRange(0, len(selected))
self._progress.setValue(0)
self._progress_label.setText(f"0 of {len(selected)}")
for shot in selected:
self._set_row_status(shot.basename, "Queued")
self._runner = NukeRunner(
nuke_exe=nuke_exe,
process_script=process_script,
jobs_file=jobs_file,
total=len(selected),
)
self._runner.log_message.connect(self._append_log)
self._runner.progress_update.connect(self._on_progress)
self._runner.job_started.connect(self._on_job_started)
self._runner.job_done.connect(self._on_job_done)
self._runner.finished_signal.connect(self._on_render_finished)
self._scan_btn.setEnabled(False)
self._render_btn.setEnabled(False)
self._runner.start()
@Slot(int, int)
def _on_progress(self, current: int, total: int) -> None:
self._progress.setMaximum(total)
self._progress.setValue(current)
self._progress_label.setText(f"{current} of {total}")
@Slot(str)
def _on_job_started(self, basename: str) -> None:
self._set_row_status(basename, "Rendering")
label = basename if len(basename) <= 32 else basename[:31] + ""
self._progress_label.setText(
f"{self._progress.value()} of {self._progress.maximum()} · {label}"
)
@Slot(str)
def _on_job_done(self, basename: str) -> None:
self._set_row_status(basename, "Done")
@Slot(bool)
def _on_render_finished(self, success: bool) -> None:
self._scan_btn.setEnabled(True)
if success:
self._progress.setValue(self._progress.maximum())
self._progress_label.setText(
f"Complete · {self._progress.maximum()} job(s)"
)
self._append_log("── Render complete ──")
else:
# Anything still queued or mid-render did not make it
for basename, badge in self._badges.items():
if badge.status in ("Queued", "Rendering"):
self._set_row_status(basename, "Failed")
self._progress_label.setText("Finished with errors")
self._append_log("ERROR: Render finished with errors")
self._update_selection_ui()
# ── Helpers ───────────────────────────────────────────────────────
def _set_row_status(self, basename: str, status: str) -> None:
badge = self._badges.get(basename)
if badge is not None:
badge.set_status(status)
@Slot(bool)
def _on_log_toggled(self, open_: bool) -> None:
self._log.setVisible(open_)
self._log_toggle.setText(("" if open_ else "") + " Log")
def _append_log(self, msg: str) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
lower = msg.lower()
colour = COL_DIM
if "error" in lower or "traceback" in lower or "failed" in lower:
colour = COL_RED
self._error_count += 1
self._log_hint.setText(f"{self._error_count} error(s)")
self._log_hint.setStyleSheet(f"color: {COL_RED}; font-size: 12px;")
if not self._log_toggle.isChecked():
self._log_toggle.setChecked(True) # auto-open on first error
elif "done:" in lower or "complete" in lower:
colour = COL_GREEN
elif "missing" in lower or "skipping" in lower or "warning" in lower:
colour = COL_AMBER
self._log.append(
f'<span style="color:{COL_FAINT}">{stamp}</span>&nbsp;&nbsp;'
f'<span style="color:{colour}">{html.escape(msg)}</span>'
)
def _summarise_frames(frames: list[int], limit: int = 12) -> str:
"""Compact string for a sorted frame list, e.g. '11041106, 1210'."""
if not frames:
return ""
runs: list[tuple[int, int]] = []
start = prev = frames[0]
for f in frames[1:]:
if f == prev + 1:
prev = f
continue
runs.append((start, prev))
start = prev = f
runs.append((start, prev))
parts = [f"{a}" if a == b else f"{a}{b}" for a, b in runs[:limit]]
if len(runs) > limit:
parts.append("")
return ", ".join(parts)