945 lines
30 KiB
Python
945 lines
30 KiB
Python
"""
|
||
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> '
|
||
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. '1104–1106, 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)
|