1246 lines
49 KiB
Python
1246 lines
49 KiB
Python
"""
|
||
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"<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 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_())
|