Files
vfxreview/VFXReviewConnector.py
T
twotalesanimation c968b8a46a
Deploy / deploy (push) Successful in 2m52s
Nuke Connector Update
2026-07-22 10:37:13 +02:00

1258 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
},
)
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*.
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 shots 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/*",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
},
)
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_())