""" 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 = "V:/_FOOTAGE/UNG" EXPORT_ROOT = "V:/_EXPORTS/UNG" RENDER_ROOT = PROJECT_ROOT + "/production/renders" PICLOCK_ROOT = "V:/_FOOTAGE/UNG/_PICLOCKS" NK_ROOT = PROJECT_ROOT + "/production/nuke" OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio" TEMPLATE_SCRIPT = PROJECT_ROOT + "/production/working_files/ung_template_script.nkind" SHOW_NAME = "UNGOVERNABLE" # ── 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"{label}") 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*. Per project spec, searches FOOTAGE_ROOT/{episodeCode}/ for subfolders named exactly {shotCode} or starting with {shotCode}_ (e.g. {shotCode}_A). Falls back to a flat search directly under FOOTAGE_ROOT if no episode subfolder exists. Each candidate folder is also scanned one level deep to handle version subdirectories (e.g. .../shotCode_A/v001/*.exr). Returns a list of first-frame file paths (one per EXR sequence found). """ # Robustly extract episode prefix: UNG_106 from UNG_106_010_020 parts = shot_code.split("_") ep_code = "_".join(parts[:2]) if len(parts) >= 2 else shot_code # Ordered list of roots to search — episode subfolder first, then flat search_roots = [ os.path.join(FOOTAGE_ROOT, ep_code), FOOTAGE_ROOT, ] results: list[str] = [] for root in search_roots: if not os.path.isdir(root): continue try: entries = sorted(os.listdir(root)) except OSError: continue for entry in entries: # Match exact shot-code folder OR any {shotCode}_* variant if entry != shot_code and not entry.startswith(shot_code + "_"): continue entry_path = os.path.join(root, entry) if not os.path.isdir(entry_path): continue exrs = _scan_for_exr(entry_path) if exrs: results.extend(exrs) else: # Try one level deeper (e.g. shotCode_A/v001/*.exr) try: sub_entries = sorted(os.listdir(entry_path)) except OSError: continue for sub in sub_entries: sub_path = os.path.join(entry_path, sub) if os.path.isdir(sub_path): results.extend(_scan_for_exr(sub_path)) if results: break # found at this root level — skip the flat fallback 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 _safe_set_knob(node: "nuke.Node", knob_name: str, value) -> bool: """Set *knob_name* on *node* if the knob exists. Returns True on success.""" knob = node.knob(knob_name) if knob is None: return False try: knob.setValue(value) return True except Exception: return False def build_shot(shot_code: str, api: VFXReviewAPI, shot_type: str = "2d comp") -> bool: """ Build a Nuke shot script for *shot_code* from the facility template. - Opens the facility template script - Fetches shot metadata from VFXReview - Sets Read1 to the first found plate - Configures Netflix_MEI_Overlay and NETFLIX_TEMPLATE_SLATE - Updates Write_EXR / Write_Review output paths - Saves the script to NK_ROOT 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", "") scene = shot.get("scene", "") 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" ep_code = f"UNG_{str(episode).zfill(3)}" if str(episode).isdigit() else str(episode) shot_name = f"{shot_code}_cmp_TT_v001" 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) # ── Open template ───────────────────────────────────────────────────────── if not os.path.isfile(TEMPLATE_SCRIPT): nuke.message(f"Template script not found:\n{TEMPLATE_SCRIPT}") return False nuke.scriptOpen(TEMPLATE_SCRIPT) # ── 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 → configure Read1 ────────────────────────────────────── plate_paths = _find_plates(shot_code) plate_warning = "" read1 = nuke.toNode("Read1") if plate_paths: seq_path = _nuke_sequence_path(plate_paths[0]).replace("\\", "/") if read1 is None: read1 = nuke.createNode("Read", inpanel=False) read1.setName("Read1") read1["file"].setValue(seq_path) read1["first"].setValue(frame_start) read1["last"].setValue(frame_end) read1["colorspace"].setValue("raw") else: plate_warning = f"No plates found under {FOOTAGE_ROOT} — set Read1 manually" if read1: read1["file"].setValue("") # ── Configure Netflix_MEI_Overlay ───────────────────────────────────────── mei = nuke.toNode("Netflix_MEI_Overlay") if mei: _safe_set_knob(mei, "bottomleft", shot_name) # ── Configure NETFLIX_TEMPLATE_SLATE ───────────────────────────────────── slate = nuke.toNode("NETFLIX_TEMPLATE_SLATE") if slate: _safe_set_knob(slate, "f_version_name", shot_name) _safe_set_knob(slate, "f_submitting_for", "APPROVAL") _safe_set_knob(slate, "f_shot_name", shot_code) _safe_set_knob(slate, "f_shot_type", shot_type) _safe_set_knob(slate, "f_show", SHOW_NAME) _safe_set_knob(slate, "f_vendor", "TWO TALES ANIMATION (PTY) LTD") _safe_set_knob(slate, "f_media_color", "Rec.709 w/SHOW LUT") _safe_set_knob(slate, "f_shot_description", description) _safe_set_knob(slate, "f_episode", ep_code) _safe_set_knob(slate, "f_scene", str(scene)) # ── Update Write paths ──────────────────────────────────────────────────── 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("\\", "/") mov_path = os.path.join(EXPORT_ROOT, f"{shot_name}.mov").replace("\\", "/") write_exr = nuke.toNode("Write_EXR") if write_exr: write_exr["file"].setValue(exr_path) write_mov = nuke.toNode("Write_Review") if write_mov: write_mov["file"].setValue(mov_path) # ── Save script ─────────────────────────────────────────────────────────── nuke.scriptSaveAs(script_path, overwrite=False) return True, plate_warning def import_renders(shot_code: str) -> tuple[bool, str]: """ Scan RENDER_ROOT/{shotCode}/ for EXR sequences and create Read → Shuffle pairs for each one found. Checks subdirectories first (individual render passes such as beauty, matte, fx …), then falls back to sequences directly in the shot folder. Returns (ok, message). """ if not _IN_NUKE: print(f"[VFXReview] Would import renders for: {shot_code}") return False, "" render_dir = os.path.join(RENDER_ROOT, shot_code) if not os.path.isdir(render_dir): return False, f"Render folder not found: {render_dir}" try: entries = sorted(os.listdir(render_dir)) except OSError as exc: return False, f"Cannot read render folder: {exc}" # Build list of (nuke_sequence_path, layer_label) sequences: list[tuple[str, str]] = [] subdirs = [e for e in entries if os.path.isdir(os.path.join(render_dir, e))] if subdirs: for sub in subdirs: for exr in _scan_for_exr(os.path.join(render_dir, sub)): seq = _nuke_sequence_path(exr).replace("\\", "/") sequences.append((seq, sub)) else: # No pass subfolders — grab sequences directly in the shot folder for exr in _scan_for_exr(render_dir): label = re.sub(r"[\._]\d+\.exr$", "", os.path.basename(exr), flags=re.IGNORECASE) seq = _nuke_sequence_path(exr).replace("\\", "/") sequences.append((seq, label)) if not sequences: return False, f"No EXR sequences found in: {render_dir}" x_cursor = 0 for seq_path, layer_name in sequences: read = nuke.createNode("Read", inpanel=False) read["file"].setValue(seq_path) read.setName(_safe(f"Read_{layer_name}")) read.setXYpos(x_cursor, 0) shuffle = nuke.createNode("Shuffle", inpanel=False) shuffle.setInput(0, read) shuffle.setName(_safe(f"Shuffle_{layer_name}")) shuffle["label"].setValue(layer_name) shuffle.setXYpos(x_cursor, 150) x_cursor += 220 return True, f"Imported {len(sequences)} render pass(es) for {shot_code}" def _tc_to_seconds(tc: str, fps: float) -> float: """Convert HH:MM:SS:FF (or drop-frame HH:MM:SS;FF) timecode to seconds.""" parts = re.split(r"[:;]", str(tc).strip()) if len(parts) < 4: return 0.0 try: hh = int(parts[0]) mm = int(parts[1]) ss = int(parts[2]) ff = int(parts[3]) return hh * 3600.0 + mm * 60.0 + ss + ff / (fps or 24.0) except (ValueError, ZeroDivisionError): return 0.0 def _find_piclock(ep_code: str) -> str | None: """Return the path of the first file matching {ep_code}_* in PICLOCK_ROOT.""" if not os.path.isdir(PICLOCK_ROOT): return None try: for entry in sorted(os.listdir(PICLOCK_ROOT)): if entry.startswith(ep_code + "_"): full = os.path.join(PICLOCK_ROOT, entry) if os.path.isfile(full): return full except OSError: pass return None def pull_piclock(shot_code: str, api: VFXReviewAPI) -> tuple[bool, str]: """ Find the offline picture-lock for *shot_code*, create a Read node trimmed to the shot’s sequence timecode range (with ±8-frame handles), and attach a Switch node that enables the piclock between frames 1009 and (last_frame - 9) — i.e. only inside the main content range. Returns (ok, message). """ if not _IN_NUKE: print(f"[VFXReview] Would pull piclock for: {shot_code}") return False, "" # ── Fetch metadata ──────────────────────────────────────────────────────── shot = api.lookup_shot(shot_code) if not shot: return False, f"Shot not found in VFXReview: {shot_code}" seq_tc_start = shot.get("seqTimecodeStart", "") seq_tc_end = shot.get("seqTimecodeEnd", "") if not seq_tc_start or not seq_tc_end: return False, "No sequence timecodes set — import from EDL/CSV first" fps = float(shot.get("fps") or 24) frame_start = int(shot.get("frameStart") or 1001) # ── Locate piclock file ─────────────────────────────────────────────────── ep_parts = shot_code.split("_") ep_code = "_".join(ep_parts[:2]) # e.g. UNG_106 piclock_path = _find_piclock(ep_code) if not piclock_path: return False, f"No piclock found for {ep_code} in {PICLOCK_ROOT}" # ── Timecode → frame offset calculation ────────────────────────────────── # Show convention: piclock starts at 01:00:00:00 (3590 s per AE connector) piclock_start_secs = 3590.0 handles = 8 tc_in_secs = _tc_to_seconds(seq_tc_start, fps) tc_out_secs = _tc_to_seconds(seq_tc_end, fps) in_frames = int(round((tc_in_secs - piclock_start_secs) * fps)) - handles out_frames = int(round((tc_out_secs - piclock_start_secs) * fps)) + handles if in_frames < 0: in_frames = 0 # With frame_mode="offset" and frame=N, comp frame F reads footage frame F+N. # We want comp frame_start → footage frame in_frames, so N = in_frames - frame_start. offset = in_frames - frame_start + 1 duration = out_frames - in_frames # ── Create Read node ────────────────────────────────────────────────────────── read = nuke.createNode("Read", inpanel=False) read["file"].setValue(piclock_path.replace("\\", "/")) read["frame_mode"].setValue("offset") read["frame"].setValue(str(offset)) # read["first"].setValue(frame_start) read["last"].setValue(100000) read["colorspace"].setValue("sRGB Encoded Rec.709 (sRGB)") read.setName("Read_Piclock") read.setXYpos(1000, 0) # ── Create Switch node ───────────────────────────────────────────────────────── # Input 0 = off (unconnected / black), Input 1 = piclock. # which = 1 only inside content range; 0 in the handle zones. switch = nuke.createNode("Switch", inpanel=False) switch.setInput(1, read) switch.setName("Switch_Piclock") switch["which"].setExpression( "(frame > 1008 && frame < root.last_frame - 8) ? 1 : 0" ) switch.setXYpos(1000, 180) # ── REFERENCE backdrop ────────────────────────────────────────────────────── _create_backdrop("REFERENCE", [read, switch], color=0x2A1F4AFF) return True, ( f"Piclock: {os.path.basename(piclock_path)} " f"{seq_tc_start} \u2192 {seq_tc_end} (+{handles}fr handles)" ) # ── 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 _VersionThumbFetcher(QtCore.QThread): """ Calls lookup_shot() in a background thread and emits the best available thumbnail URL: shot-level thumbnailUrl first, then the latest version's thumbnailUrl, then empty string if neither is set. """ found = Signal(int, str) # (request_id, url_or_empty) def __init__(self, shot_code: str, api: "VFXReviewAPI", request_id: int, parent=None): super().__init__(parent) self._shot_code = shot_code self._api = api self._request_id = request_id def run(self): try: shot = self._api.lookup_shot(self._shot_code) url = "" if shot: url = shot.get("thumbnailUrl") or "" if not url: for ver in shot.get("versions") or []: url = ver.get("thumbnailUrl") or "" if url: break self.found.emit(self._request_id, url) except Exception: self.found.emit(self._request_id, "") class _ThumbnailLoader(QtCore.QThread): """Fetches a thumbnail URL in a background thread and emits (request_id, bytes).""" loaded = Signal(int, bytes) def __init__(self, url: str, request_id: int, parent=None): super().__init__(parent) self._url = url self._request_id = request_id def run(self): try: full_url = self._url if self._url.startswith("http") else BASE_URL + self._url req = urllib.request.Request( full_url, headers={ "Authorization": f"Bearer {TOKEN}", "Accept": "image/*", }, ) with urllib.request.urlopen(req, timeout=5) as resp: self.loaded.emit(self._request_id, resp.read()) except Exception: self.loaded.emit(self._request_id, b"") 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 1b: shot type ───────────────────────────────────────────────── row1b = QtWidgets.QHBoxLayout() shot_type_lbl = QtWidgets.QLabel("Shot Type:") shot_type_lbl.setFixedWidth(65) self._shot_type_combo = QtWidgets.QComboBox() for _st in ("Screen Fill", "Clean up", "2d comp", "3d", "comp"): self._shot_type_combo.addItem(_st) self._shot_type_combo.setCurrentIndex(2) # default: 2d comp row1b.addWidget(shot_type_lbl) row1b.addWidget(self._shot_type_combo, 1) layout.addLayout(row1b) # ── 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 2b: import renders + piclock buttons ────────────────────────── self._import_btn = QtWidgets.QPushButton("Import Renders") self._piclock_btn = QtWidgets.QPushButton("Pull Picture Lock") layout.addWidget(self._import_btn) layout.addWidget(self._piclock_btn) # ── 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) # ── Thumbnail ────────────────────────────────────────────────────────────── self._thumb_label = QtWidgets.QLabel() self._thumb_label.setAlignment(Qt.AlignCenter) self._thumb_label.setMinimumHeight(80) self._thumb_label.setMaximumHeight(120) self._thumb_label.setStyleSheet( "background: #181818; border: 1px solid #333; color: #555; font-size: 11px;" ) self._thumb_label.setVisible(False) layout.addWidget(self._thumb_label) self._thumb_request_id: int = 0 self._version_fetch_id: int = 0 # Holds ALL active QThreads so Python's GC never destroys a running thread self._threads: set = set() # ── 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) self._import_btn.clicked.connect(self._import_renders) self._piclock_btn.clicked.connect(self._pull_piclock) # ── 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("") self._thumb_label.setVisible(False) 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)) thumb_url = shot.get("thumbnailUrl") or shot.get("thumbnail") or "" if thumb_url: self._load_thumbnail(thumb_url) else: # Show placeholder immediately; do a background lookup for version thumbnail self._thumb_label.setPixmap(QtGui.QPixmap()) self._thumb_label.setText("No thumbnail") self._thumb_label.setVisible(True) shot_code = shot.get("shotCode", "") if shot_code: self._start_version_thumb_lookup(shot_code) def _load_thumbnail(self, url: str): """Start async image fetch. Thread lives in self._threads until finished.""" self._thumb_request_id += 1 request_id = self._thumb_request_id self._thumb_label.setText("Loading thumbnail\u2026") self._thumb_label.setPixmap(QtGui.QPixmap()) self._thumb_label.setVisible(True) loader = _ThumbnailLoader(url, request_id) loader.loaded.connect(self._on_thumbnail_loaded) loader.finished.connect(lambda: self._threads.discard(loader)) self._threads.add(loader) loader.start() def _start_version_thumb_lookup(self, shot_code: str): """Background lookup for version thumbnailUrl when the shot has none.""" self._version_fetch_id += 1 fetch_id = self._version_fetch_id fetcher = _VersionThumbFetcher(shot_code, self._api, fetch_id) fetcher.found.connect(self._on_version_thumb_found) fetcher.finished.connect(lambda: self._threads.discard(fetcher)) self._threads.add(fetcher) fetcher.start() def _on_version_thumb_found(self, fetch_id: int, url: str): """Fires on the main thread after version lookup; loads image if found.""" if fetch_id != self._version_fetch_id: return # stale -- user moved to a different shot if url: self._load_thumbnail(url) # Otherwise label already shows 'No thumbnail' def _on_thumbnail_loaded(self, request_id: int, data: bytes): """Called on the main thread. Ignores results from superseded requests.""" if request_id != self._thumb_request_id: return # stale — user already selected a different shot if not data: self._thumb_label.setText("No thumbnail") self._thumb_label.setPixmap(QtGui.QPixmap()) return pixmap = QtGui.QPixmap() if not pixmap.loadFromData(data) or pixmap.isNull(): self._thumb_label.setText("No thumbnail") self._thumb_label.setPixmap(QtGui.QPixmap()) return # Scale to fit label width, capping height at 120 px label_w = max(self._thumb_label.width(), 200) scaled = pixmap.scaledToWidth(label_w, Qt.SmoothTransformation) if scaled.height() > 120: scaled = pixmap.scaledToHeight(120, Qt.SmoothTransformation) self._thumb_label.setPixmap(scaled) self._thumb_label.setVisible(True) 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, warn = build_shot(shot_code, self._api, self._shot_type_combo.currentText()) 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(warn) # empty string clears, message shows in amber else: self.status_msg.emit("Build cancelled or failed") def _import_renders(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"Importing renders for {shot_code}…") self._import_btn.setEnabled(False) QtWidgets.QApplication.processEvents() try: ok, msg = import_renders(shot_code) except Exception as exc: self.warning_msg.emit(str(exc)) self.status_msg.emit("Import failed") self._import_btn.setEnabled(True) return self._import_btn.setEnabled(True) if ok: self.status_msg.emit(msg) self.warning_msg.emit("") else: self.status_msg.emit("Import failed") self.warning_msg.emit(msg) def _pull_piclock(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"Pulling piclock for {shot_code}\u2026") self._piclock_btn.setEnabled(False) QtWidgets.QApplication.processEvents() try: ok, msg = pull_piclock(shot_code, self._api) except Exception as exc: self.warning_msg.emit(str(exc)) self.status_msg.emit("Piclock failed") self._piclock_btn.setEnabled(True) return self._piclock_btn.setEnabled(True) if ok: self.status_msg.emit(msg) self.warning_msg.emit("") else: self.status_msg.emit("Piclock failed") self.warning_msg.emit(msg) class _CollapsibleSection(QtWidgets.QWidget): """A titled section that expands/collapses when its header button is clicked.""" def __init__(self, title: str, parent=None): super().__init__(parent) self._title = title outer = QtWidgets.QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) self._btn = QtWidgets.QPushButton(f"▶ {title}") self._btn.setCheckable(True) self._btn.setChecked(False) self._btn.setStyleSheet( "QPushButton { text-align: left; padding: 4px 8px; " "background: #2E2E2E; border: 1px solid #555; font-weight: bold; }" "QPushButton:checked { background: #383848; }" ) self._btn.toggled.connect(self._on_toggle) outer.addWidget(self._btn) self._body = QtWidgets.QWidget() self._body_layout = QtWidgets.QVBoxLayout(self._body) self._body_layout.setContentsMargins(8, 6, 8, 8) self._body_layout.setSpacing(4) self._body.setVisible(False) outer.addWidget(self._body) def _on_toggle(self, checked: bool): arrow = u"\u25bc" if checked else u"\u25b6" self._btn.setText(f"{arrow} {self._title}") self._body.setVisible(checked) def content_layout(self) -> QtWidgets.QVBoxLayout: return self._body_layout class SettingsWidget(_CollapsibleSection): """Collapsible panel section for configuring FOOTAGE_ROOT and EXPORT_ROOT.""" def __init__(self, parent=None): super().__init__("Settings", parent) body = self.content_layout() # ── Footage Root ────────────────────────────────────────────────────── body.addWidget(QtWidgets.QLabel("Footage Root:")) footage_row = QtWidgets.QHBoxLayout() self._footage_edit = QtWidgets.QLineEdit(FOOTAGE_ROOT) self._footage_edit.setPlaceholderText("V:/_FOOTAGE/UNG") self._footage_browse = QtWidgets.QPushButton("…") self._footage_browse.setFixedWidth(26) footage_row.addWidget(self._footage_edit) footage_row.addWidget(self._footage_browse) body.addLayout(footage_row) # ── Export Root ─────────────────────────────────────────────────────── body.addWidget(QtWidgets.QLabel("Export Root:")) export_row = QtWidgets.QHBoxLayout() self._export_edit = QtWidgets.QLineEdit(EXPORT_ROOT) self._export_edit.setPlaceholderText("V:/_EXPORTS/UNG") self._export_browse = QtWidgets.QPushButton("…") self._export_browse.setFixedWidth(26) export_row.addWidget(self._export_edit) export_row.addWidget(self._export_browse) body.addLayout(export_row) # ── Wire ────────────────────────────────────────────────────────────── self._footage_edit.textChanged.connect(self._update_globals) self._export_edit.textChanged.connect(self._update_globals) self._footage_browse.clicked.connect(self._browse_footage) self._export_browse.clicked.connect(self._browse_export) def _update_globals(self): global FOOTAGE_ROOT, EXPORT_ROOT FOOTAGE_ROOT = self._footage_edit.text().strip() EXPORT_ROOT = self._export_edit.text().strip() def _browse_footage(self): path = QtWidgets.QFileDialog.getExistingDirectory( self, "Select Footage Root", self._footage_edit.text() ) if path: self._footage_edit.setText(path) def _browse_export(self): path = QtWidgets.QFileDialog.getExistingDirectory( self, "Select Export Root", self._export_edit.text() ) if path: self._export_edit.setText(path) 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) # ── Settings (collapsible) ─────────────────────────────────────────── self._settings = SettingsWidget() layout.addWidget(self._settings) # ── 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_())