@@ -0,0 +1,724 @@
|
||||
"""
|
||||
VFXReview Connector for Nuke
|
||||
Dockable PySide2 panel — Phase 1 + Phase 2
|
||||
|
||||
Install:
|
||||
Copy this file to:
|
||||
~/.nuke/VFXReviewConnector.py
|
||||
Add to ~/.nuke/menu.py:
|
||||
import VFXReviewConnector
|
||||
VFXReviewConnector.add_menu()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import nuke
|
||||
import nukescripts
|
||||
_IN_NUKE = True
|
||||
except ImportError:
|
||||
_IN_NUKE = False
|
||||
|
||||
try:
|
||||
from PySide2 import QtCore, QtGui, QtWidgets
|
||||
from PySide2.QtCore import Qt, Signal
|
||||
except ImportError:
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
# Edit these values for your facility / show.
|
||||
|
||||
TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4="
|
||||
BASE_URL = "https://review.twotalesvfx.com"
|
||||
PROJECT_CODE = "UNG_S1"
|
||||
|
||||
PROJECT_ROOT = "X:/shared_projects_2026/UNGO_VFX"
|
||||
FOOTAGE_ROOT = PROJECT_ROOT + "/production/plates"
|
||||
EXPORT_ROOT = PROJECT_ROOT + "/production/renders"
|
||||
PICLOCK_ROOT = PROJECT_ROOT + "/production/piclocks"
|
||||
NK_ROOT = PROJECT_ROOT + "/production/nuke"
|
||||
|
||||
OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio"
|
||||
|
||||
# ── API client ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VFXReviewAPI:
|
||||
"""Thin wrapper around the VFXReview HTTP API."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL, token: str = TOKEN, project_code: str = PROJECT_CODE):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.project_code = project_code
|
||||
|
||||
# ── Low-level ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
|
||||
"""Make a GET request and return parsed JSON."""
|
||||
url = self.base_url + path
|
||||
if params:
|
||||
query = "&".join(
|
||||
f"{urllib.parse.quote(k)}={urllib.parse.quote(str(v))}"
|
||||
for k, v in params.items()
|
||||
if v is not None
|
||||
)
|
||||
url = url + "?" + query
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise APIError(f"HTTP {exc.code} — {body}") from exc
|
||||
except Exception as exc:
|
||||
raise APIError(str(exc)) from exc
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_episodes(self) -> list[dict]:
|
||||
"""Return list of episode dicts for the project."""
|
||||
data = self._get(f"/api/ext/projects/{self.project_code}/episodes")
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("episodes", [])
|
||||
|
||||
def get_shots(self, episode: str | None = None, sequence: str | None = None) -> list[dict]:
|
||||
"""Return shots for the project, optionally filtered by episode / sequence."""
|
||||
params: dict[str, str] = {"limit": "500"}
|
||||
if episode:
|
||||
params["episode"] = episode
|
||||
if sequence:
|
||||
params["sequence"] = sequence
|
||||
data = self._get(f"/api/ext/projects/{self.project_code}/shots", params)
|
||||
return data.get("shots", [])
|
||||
|
||||
def lookup_shot(self, shot_code: str) -> dict | None:
|
||||
"""Return full shot metadata dict, or None if not found."""
|
||||
try:
|
||||
data = self._get("/api/ext/shots/lookup", {
|
||||
"shotCode": shot_code,
|
||||
"projectCode": self.project_code,
|
||||
})
|
||||
return data.get("shot") or data
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Return (ok, message) — used by the panel health-check."""
|
||||
try:
|
||||
self.get_episodes()
|
||||
return True, "Connected"
|
||||
except APIError as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
# ── urllib.parse shim — included so the module works without extra imports ────
|
||||
import urllib.parse # noqa: E402 (already in stdlib, just ensuring it's imported)
|
||||
|
||||
|
||||
# ── Nuke helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _safe(name: str) -> str:
|
||||
"""Return a Nuke-safe node name from an arbitrary string."""
|
||||
return re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||
|
||||
|
||||
def _find_node(name: str) -> "nuke.Node | None":
|
||||
"""Return the first node whose name starts with *name*, or None."""
|
||||
for node in nuke.allNodes():
|
||||
if node.name().startswith(name):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _create_backdrop(label: str, nodes: list, color: int = 0x2A2A3AFF,
|
||||
margin: int = 40) -> "nuke.Node":
|
||||
"""Create a backdrop that encloses *nodes*."""
|
||||
if not nodes:
|
||||
return None
|
||||
bd = nuke.nodes.BackdropNode()
|
||||
bd["label"].setValue(f"<b>{label}</b>")
|
||||
bd["note_font_size"].setValue(18)
|
||||
bd["tile_color"].setValue(color)
|
||||
|
||||
x_positions = [n.xpos() for n in nodes]
|
||||
y_positions = [n.ypos() for n in nodes]
|
||||
x = min(x_positions) - margin
|
||||
y = min(y_positions) - margin
|
||||
r = max(n.xpos() + n.screenWidth() for n in nodes) + margin
|
||||
b = max(n.ypos() + n.screenHeight() for n in nodes) + margin
|
||||
bd.setXYpos(x, y)
|
||||
bd["bdwidth"].setValue(r - x)
|
||||
bd["bdheight"].setValue(b - y)
|
||||
return bd
|
||||
|
||||
|
||||
def _format_exists(name: str) -> bool:
|
||||
for fmt in nuke.formats():
|
||||
if fmt.name() == name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Shot builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _find_plates(shot_code: str) -> list[str]:
|
||||
"""
|
||||
Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*.
|
||||
Returns a list of first-frame file paths.
|
||||
"""
|
||||
episode = shot_code[:7] # e.g. UNG_106
|
||||
shot_dir = os.path.join(FOOTAGE_ROOT, episode, shot_code)
|
||||
results = []
|
||||
|
||||
if not os.path.isdir(shot_dir):
|
||||
# Fall back: look for any subfolder under the episode root
|
||||
ep_dir = os.path.join(FOOTAGE_ROOT, episode)
|
||||
if os.path.isdir(ep_dir):
|
||||
for sub in sorted(os.listdir(ep_dir)):
|
||||
if sub.startswith(shot_code):
|
||||
results.extend(_scan_for_exr(os.path.join(ep_dir, sub)))
|
||||
return results
|
||||
|
||||
results.extend(_scan_for_exr(shot_dir))
|
||||
return results
|
||||
|
||||
|
||||
def _scan_for_exr(directory: str) -> list[str]:
|
||||
"""Return the first frame path of every EXR sequence found in *directory*."""
|
||||
results = []
|
||||
try:
|
||||
files = sorted(f for f in os.listdir(directory) if f.lower().endswith(".exr"))
|
||||
except OSError:
|
||||
return results
|
||||
|
||||
seen_stems: set[str] = set()
|
||||
for f in files:
|
||||
stem = re.sub(r"[\._]\d+\.exr$", "", f, flags=re.IGNORECASE)
|
||||
if stem not in seen_stems:
|
||||
seen_stems.add(stem)
|
||||
results.append(os.path.join(directory, f))
|
||||
return results
|
||||
|
||||
|
||||
def _nuke_sequence_path(first_frame_path: str) -> str:
|
||||
"""Convert a concrete first-frame path to a Nuke ####.exr pattern."""
|
||||
return re.sub(r"(\d+)(\.exr)$", lambda m: "#" * len(m.group(1)) + m.group(2),
|
||||
first_frame_path, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
|
||||
"""
|
||||
Build a complete Nuke shot script for *shot_code*.
|
||||
|
||||
- Fetches metadata from VFXReview
|
||||
- Locates plates on disk
|
||||
- Creates Read → OCIOColorSpace nodes
|
||||
- Creates Write_EXR and Write_Review nodes
|
||||
- Saves the script
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
if not _IN_NUKE:
|
||||
print(f"[VFXReview] Would build shot: {shot_code}")
|
||||
return False
|
||||
|
||||
# ── Fetch metadata ────────────────────────────────────────────────────────
|
||||
shot = api.lookup_shot(shot_code)
|
||||
if not shot:
|
||||
nuke.message(f"Shot not found in VFXReview:\n{shot_code}")
|
||||
return False
|
||||
|
||||
episode = shot.get("episode", "")
|
||||
frame_start = int(shot.get("frameStart") or 1001)
|
||||
frame_end = int(shot.get("frameEnd") or 1100)
|
||||
fps = float(shot.get("fps") or 24)
|
||||
description = shot.get("description", "")
|
||||
exr_output = shot.get("exrOutput") or f"{shot_code}_comp_TT_v001"
|
||||
|
||||
# ── Check / create save path ──────────────────────────────────────────────
|
||||
ep_code = f"UNG_{str(episode).zfill(3)}" if str(episode).isdigit() else str(episode)
|
||||
script_dir = os.path.join(NK_ROOT, ep_code, shot_code)
|
||||
script_path = os.path.join(script_dir, f"{shot_code}_comp_v001.nk")
|
||||
|
||||
os.makedirs(script_dir, exist_ok=True)
|
||||
|
||||
# ── Project settings ──────────────────────────────────────────────────────
|
||||
root = nuke.root()
|
||||
root["fps"].setValue(fps)
|
||||
root["first_frame"].setValue(frame_start)
|
||||
root["last_frame"].setValue(frame_end)
|
||||
|
||||
if os.path.isfile(OCIO_CONFIG):
|
||||
root["colorManagement"].setValue("OCIO")
|
||||
root["OCIO_config"].setValue("custom")
|
||||
root["customOCIOConfigPath"].setValue(OCIO_CONFIG)
|
||||
|
||||
# ── Locate plates ─────────────────────────────────────────────────────────
|
||||
plate_paths = _find_plates(shot_code)
|
||||
|
||||
read_nodes: list[nuke.Node] = []
|
||||
x_cursor = 0
|
||||
|
||||
for plate_path in plate_paths:
|
||||
seq_path = _nuke_sequence_path(plate_path)
|
||||
read = nuke.createNode("Read", inpanel=False)
|
||||
read["file"].setValue(seq_path)
|
||||
read["first"].setValue(frame_start)
|
||||
read["last"].setValue(frame_end)
|
||||
read["colorspace"].setValue("ACES2065-1")
|
||||
read.setName(_safe(f"Read_{os.path.basename(os.path.dirname(plate_path))}"))
|
||||
read.setXYpos(x_cursor, 0)
|
||||
|
||||
color = nuke.createNode("OCIOColorSpace", inpanel=False)
|
||||
color["in_colorspace"].setValue("ACES2065-1")
|
||||
color["out_colorspace"].setValue("ACEScg")
|
||||
color.setInput(0, read)
|
||||
color.setXYpos(x_cursor, 100)
|
||||
color.setName(_safe(f"CS_{read.name()}"))
|
||||
|
||||
read_nodes.append(read)
|
||||
x_cursor += 200
|
||||
|
||||
# ── Merge / dot into comp area ────────────────────────────────────────────
|
||||
# If we have plates, connect the first into a "COMP" dot
|
||||
comp_dot = nuke.createNode("Dot", inpanel=False)
|
||||
comp_dot.setXYpos(0, 250)
|
||||
comp_dot.setName("Dot_COMP")
|
||||
if read_nodes:
|
||||
# Wire the OCIOColorSpace node below the first read into the dot
|
||||
cs_nodes = [n for n in nuke.allNodes("OCIOColorSpace")]
|
||||
if cs_nodes:
|
||||
comp_dot.setInput(0, cs_nodes[0])
|
||||
|
||||
# ── Write EXR ─────────────────────────────────────────────────────────────
|
||||
exr_dir = os.path.join(EXPORT_ROOT, shot_code)
|
||||
os.makedirs(exr_dir, exist_ok=True)
|
||||
exr_path = os.path.join(exr_dir, f"{exr_output}.####.exr").replace("\\", "/")
|
||||
|
||||
write_exr = nuke.createNode("Write", inpanel=False)
|
||||
write_exr.setName("Write_EXR")
|
||||
write_exr["file"].setValue(exr_path)
|
||||
write_exr["file_type"].setValue("exr")
|
||||
write_exr["datatype"].setValue("16 bit half")
|
||||
write_exr["compression"].setValue("ZIP (1 scanline)")
|
||||
write_exr["colorspace"].setValue("ACEScg")
|
||||
write_exr["create_directories"].setValue(True)
|
||||
write_exr.setInput(0, comp_dot)
|
||||
write_exr.setXYpos(0, 400)
|
||||
|
||||
# ── Write Review MOV ──────────────────────────────────────────────────────
|
||||
mov_path = os.path.join(EXPORT_ROOT, f"{shot_code}_cmp_TT_v001.mov").replace("\\", "/")
|
||||
|
||||
write_mov = nuke.createNode("Write", inpanel=False)
|
||||
write_mov.setName("Write_Review")
|
||||
write_mov["file"].setValue(mov_path)
|
||||
write_mov["file_type"].setValue("mov")
|
||||
write_mov["colorspace"].setValue("Output - Rec.709")
|
||||
write_mov["create_directories"].setValue(True)
|
||||
write_mov.setInput(0, comp_dot)
|
||||
write_mov.setXYpos(200, 400)
|
||||
|
||||
# ── Backdrops ─────────────────────────────────────────────────────────────
|
||||
if read_nodes:
|
||||
all_input_nodes = nuke.allNodes("Read") + nuke.allNodes("OCIOColorSpace")
|
||||
_create_backdrop("PLATES", all_input_nodes, color=0x1F3A4AFF)
|
||||
|
||||
_create_backdrop("COMP", [comp_dot], color=0x2A3A2AFF)
|
||||
_create_backdrop("WRITE", [write_exr, write_mov], color=0x3A2A2AFF)
|
||||
|
||||
# ── Viewer ────────────────────────────────────────────────────────────────
|
||||
viewer = nuke.createNode("Viewer", inpanel=False)
|
||||
viewer.setInput(0, comp_dot)
|
||||
viewer.setXYpos(400, 250)
|
||||
|
||||
# ── Save script ───────────────────────────────────────────────────────────
|
||||
nuke.scriptSaveAs(script_path, overwrite=False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── Panel ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StatusBar(QtWidgets.QWidget):
|
||||
"""Three-line status bar shown at the bottom of the panel."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 4, 6, 4)
|
||||
layout.setSpacing(2)
|
||||
|
||||
self._shot_label = QtWidgets.QLabel("No shot loaded")
|
||||
self._action_label = QtWidgets.QLabel("Ready")
|
||||
self._warn_label = QtWidgets.QLabel("")
|
||||
|
||||
for lbl in (self._shot_label, self._action_label, self._warn_label):
|
||||
lbl.setWordWrap(True)
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._warn_label.setStyleSheet("color: #E8A020;")
|
||||
|
||||
frame = QtWidgets.QFrame()
|
||||
frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
frame_layout = QtWidgets.QVBoxLayout(frame)
|
||||
frame_layout.setContentsMargins(0, 0, 0, 0)
|
||||
frame_layout.addWidget(self)
|
||||
self._frame = frame
|
||||
|
||||
def set_shot(self, text: str):
|
||||
self._shot_label.setText(f"Shot: {text}")
|
||||
|
||||
def set_action(self, text: str):
|
||||
self._action_label.setText(text)
|
||||
|
||||
def set_warning(self, text: str):
|
||||
self._warn_label.setText(text)
|
||||
self._warn_label.setVisible(bool(text))
|
||||
|
||||
def clear_warning(self):
|
||||
self.set_warning("")
|
||||
|
||||
|
||||
class ConnectionWidget(QtWidgets.QGroupBox):
|
||||
"""Shows live API connection status with a test button."""
|
||||
|
||||
def __init__(self, api: VFXReviewAPI, parent=None):
|
||||
super().__init__("Connection", parent)
|
||||
self._api = api
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
|
||||
self._indicator = QtWidgets.QLabel("●")
|
||||
self._indicator.setFixedWidth(16)
|
||||
self._status = QtWidgets.QLabel("Not tested")
|
||||
self._test_btn = QtWidgets.QPushButton("Test")
|
||||
self._test_btn.setFixedWidth(50)
|
||||
self._test_btn.clicked.connect(self._test)
|
||||
|
||||
layout.addWidget(self._indicator)
|
||||
layout.addWidget(self._status, 1)
|
||||
layout.addWidget(self._test_btn)
|
||||
|
||||
self._set_state(None)
|
||||
|
||||
def _set_state(self, ok: bool | None, message: str = ""):
|
||||
if ok is None:
|
||||
color, text = "#888888", "Not tested"
|
||||
elif ok:
|
||||
color, text = "#44CC44", f"OK — {message}"
|
||||
else:
|
||||
color, text = "#CC4444", message or "Failed"
|
||||
self._indicator.setStyleSheet(f"color: {color}; font-size: 14px;")
|
||||
self._status.setText(text)
|
||||
|
||||
def _test(self):
|
||||
self._status.setText("Testing…")
|
||||
QtWidgets.QApplication.processEvents()
|
||||
ok, msg = self._api.test_connection()
|
||||
self._set_state(ok, msg)
|
||||
|
||||
|
||||
class ShotBuilderWidget(QtWidgets.QGroupBox):
|
||||
"""Episode → Shot dropdowns + Build Shot button."""
|
||||
|
||||
shot_built = Signal(str) # emitted with shot_code on success
|
||||
status_msg = Signal(str) # status line updates
|
||||
warning_msg = Signal(str) # warning line updates
|
||||
|
||||
def __init__(self, api: VFXReviewAPI, parent=None):
|
||||
super().__init__("Shot Builder", parent)
|
||||
self._api = api
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
|
||||
# ── Row 1: episode + shot dropdowns ──────────────────────────────────
|
||||
row1 = QtWidgets.QHBoxLayout()
|
||||
self._episode_combo = QtWidgets.QComboBox()
|
||||
self._episode_combo.setMinimumWidth(90)
|
||||
self._episode_combo.setToolTip("Episode")
|
||||
self._shot_combo = QtWidgets.QComboBox()
|
||||
self._shot_combo.setMinimumWidth(160)
|
||||
self._shot_combo.setToolTip("Shot code")
|
||||
row1.addWidget(self._episode_combo)
|
||||
row1.addWidget(self._shot_combo, 1)
|
||||
layout.addLayout(row1)
|
||||
|
||||
# ── Row 2: action buttons ─────────────────────────────────────────────
|
||||
row2 = QtWidgets.QHBoxLayout()
|
||||
self._refresh_btn = QtWidgets.QPushButton("Refresh")
|
||||
self._build_btn = QtWidgets.QPushButton("Build Shot")
|
||||
self._build_btn.setDefault(True)
|
||||
row2.addWidget(self._refresh_btn)
|
||||
row2.addWidget(self._build_btn)
|
||||
layout.addLayout(row2)
|
||||
|
||||
# ── Row 3: shot info label ────────────────────────────────────────────
|
||||
self._info_label = QtWidgets.QLabel("")
|
||||
self._info_label.setWordWrap(True)
|
||||
self._info_label.setStyleSheet("color: #AAAAAA; font-size: 11px;")
|
||||
layout.addWidget(self._info_label)
|
||||
|
||||
# ── Wire signals ──────────────────────────────────────────────────────
|
||||
self._episode_combo.currentIndexChanged.connect(self._on_episode_changed)
|
||||
self._shot_combo.currentIndexChanged.connect(self._on_shot_changed)
|
||||
self._refresh_btn.clicked.connect(self._load_episodes)
|
||||
self._build_btn.clicked.connect(self._build)
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────────────
|
||||
|
||||
def load(self):
|
||||
"""Populate the episode dropdown (call once after the panel is shown)."""
|
||||
self._load_episodes()
|
||||
|
||||
# ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_episodes(self):
|
||||
self._episode_combo.blockSignals(True)
|
||||
self._episode_combo.clear()
|
||||
self.status_msg.emit("Loading episodes…")
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
try:
|
||||
episodes = self._api.get_episodes()
|
||||
except APIError as exc:
|
||||
self.warning_msg.emit(f"API error: {exc}")
|
||||
self.status_msg.emit("Failed to load episodes")
|
||||
self._episode_combo.blockSignals(False)
|
||||
return
|
||||
|
||||
for ep in episodes:
|
||||
ep_val = str(ep.get("episode") or ep.get("code") or ep)
|
||||
ep_label = ep_val if len(ep_val) > 3 else f"UNG_{ep_val.zfill(3)}"
|
||||
self._episode_combo.addItem(ep_label, userData=ep_val)
|
||||
|
||||
self._episode_combo.blockSignals(False)
|
||||
if self._episode_combo.count():
|
||||
self._episode_combo.setCurrentIndex(0)
|
||||
self._on_episode_changed(0)
|
||||
else:
|
||||
self.status_msg.emit("No episodes found")
|
||||
|
||||
def _on_episode_changed(self, _index: int):
|
||||
ep_val = self._episode_combo.currentData()
|
||||
if ep_val is None:
|
||||
return
|
||||
self._shot_combo.clear()
|
||||
self.status_msg.emit(f"Loading shots for episode {ep_val}…")
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
try:
|
||||
shots = self._api.get_shots(episode=str(ep_val))
|
||||
except APIError as exc:
|
||||
self.warning_msg.emit(f"API error: {exc}")
|
||||
self.status_msg.emit("Failed to load shots")
|
||||
return
|
||||
|
||||
for shot in shots:
|
||||
code = shot.get("shotCode", "")
|
||||
if code:
|
||||
self._shot_combo.addItem(code, userData=shot)
|
||||
|
||||
count = self._shot_combo.count()
|
||||
self.status_msg.emit(f"{count} shot(s) for episode {ep_val}")
|
||||
self.warning_msg.emit("")
|
||||
if count:
|
||||
self._shot_combo.setCurrentIndex(0)
|
||||
self._on_shot_changed(0)
|
||||
|
||||
def _on_shot_changed(self, _index: int):
|
||||
shot = self._shot_combo.currentData()
|
||||
if not shot:
|
||||
self._info_label.setText("")
|
||||
return
|
||||
parts = []
|
||||
if shot.get("description"):
|
||||
parts.append(shot["description"])
|
||||
fr = shot.get("frameStart")
|
||||
to = shot.get("frameEnd")
|
||||
if fr and to:
|
||||
parts.append(f"Frames {fr}–{to}")
|
||||
status = shot.get("status", "")
|
||||
if status:
|
||||
parts.append(status.replace("_", " ").title())
|
||||
self._info_label.setText(" | ".join(parts))
|
||||
|
||||
def _build(self):
|
||||
shot_data = self._shot_combo.currentData()
|
||||
if not shot_data:
|
||||
self.warning_msg.emit("Select a shot first")
|
||||
return
|
||||
|
||||
shot_code = shot_data.get("shotCode", "")
|
||||
if not shot_code:
|
||||
self.warning_msg.emit("Invalid shot selection")
|
||||
return
|
||||
|
||||
self.status_msg.emit(f"Building {shot_code}…")
|
||||
self._build_btn.setEnabled(False)
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
try:
|
||||
ok = build_shot(shot_code, self._api)
|
||||
except Exception as exc:
|
||||
self.warning_msg.emit(str(exc))
|
||||
self.status_msg.emit("Build failed")
|
||||
self._build_btn.setEnabled(True)
|
||||
return
|
||||
|
||||
self._build_btn.setEnabled(True)
|
||||
if ok:
|
||||
self.shot_built.emit(shot_code)
|
||||
self.status_msg.emit(f"Built: {shot_code}")
|
||||
self.warning_msg.emit("")
|
||||
else:
|
||||
self.status_msg.emit("Build cancelled or failed")
|
||||
|
||||
|
||||
class VFXReviewPanel(QtWidgets.QWidget):
|
||||
"""Main dockable panel widget."""
|
||||
|
||||
TITLE = "VFXReview Connector"
|
||||
OBJECT_NAME = "VFXReviewConnectorPanel"
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName(self.OBJECT_NAME)
|
||||
self.setWindowTitle(self.TITLE)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
self._api = VFXReviewAPI()
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# ── Connection ────────────────────────────────────────────────────────
|
||||
self._connection = ConnectionWidget(self._api)
|
||||
layout.addWidget(self._connection)
|
||||
|
||||
# ── Shot Builder ──────────────────────────────────────────────────────
|
||||
self._shot_builder = ShotBuilderWidget(self._api)
|
||||
layout.addWidget(self._shot_builder)
|
||||
|
||||
# ── Spacer ────────────────────────────────────────────────────────────
|
||||
layout.addStretch(1)
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────────────
|
||||
self._status = StatusBar()
|
||||
frame = QtWidgets.QFrame()
|
||||
frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
||||
frame_layout = QtWidgets.QVBoxLayout(frame)
|
||||
frame_layout.setContentsMargins(0, 0, 0, 0)
|
||||
frame_layout.addWidget(self._status)
|
||||
layout.addWidget(frame)
|
||||
|
||||
# ── Wire inter-widget signals ─────────────────────────────────────────
|
||||
self._shot_builder.shot_built.connect(self._status.set_shot)
|
||||
self._shot_builder.status_msg.connect(self._status.set_action)
|
||||
self._shot_builder.warning_msg.connect(self._status.set_warning)
|
||||
|
||||
# Load episodes asynchronously after the panel is shown
|
||||
QtCore.QTimer.singleShot(200, self._shot_builder.load)
|
||||
|
||||
def sizeHint(self):
|
||||
return QtCore.QSize(320, 480)
|
||||
|
||||
|
||||
# ── Nuke integration ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_panel_instance: VFXReviewPanel | None = None
|
||||
|
||||
|
||||
def show_panel():
|
||||
"""Create (or raise) the floating panel."""
|
||||
global _panel_instance
|
||||
if _panel_instance is None or not _panel_instance.isVisible():
|
||||
_panel_instance = VFXReviewPanel()
|
||||
_panel_instance.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint)
|
||||
_panel_instance.show()
|
||||
else:
|
||||
_panel_instance.raise_()
|
||||
_panel_instance.activateWindow()
|
||||
|
||||
|
||||
class _NukePanel(nukescripts.PythonPanel if _IN_NUKE else object):
|
||||
"""Wrapper so Nuke can dock the panel in its workspace."""
|
||||
|
||||
def __init__(self):
|
||||
if not _IN_NUKE:
|
||||
return
|
||||
nukescripts.PythonPanel.__init__(
|
||||
self, VFXReviewPanel.TITLE, VFXReviewPanel.OBJECT_NAME
|
||||
)
|
||||
self._widget = VFXReviewPanel()
|
||||
self.customKnob = nuke.PyCustom_Knob(
|
||||
VFXReviewPanel.OBJECT_NAME, "", "_NukePanel._get_widget()"
|
||||
)
|
||||
self.addKnob(self.customKnob)
|
||||
|
||||
@staticmethod
|
||||
def _get_widget():
|
||||
global _panel_instance
|
||||
if _panel_instance is None:
|
||||
_panel_instance = VFXReviewPanel()
|
||||
return _panel_instance
|
||||
|
||||
|
||||
def add_menu():
|
||||
"""Called from menu.py to register the panel in the Nuke menus."""
|
||||
if not _IN_NUKE:
|
||||
return
|
||||
|
||||
menu = nuke.menu("Nuke")
|
||||
vfx_menu = menu.addMenu("VFXReview")
|
||||
vfx_menu.addCommand(
|
||||
"Open Connector",
|
||||
"import VFXReviewConnector; VFXReviewConnector.show_panel()",
|
||||
"F8",
|
||||
)
|
||||
# Also register as a dockable pane
|
||||
nukescripts.registerPanel(
|
||||
VFXReviewPanel.OBJECT_NAME,
|
||||
"import VFXReviewConnector; return VFXReviewConnector._NukePanel()",
|
||||
)
|
||||
|
||||
|
||||
# ── Standalone test (run outside Nuke with: python VFXReviewConnector.py) ─────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
|
||||
win = VFXReviewPanel()
|
||||
win.setWindowFlags(Qt.Window)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
Reference in New Issue
Block a user