API Updates
Deploy / deploy (push) Successful in 2m45s

This commit is contained in:
twotalesanimation
2026-07-08 14:17:03 +02:00
parent 7026a5342b
commit d40ca8eb55
4 changed files with 672 additions and 100 deletions
+616 -95
View File
@@ -43,13 +43,17 @@ BASE_URL = "https://review.twotalesvfx.com"
PROJECT_CODE = "UNG_S1"
PROJECT_ROOT = "X:/shared_projects_2026/UNGO_VFX"
FOOTAGE_ROOT = PROJECT_ROOT + "/production/plates"
EXPORT_ROOT = PROJECT_ROOT + "/production/renders"
PICLOCK_ROOT = PROJECT_ROOT + "/production/piclocks"
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 ────────────────────────────────────────────────────────────────
@@ -188,22 +192,60 @@ def _format_exists(name: str) -> bool:
def _find_plates(shot_code: str) -> list[str]:
"""
Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*.
Returns a list of first-frame file paths.
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).
"""
episode = shot_code[:7] # e.g. UNG_106
shot_dir = os.path.join(FOOTAGE_ROOT, episode, shot_code)
results = []
# 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
if not os.path.isdir(shot_dir):
# Fall back: look for any subfolder under the episode root
ep_dir = os.path.join(FOOTAGE_ROOT, episode)
if os.path.isdir(ep_dir):
for sub in sorted(os.listdir(ep_dir)):
if sub.startswith(shot_code):
results.extend(_scan_for_exr(os.path.join(ep_dir, sub)))
return results
# 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
results.extend(_scan_for_exr(shot_dir))
return results
@@ -230,15 +272,28 @@ def _nuke_sequence_path(first_frame_path: str) -> str:
first_frame_path, flags=re.IGNORECASE)
def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
"""
Build a complete Nuke shot script for *shot_code*.
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
- Fetches metadata from VFXReview
- Locates plates on disk
- Creates Read → OCIOColorSpace nodes
- Creates Write_EXR and Write_Review nodes
- Saves the script
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.
"""
@@ -253,19 +308,26 @@ def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
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"
# ── Check / create save path ──────────────────────────────────────────────
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)
@@ -277,88 +339,234 @@ def build_shot(shot_code: str, api: VFXReviewAPI) -> bool:
root["OCIO_config"].setValue("custom")
root["customOCIOConfigPath"].setValue(OCIO_CONFIG)
# ── Locate plates ─────────────────────────────────────────────────────────
# ── Locate plates → configure Read1 ──────────────────────────────────────
plate_paths = _find_plates(shot_code)
plate_warning = ""
read1 = nuke.toNode("Read1")
read_nodes: list[nuke.Node] = []
x_cursor = 0
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("")
for plate_path in plate_paths:
seq_path = _nuke_sequence_path(plate_path)
read = nuke.createNode("Read", inpanel=False)
read["file"].setValue(seq_path)
read["first"].setValue(frame_start)
read["last"].setValue(frame_end)
read["colorspace"].setValue("ACES2065-1")
read.setName(_safe(f"Read_{os.path.basename(os.path.dirname(plate_path))}"))
read.setXYpos(x_cursor, 0)
# ── Configure Netflix_MEI_Overlay ─────────────────────────────────────────
mei = nuke.toNode("Netflix_MEI_Overlay")
if mei:
_safe_set_knob(mei, "bottomleft", shot_name)
color = nuke.createNode("OCIOColorSpace", inpanel=False)
color["in_colorspace"].setValue("ACES2065-1")
color["out_colorspace"].setValue("ACEScg")
color.setInput(0, read)
color.setXYpos(x_cursor, 100)
color.setName(_safe(f"CS_{read.name()}"))
# ── 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))
read_nodes.append(read)
x_cursor += 200
# ── Merge / dot into comp area ────────────────────────────────────────────
# If we have plates, connect the first into a "COMP" dot
comp_dot = nuke.createNode("Dot", inpanel=False)
comp_dot.setXYpos(0, 250)
comp_dot.setName("Dot_COMP")
if read_nodes:
# Wire the OCIOColorSpace node below the first read into the dot
cs_nodes = [n for n in nuke.allNodes("OCIOColorSpace")]
if cs_nodes:
comp_dot.setInput(0, cs_nodes[0])
# ── Write EXR ─────────────────────────────────────────────────────────────
# ── 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.createNode("Write", inpanel=False)
write_exr.setName("Write_EXR")
write_exr = nuke.toNode("Write_EXR")
if write_exr:
write_exr["file"].setValue(exr_path)
write_exr["file_type"].setValue("exr")
write_exr["datatype"].setValue("16 bit half")
write_exr["compression"].setValue("ZIP (1 scanline)")
write_exr["colorspace"].setValue("ACEScg")
write_exr["create_directories"].setValue(True)
write_exr.setInput(0, comp_dot)
write_exr.setXYpos(0, 400)
# ── Write Review MOV ──────────────────────────────────────────────────────
mov_path = os.path.join(EXPORT_ROOT, f"{shot_code}_cmp_TT_v001.mov").replace("\\", "/")
write_mov = nuke.createNode("Write", inpanel=False)
write_mov.setName("Write_Review")
write_mov = nuke.toNode("Write_Review")
if write_mov:
write_mov["file"].setValue(mov_path)
write_mov["file_type"].setValue("mov")
write_mov["colorspace"].setValue("Output - Rec.709")
write_mov["create_directories"].setValue(True)
write_mov.setInput(0, comp_dot)
write_mov.setXYpos(200, 400)
# ── Backdrops ─────────────────────────────────────────────────────────────
if read_nodes:
all_input_nodes = nuke.allNodes("Read") + nuke.allNodes("OCIOColorSpace")
_create_backdrop("PLATES", all_input_nodes, color=0x1F3A4AFF)
_create_backdrop("COMP", [comp_dot], color=0x2A3A2AFF)
_create_backdrop("WRITE", [write_exr, write_mov], color=0x3A2A2AFF)
# ── Viewer ────────────────────────────────────────────────────────────────
viewer = nuke.createNode("Viewer", inpanel=False)
viewer.setInput(0, comp_dot)
viewer.setXYpos(400, 250)
# ── Save script ───────────────────────────────────────────────────────────
nuke.scriptSaveAs(script_path, overwrite=False)
return True
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 ─────────────────────────────────────────────────────────────────────
@@ -444,6 +652,61 @@ class ConnectionWidget(QtWidgets.QGroupBox):
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."""
@@ -471,6 +734,18 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
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")
@@ -480,17 +755,38 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
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 ────────────────────────────────────────────────────────────────
@@ -557,6 +853,7 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
shot = self._shot_combo.currentData()
if not shot:
self._info_label.setText("")
self._thumb_label.setVisible(False)
return
parts = []
if shot.get("description"):
@@ -570,6 +867,73 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
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:
@@ -586,7 +950,7 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
QtWidgets.QApplication.processEvents()
try:
ok = build_shot(shot_code, self._api)
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")
@@ -597,10 +961,165 @@ class ShotBuilderWidget(QtWidgets.QGroupBox):
if ok:
self.shot_built.emit(shot_code)
self.status_msg.emit(f"Built: {shot_code}")
self.warning_msg.emit("")
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."""
@@ -623,7 +1142,9 @@ class VFXReviewPanel(QtWidgets.QWidget):
# ── 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)
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [
approved,
review,
changes,
todo,
artists,
shots,
renderQueue,
] = await Promise.all([
db.shot.count({
where: { shotApprovalStatus: "CLIENT_APPROVED" },
}),
db.shot.count({
where: { status: { in: ["INTERNAL_REVIEW", "READY_FOR_CLIENT", "CLIENT_REVIEW"] } },
}),
db.shot.count({
where: { status: "REVISIONS" },
}),
db.shot.count({
where: { status: "WAITING" },
}),
db.user.count({
where: { role: "ARTIST", isActive: true },
}),
db.shot.count({}),
db.task.count({
where: { type: "RENDER", status: { not: "DONE" } },
}),
]);
return NextResponse.json({
approved,
review,
changes,
todo,
artists,
shots,
renderQueue,
});
}
+1
View File
@@ -93,6 +93,7 @@ export async function GET(req: NextRequest) {
approvalStatus: true,
reviewStatus: true,
fileUrl: true,
thumbnailUrl: true,
createdAt: true,
},
},