diff --git a/VFXReviewConnector.py b/VFXReviewConnector.py index a7f2d35..00d5fd4 100644 --- a/VFXReviewConnector.py +++ b/VFXReviewConnector.py @@ -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. """ @@ -252,20 +307,27 @@ def build_shot(shot_code: str, api: VFXReviewAPI) -> bool: nuke.message(f"Shot not found in VFXReview:\n{shot_code}") return False - episode = shot.get("episode", "") + 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) - script_dir = os.path.join(NK_ROOT, ep_code, shot_code) + 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["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_exr = nuke.toNode("Write_EXR") + if write_exr: + write_exr["file"].setValue(exr_path) - # ── Write Review MOV ────────────────────────────────────────────────────── - mov_path = os.path.join(EXPORT_ROOT, f"{shot_code}_cmp_TT_v001.mov").replace("\\", "/") - - write_mov = nuke.createNode("Write", inpanel=False) - write_mov.setName("Write_Review") - write_mov["file"].setValue(mov_path) - write_mov["file_type"].setValue("mov") - write_mov["colorspace"].setValue("Output - Rec.709") - write_mov["create_directories"].setValue(True) - write_mov.setInput(0, comp_dot) - write_mov.setXYpos(200, 400) - - # ── Backdrops ───────────────────────────────────────────────────────────── - if read_nodes: - all_input_nodes = nuke.allNodes("Read") + nuke.allNodes("OCIOColorSpace") - _create_backdrop("PLATES", all_input_nodes, color=0x1F3A4AFF) - - _create_backdrop("COMP", [comp_dot], color=0x2A3A2AFF) - _create_backdrop("WRITE", [write_exr, write_mov], color=0x3A2A2AFF) - - # ── Viewer ──────────────────────────────────────────────────────────────── - viewer = nuke.createNode("Viewer", inpanel=False) - viewer.setInput(0, comp_dot) - viewer.setXYpos(400, 250) + 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 + 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 ───────────────────────────────────────────────────────────────────── @@ -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) diff --git a/__pycache__/VFXReviewConnector.cpython-311.pyc b/__pycache__/VFXReviewConnector.cpython-311.pyc new file mode 100644 index 0000000..079c1b6 Binary files /dev/null and b/__pycache__/VFXReviewConnector.cpython-311.pyc differ diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..106a406 --- /dev/null +++ b/app/api/dashboard/stats/route.ts @@ -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, + }); +} diff --git a/app/api/ext/shots/lookup/route.ts b/app/api/ext/shots/lookup/route.ts index 8e56e35..7eda8d5 100644 --- a/app/api/ext/shots/lookup/route.ts +++ b/app/api/ext/shots/lookup/route.ts @@ -93,6 +93,7 @@ export async function GET(req: NextRequest) { approvalStatus: true, reviewStatus: true, fileUrl: true, + thumbnailUrl: true, createdAt: true, }, },