Initial Commit
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
Create a Python desktop application using PySide6 called "VFX Pull Processor".
|
||||
|
||||
Purpose:
|
||||
This application scans one or more selected folders recursively for EXR image sequences, then uses Nuke in terminal mode to render proxy movies using a template Nuke script.
|
||||
|
||||
Architecture:
|
||||
|
||||
- PySide6 GUI
|
||||
- Separate modules
|
||||
- app.py
|
||||
- gui.py
|
||||
- scanner.py
|
||||
- parser.py
|
||||
- jobs.py
|
||||
- nuke_runner.py
|
||||
- process.py (runs inside Nuke)
|
||||
- config.json
|
||||
|
||||
The GUI should contain:
|
||||
|
||||
------------------------------------------------
|
||||
VFX Pull Processor
|
||||
|
||||
Pull Folder
|
||||
[_________________________] [Browse]
|
||||
|
||||
Output Root
|
||||
[ V:\_FOOTAGE ] [Browse]
|
||||
|
||||
Nuke Executable
|
||||
[ C:\Program Files\Nuke16.0v1\Nuke16.0.exe ] [Browse]
|
||||
|
||||
Template Script
|
||||
[ proxy_template.nk ] [Browse]
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
Sequences Found
|
||||
|
||||
(QTableWidget)
|
||||
|
||||
Columns:
|
||||
|
||||
Checkbox
|
||||
Shot
|
||||
Frames
|
||||
Resolution
|
||||
Status
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
[ Scan ]
|
||||
|
||||
[ Render Selected ]
|
||||
|
||||
Progress Bar
|
||||
|
||||
Log Window
|
||||
|
||||
------------------------------------------------
|
||||
|
||||
Requirements
|
||||
|
||||
When Scan is pressed:
|
||||
|
||||
- recursively search every selected folder
|
||||
- identify EXR sequences
|
||||
- group frames into sequences
|
||||
- detect missing frames
|
||||
- determine first and last frame
|
||||
- populate the table
|
||||
|
||||
Filename format is always:
|
||||
|
||||
SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION.FRAME.exr
|
||||
|
||||
Example:
|
||||
|
||||
UNG_101_012_010_BG01_TT_v001.01001.exr
|
||||
|
||||
Parse into:
|
||||
|
||||
show = UNG
|
||||
episode = 101
|
||||
sequence = 012
|
||||
shot = 010
|
||||
element = BG01
|
||||
task = TT
|
||||
version = v001
|
||||
|
||||
Create a Shot object containing:
|
||||
|
||||
show
|
||||
episode
|
||||
sequence
|
||||
shot
|
||||
element
|
||||
task
|
||||
version
|
||||
basename
|
||||
firstFrame
|
||||
lastFrame
|
||||
inputSequence
|
||||
outputFolder
|
||||
outputMovie
|
||||
|
||||
basename should equal:
|
||||
|
||||
UNG_101_012_010_BG01_TT_v001
|
||||
|
||||
Output folder should be:
|
||||
|
||||
V:/_FOOTAGE/
|
||||
{show}/
|
||||
{show}_{episode}/
|
||||
{basename}/
|
||||
|
||||
Output files should be:
|
||||
|
||||
{basename}.exr
|
||||
{basename}.mov
|
||||
|
||||
When Render Selected is pressed:
|
||||
|
||||
Launch Nuke ONCE using:
|
||||
|
||||
Nuke.exe -t process.py jobs.json
|
||||
|
||||
Do NOT launch Nuke once per shot.
|
||||
|
||||
Instead:
|
||||
|
||||
- create jobs.json
|
||||
- pass it to process.py
|
||||
|
||||
process.py should:
|
||||
|
||||
- load proxy_template.nk
|
||||
- locate nodes named:
|
||||
Read_Input
|
||||
Write_Output
|
||||
- for each job:
|
||||
set Read_Input.file
|
||||
set Write_Output.file
|
||||
execute render
|
||||
- clear caches between jobs if needed
|
||||
- print progress to stdout
|
||||
|
||||
The GUI should capture stdout from Nuke and display it in the log window.
|
||||
|
||||
Design the code cleanly using dataclasses where appropriate.
|
||||
|
||||
Keep parsing logic, scanning logic, GUI, and Nuke execution completely separate.
|
||||
|
||||
The code should be easy to extend later for:
|
||||
- JPG proxies
|
||||
- PNG proxies
|
||||
- Contact sheets
|
||||
- Thumbnail generation
|
||||
- Validation reports
|
||||
|
||||
Do not put any pipeline logic inside the Nuke script. The Nuke script is only a visual template.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
app.py — Entry point for VFX Pull Processor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from gui import MainWindow
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("VFX Pull Processor")
|
||||
app.setOrganizationName("VFX")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pull_folder": "V:/_FOOTAGE/UNG/_PULLS/UNG_111_029_SERIES_EL01_TT_GATE_ADDITIONAL_v001",
|
||||
"output_root": "V:\\_FOOTAGE",
|
||||
"nuke_executable": "C:/Program Files/Nuke17.0v1/Nuke17.0.exe",
|
||||
"template_script": "proxy_template.nkind"
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
jobs.py — Build and serialise the jobs.json payload passed to process.py.
|
||||
|
||||
The jobs file is the sole interface between the GUI/host Python environment
|
||||
and the Nuke Python environment. Keeping it as plain JSON means process.py
|
||||
has no dependency on this project's other modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from parser import Shot
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_jobs_payload(shots: list[Shot], template_script: str) -> dict:
|
||||
"""Return the dict that will be serialised to jobs.json.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
shots:
|
||||
Shots selected for rendering.
|
||||
template_script:
|
||||
Absolute path to the Nuke template (.nk) file.
|
||||
"""
|
||||
return {
|
||||
"template_script": Path(template_script).as_posix(),
|
||||
"jobs": [_shot_to_job(s) for s in shots],
|
||||
}
|
||||
|
||||
|
||||
def write_jobs_file(payload: dict, path: str) -> None:
|
||||
"""Write *payload* to *path* as indented JSON."""
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, indent=2)
|
||||
|
||||
|
||||
def read_jobs_file(path: str) -> dict:
|
||||
"""Read and return the jobs payload from *path*."""
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Internal helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _shot_to_job(shot: Shot) -> dict:
|
||||
return {
|
||||
"basename": shot.basename,
|
||||
"input_sequence": shot.input_sequence,
|
||||
"first_frame": shot.first_frame,
|
||||
"last_frame": shot.last_frame,
|
||||
"output_folder": shot.output_folder,
|
||||
"output_movie": shot.output_movie,
|
||||
"output_exr": shot.output_exr,
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
nuke_runner.py — Launch Nuke in terminal mode and stream its output.
|
||||
|
||||
Nuke is started ONCE with:
|
||||
Nuke.exe -t process.py jobs.json
|
||||
|
||||
stdout/stderr are merged and forwarded line-by-line via Qt signals so the
|
||||
GUI log window updates in real time. Progress is inferred from the
|
||||
structured output lines that process.py prints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
# Lines emitted by process.py that carry progress information:
|
||||
# [3/10] Rendering: UNG_101_012_010_BG01_TT_v001
|
||||
# [3/10] Done: UNG_101_012_010_BG01_TT_v001
|
||||
_RENDERING_RE = re.compile(r"^\[(\d+)/(\d+)\]\s+Rendering:\s+(.+)$")
|
||||
_DONE_RE = re.compile(r"^\[(\d+)/(\d+)\]\s+Done:\s+(.+)$")
|
||||
|
||||
|
||||
class NukeRunner(QThread):
|
||||
"""Runs a single Nuke process and emits its output as Qt signals.
|
||||
|
||||
Signals
|
||||
-------
|
||||
log_message(str)
|
||||
Each non-empty output line from Nuke.
|
||||
progress_update(int, int)
|
||||
(jobs_done, jobs_total) whenever a job completes.
|
||||
job_started(str)
|
||||
basename of the job Nuke has just started rendering.
|
||||
job_done(str)
|
||||
basename of the job Nuke has just finished.
|
||||
finished_signal(bool)
|
||||
True if Nuke exited with code 0, False otherwise.
|
||||
"""
|
||||
|
||||
log_message: Signal = Signal(str)
|
||||
progress_update: Signal = Signal(int, int)
|
||||
job_started: Signal = Signal(str)
|
||||
job_done: Signal = Signal(str)
|
||||
finished_signal: Signal = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nuke_exe: str,
|
||||
process_script: str,
|
||||
jobs_file: str,
|
||||
total: int = 0,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.nuke_exe = nuke_exe
|
||||
self.process_script = process_script
|
||||
self.jobs_file = jobs_file
|
||||
self.total = total
|
||||
|
||||
def set_total(self, total: int) -> None:
|
||||
self.total = total
|
||||
|
||||
# ── Thread entry point ────────────────────────────────────────────
|
||||
|
||||
def run(self) -> None:
|
||||
cmd = [self.nuke_exe, "--indie", "-t", self.process_script, self.jobs_file]
|
||||
self.log_message.emit("Launching: " + " ".join(cmd))
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.log_message.emit(
|
||||
f"ERROR: Nuke executable not found: {self.nuke_exe}"
|
||||
)
|
||||
self.finished_signal.emit(False)
|
||||
return
|
||||
except OSError as exc:
|
||||
self.log_message.emit(f"ERROR: Could not launch Nuke: {exc}")
|
||||
self.finished_signal.emit(False)
|
||||
return
|
||||
|
||||
for raw_line in proc.stdout:
|
||||
line = raw_line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
self.log_message.emit(line)
|
||||
|
||||
m = _DONE_RE.match(line)
|
||||
if m:
|
||||
current, total, basename = int(m.group(1)), int(m.group(2)), m.group(3)
|
||||
self.total = total
|
||||
self.progress_update.emit(current, total)
|
||||
self.job_done.emit(basename.strip())
|
||||
continue
|
||||
|
||||
m = _RENDERING_RE.match(line)
|
||||
if m:
|
||||
basename = m.group(3)
|
||||
self.job_started.emit(basename.strip())
|
||||
|
||||
proc.wait()
|
||||
self.finished_signal.emit(proc.returncode == 0)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
parser.py — Filename parsing and Shot data model.
|
||||
|
||||
Filename format:
|
||||
SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION.FRAME.exr
|
||||
|
||||
Example:
|
||||
UNG_101_012_010_BG01_TT_v001.01001.exr
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Regex patterns
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Matches the basename (no frame, no extension):
|
||||
# SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION
|
||||
BASENAME_RE = re.compile(
|
||||
r"^(?P<show>[^_]+)_"
|
||||
r"(?P<episode>[^_]+)_"
|
||||
r"(?P<sequence>[^_]+)_"
|
||||
r"(?P<shot>[^_]+)_"
|
||||
r"(?P<element>[^_]+)_"
|
||||
r"(?P<task>[^_]+)_"
|
||||
r"(?P<version>v\d+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Matches a full EXR sequence filename: {basename}.{4-7 digit frame}.exr
|
||||
SEQ_FILE_RE = re.compile(r"^(.+)\.(\d{4,7})\.exr$", re.IGNORECASE)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data model
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Shot:
|
||||
show: str
|
||||
episode: str
|
||||
sequence: str
|
||||
shot: str
|
||||
element: str
|
||||
task: str
|
||||
version: str
|
||||
basename: str
|
||||
first_frame: int
|
||||
last_frame: int
|
||||
frame_count: int
|
||||
missing_frames: list[int] = field(default_factory=list)
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
input_sequence: str = "" # Nuke-style path, e.g. /path/basename.%05d.exr
|
||||
output_folder: str = ""
|
||||
output_movie: str = ""
|
||||
output_exr: str = ""
|
||||
status: str = "Ready"
|
||||
|
||||
@property
|
||||
def display_frames(self) -> str:
|
||||
s = f"{self.first_frame}\u2013{self.last_frame} ({self.frame_count}f)"
|
||||
if self.missing_frames:
|
||||
s += f" [{len(self.missing_frames)} missing]"
|
||||
return s
|
||||
|
||||
@property
|
||||
def display_resolution(self) -> str:
|
||||
return f"{self.width}\u00d7{self.height}" if self.width and self.height else "\u2014"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Parsing helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_basename(basename: str) -> dict | None:
|
||||
"""Return a dict of named fields for *basename*, or None if it does not match."""
|
||||
m = BASENAME_RE.match(basename)
|
||||
return m.groupdict() if m else None
|
||||
|
||||
|
||||
def build_shot(
|
||||
basename: str,
|
||||
source_dir: str,
|
||||
frames: list[int],
|
||||
output_root: str,
|
||||
parsed: dict,
|
||||
frame_padding: int = 4,
|
||||
) -> Shot:
|
||||
"""Construct a :class:`Shot` from parsed filename parts and frame list."""
|
||||
frames_sorted = sorted(frames)
|
||||
first = frames_sorted[0]
|
||||
last = frames_sorted[-1]
|
||||
missing = sorted(set(range(first, last + 1)) - set(frames_sorted))
|
||||
|
||||
show = parsed["show"]
|
||||
episode = parsed["episode"]
|
||||
|
||||
# Nuke requires forward slashes even on Windows
|
||||
input_dir = Path(source_dir).as_posix()
|
||||
input_seq = f"{input_dir}/{basename}.%0{frame_padding}d.exr"
|
||||
|
||||
out_folder = Path(output_root) / show / f"{show}_{episode}" / basename
|
||||
out_folder_str = out_folder.as_posix()
|
||||
|
||||
# Attempt to read resolution from the first frame of the sequence
|
||||
first_exr = Path(source_dir) / f"{basename}.{str(first).zfill(frame_padding)}.exr"
|
||||
width, height = read_exr_resolution(str(first_exr))
|
||||
|
||||
return Shot(
|
||||
show=show,
|
||||
episode=episode,
|
||||
sequence=parsed["sequence"],
|
||||
shot=parsed["shot"],
|
||||
element=parsed["element"],
|
||||
task=parsed["task"],
|
||||
version=parsed["version"],
|
||||
basename=basename,
|
||||
first_frame=first,
|
||||
last_frame=last,
|
||||
frame_count=len(frames_sorted),
|
||||
missing_frames=missing,
|
||||
width=width,
|
||||
height=height,
|
||||
input_sequence=input_seq,
|
||||
output_folder=out_folder_str,
|
||||
output_movie=f"{out_folder_str}/{basename}.mov",
|
||||
output_exr=f"{out_folder_str}/{basename}.%0{frame_padding}d.exr",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# EXR header reader (no external dependencies)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_EXR_MAGIC = b"\x76\x2f\x31\x01"
|
||||
|
||||
|
||||
def read_exr_resolution(filepath: str) -> tuple[int, int]:
|
||||
"""Read (width, height) from an OpenEXR file header without external libraries.
|
||||
|
||||
Returns (0, 0) if the file cannot be read or is not a valid EXR.
|
||||
"""
|
||||
try:
|
||||
with open(filepath, "rb") as fh:
|
||||
if fh.read(4) != _EXR_MAGIC:
|
||||
return 0, 0
|
||||
fh.read(4) # version + feature flags
|
||||
while True:
|
||||
name = _read_cstring(fh)
|
||||
if not name:
|
||||
break # end of header
|
||||
type_str = _read_cstring(fh)
|
||||
size = struct.unpack("<I", fh.read(4))[0]
|
||||
data = fh.read(size)
|
||||
if name == "dataWindow" and type_str == "box2i":
|
||||
xmin, ymin, xmax, ymax = struct.unpack("<iiii", data)
|
||||
return xmax - xmin + 1, ymax - ymin + 1
|
||||
except Exception:
|
||||
pass
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _read_cstring(fh) -> str:
|
||||
"""Read a null-terminated ASCII string from a binary file handle."""
|
||||
buf = bytearray()
|
||||
while True:
|
||||
c = fh.read(1)
|
||||
if not c or c == b"\x00":
|
||||
break
|
||||
buf.extend(c)
|
||||
return buf.decode("latin-1")
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
process.py — Nuke terminal-mode batch renderer.
|
||||
|
||||
This script runs INSIDE Nuke and must not import anything outside the
|
||||
Python standard library or Nuke's own modules.
|
||||
|
||||
Usage (called by nuke_runner.py):
|
||||
Nuke.exe -t process.py <jobs.json>
|
||||
|
||||
jobs.json schema
|
||||
----------------
|
||||
{
|
||||
"template_script": "/abs/path/to/proxy_template.nk",
|
||||
"jobs": [
|
||||
{
|
||||
"basename": "SHOW_EP_SEQ_SHOT_ELEM_TASK_v001",
|
||||
"input_sequence": "/path/SHOW_..._%05d.exr",
|
||||
"first_frame": 1001,
|
||||
"last_frame": 1100,
|
||||
"output_folder": "/output/SHOW/SHOW_EP/basename",
|
||||
"output_movie": "/output/.../basename.mov",
|
||||
"output_exr": "/output/.../basename.%05d.exr"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Expected template nodes
|
||||
-----------------------
|
||||
Read_Input — source EXR sequence
|
||||
Overlay — text burn-in; knob 'bottomleft' is set to the shot basename
|
||||
Write_4k — EXR sequence output (file = output_exr)
|
||||
Write_h264 — MOV / H.264 output (file = output_movie)
|
||||
|
||||
Both Write nodes are executed in a single nuke.execute() call so the
|
||||
graph is only evaluated once per frame.
|
||||
|
||||
Progress lines printed to stdout (parsed by nuke_runner.py):
|
||||
[1/10] Rendering: <basename>
|
||||
[1/10] Done: <basename>
|
||||
[1/10] ERROR: <basename>: <message>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
"""Print *msg* and flush immediately so the parent process receives it."""
|
||||
print(msg)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# ── Locate jobs file ──────────────────────────────────────────────
|
||||
# When launched as: Nuke.exe -t process.py jobs.json
|
||||
# sys.argv is typically: ['process.py', 'jobs.json']
|
||||
if len(sys.argv) < 2:
|
||||
_log("ERROR: Usage: Nuke.exe -t process.py <jobs.json>")
|
||||
sys.exit(1)
|
||||
|
||||
jobs_path = sys.argv[1]
|
||||
if not os.path.isfile(jobs_path):
|
||||
_log(f"ERROR: Jobs file not found: {jobs_path}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with open(jobs_path, encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except Exception as exc:
|
||||
_log(f"ERROR: Cannot parse jobs file: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
template_script = payload.get("template_script", "")
|
||||
jobs = payload.get("jobs", [])
|
||||
total = len(jobs)
|
||||
|
||||
if not jobs:
|
||||
_log("No jobs to process.")
|
||||
return
|
||||
|
||||
# ── Import Nuke ───────────────────────────────────────────────────
|
||||
try:
|
||||
import nuke # noqa: PLC0415
|
||||
except ImportError:
|
||||
_log("ERROR: 'nuke' module not available. This script must run inside Nuke.")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Open template ─────────────────────────────────────────────────
|
||||
if not os.path.isfile(template_script):
|
||||
_log(f"ERROR: Template script not found: {template_script}")
|
||||
sys.exit(1)
|
||||
|
||||
_log(f"Loading template: {template_script}")
|
||||
nuke.scriptOpen(template_script)
|
||||
|
||||
read_node = nuke.toNode("Read_Input")
|
||||
overlay_node = nuke.toNode("Overlay")
|
||||
write_4k = nuke.toNode("Write_4k")
|
||||
write_h264 = nuke.toNode("Write_h264")
|
||||
|
||||
if read_node is None:
|
||||
_log("ERROR: Node 'Read_Input' not found in template.")
|
||||
sys.exit(1)
|
||||
if write_4k is None:
|
||||
_log("ERROR: Node 'Write_4k' not found in template.")
|
||||
sys.exit(1)
|
||||
if write_h264 is None:
|
||||
_log("ERROR: Node 'Write_h264' not found in template.")
|
||||
sys.exit(1)
|
||||
if overlay_node is None:
|
||||
_log("WARNING: Node 'Overlay' not found in template — bottomleft will not be set.")
|
||||
|
||||
# ── Process jobs ──────────────────────────────────────────────────
|
||||
for idx, job in enumerate(jobs, start=1):
|
||||
basename = job.get("basename", f"job_{idx}")
|
||||
_log(f"[{idx}/{total}] Rendering: {basename}")
|
||||
|
||||
try:
|
||||
first = int(job["first_frame"])
|
||||
last = int(job["last_frame"])
|
||||
input_seq = job["input_sequence"]
|
||||
output_mov = job["output_movie"]
|
||||
output_exr = job["output_exr"]
|
||||
output_folder = job.get("output_folder", "")
|
||||
|
||||
# Ensure output directory exists
|
||||
target_dir = output_folder or os.path.dirname(output_mov)
|
||||
if target_dir:
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# Configure Read node
|
||||
read_node["file"].setValue(input_seq)
|
||||
read_node["first"].setValue(first)
|
||||
read_node["last"].setValue(last)
|
||||
read_node["origfirst"].setValue(first)
|
||||
read_node["origlast"].setValue(last)
|
||||
|
||||
# Set burn-in text on the Overlay node
|
||||
if overlay_node is not None:
|
||||
overlay_node["bottomleft"].setValue(basename)
|
||||
|
||||
# Configure both Write nodes
|
||||
write_4k["file"].setValue(output_exr)
|
||||
write_h264["file"].setValue(output_mov)
|
||||
|
||||
# Render both Write nodes (sequential calls; list form not supported in all versions)
|
||||
nuke.execute(write_4k, first, last, 1)
|
||||
nuke.execute(write_h264, first, last, 1)
|
||||
|
||||
_log(f"[{idx}/{total}] Done: {basename}")
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log(f"[{idx}/{total}] ERROR: {basename}: {exc}")
|
||||
|
||||
# Free memory caches between jobs to avoid RAM build-up
|
||||
for fn in ("clearRAMCache", "memory2", "memory"):
|
||||
func = getattr(nuke, fn, None)
|
||||
if func is not None:
|
||||
try:
|
||||
func("free")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
_log("All jobs complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
# proxy_template.nk
|
||||
#
|
||||
# VFX Pull Processor — Nuke proxy render template
|
||||
#
|
||||
# process.py sets the following knobs at render time:
|
||||
# Read_Input.file, Read_Input.first, Read_Input.last,
|
||||
# Read_Input.origfirst, Read_Input.origlast
|
||||
# Write_Output.file
|
||||
#
|
||||
# You may insert any intermediate nodes (grades, reformats, LUT transforms,
|
||||
# etc.) between Read_Input and Write_Output. process.py only touches the
|
||||
# two named nodes, so your pipeline can be as complex as needed.
|
||||
#
|
||||
# Codec / format notes:
|
||||
# The Write_Output node below is pre-set to Apple ProRes 422 HQ (apch)
|
||||
# inside a QuickTime container. Change "codec" to suit your facility:
|
||||
# apco = ProRes 422 Proxy
|
||||
# apcs = ProRes 422 LT
|
||||
# apcn = ProRes 422
|
||||
# apch = ProRes 422 HQ
|
||||
# ap4h = ProRes 4444
|
||||
# hap1 = HAP
|
||||
# jpeg = Photo-JPEG
|
||||
# On Windows you may need mov64 instead of mov32 — swap the codec
|
||||
# knob accordingly.
|
||||
#
|
||||
Root {
|
||||
inputs 0
|
||||
name proxy_template.nk
|
||||
first_frame 1001
|
||||
last_frame 1100
|
||||
fps 24
|
||||
lock_range false
|
||||
colorManagement Nuke
|
||||
format "2048 1152 0 0 2048 1152 1 2K_Super_35(full-ap)"
|
||||
}
|
||||
Read {
|
||||
inputs 0
|
||||
file ""
|
||||
name Read_Input
|
||||
first 1001
|
||||
last 1100
|
||||
origfirst 1001
|
||||
origlast 1100
|
||||
on_error nearest
|
||||
}
|
||||
Write {
|
||||
file ""
|
||||
file_type mov
|
||||
codec apch
|
||||
mov32_codec apch
|
||||
name Write_Output
|
||||
create_directories true
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+130
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
scanner.py — Recursive EXR sequence discovery.
|
||||
|
||||
Uses a background QThread so the GUI stays responsive during scanning.
|
||||
The scan logic itself (_scan_folder) is kept free of Qt types so it can
|
||||
be tested independently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from parser import SEQ_FILE_RE, Shot, build_shot, parse_basename
|
||||
|
||||
|
||||
class ScanWorker(QThread):
|
||||
"""Scans one or more folders for EXR sequences in a background thread.
|
||||
|
||||
Signals
|
||||
-------
|
||||
log_message(str)
|
||||
Informational text suitable for display in a log window.
|
||||
scan_complete(list[Shot])
|
||||
Emitted when scanning finishes (always, even on error).
|
||||
"""
|
||||
|
||||
log_message: Signal = Signal(str)
|
||||
scan_complete: Signal = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
folders: list[str],
|
||||
output_root: str,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.folders = folders
|
||||
self.output_root = output_root
|
||||
|
||||
def run(self) -> None:
|
||||
shots: list[Shot] = []
|
||||
try:
|
||||
for folder in self.folders:
|
||||
self.log_message.emit(f"Scanning: {folder}")
|
||||
found = scan_folder(folder, self.output_root, self.log_message.emit)
|
||||
shots.extend(found)
|
||||
except Exception as exc:
|
||||
self.log_message.emit(f"ERROR during scan: {exc}")
|
||||
finally:
|
||||
self.log_message.emit(f"Scan complete — {len(shots)} sequence(s) found.")
|
||||
self.scan_complete.emit(shots)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Core scan logic (Qt-free, testable standalone)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def scan_folder(
|
||||
root: str,
|
||||
output_root: str,
|
||||
log: Optional[Callable[[str], None]] = None,
|
||||
) -> list[Shot]:
|
||||
"""Recursively scan *root* for EXR sequences.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
root:
|
||||
Top-level directory to walk.
|
||||
output_root:
|
||||
Base path used to derive output folder/movie/exr paths on each Shot.
|
||||
log:
|
||||
Optional callable that receives progress strings (e.g. ``print``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[Shot]
|
||||
One Shot per discovered sequence, sorted by basename.
|
||||
"""
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log:
|
||||
log(msg)
|
||||
|
||||
# Collect files: dir → basename → [(frame_int, frame_str)]
|
||||
dir_seqs: dict[str, dict[str, list[tuple[int, str]]]] = defaultdict(
|
||||
lambda: defaultdict(list)
|
||||
)
|
||||
|
||||
for dirpath, _dirs, files in os.walk(root):
|
||||
for fname in files:
|
||||
m = SEQ_FILE_RE.match(fname)
|
||||
if not m:
|
||||
continue
|
||||
basename, frame_str = m.group(1), m.group(2)
|
||||
dir_seqs[dirpath][basename].append((int(frame_str), frame_str))
|
||||
|
||||
shots: list[Shot] = []
|
||||
|
||||
for dirpath in sorted(dir_seqs):
|
||||
for basename in sorted(dir_seqs[dirpath]):
|
||||
parsed = parse_basename(basename)
|
||||
if parsed is None:
|
||||
_log(f" Skipping (unrecognised name): {basename}")
|
||||
continue
|
||||
|
||||
frame_list = dir_seqs[dirpath][basename]
|
||||
frames = [f for f, _ in frame_list]
|
||||
# Detect padding length from the first filename encountered
|
||||
frame_padding = len(frame_list[0][1])
|
||||
|
||||
_log(
|
||||
f" Found: {basename} "
|
||||
f"({len(frames)} frames, padding={frame_padding})"
|
||||
)
|
||||
|
||||
shot = build_shot(
|
||||
basename=basename,
|
||||
source_dir=dirpath,
|
||||
frames=frames,
|
||||
output_root=output_root,
|
||||
parsed=parsed,
|
||||
frame_padding=frame_padding,
|
||||
)
|
||||
shots.append(shot)
|
||||
|
||||
return shots
|
||||
Reference in New Issue
Block a user