From d40ca8eb55b010d447e3f6a7ce2c359e9df9d482 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:17:03 +0200 Subject: [PATCH] API Updates --- VFXReviewConnector.py | 721 +++++++++++++++--- .../VFXReviewConnector.cpython-311.pyc | Bin 0 -> 75776 bytes app/api/dashboard/stats/route.ts | 50 ++ app/api/ext/shots/lookup/route.ts | 1 + 4 files changed, 672 insertions(+), 100 deletions(-) create mode 100644 __pycache__/VFXReviewConnector.cpython-311.pyc create mode 100644 app/api/dashboard/stats/route.ts 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 0000000000000000000000000000000000000000..079c1b6a190ea236a017a4f354554deb02e789ae GIT binary patch literal 75776 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 z zVu`PT&Gjf>lhPkGjdD%r>dR8iA-RTWG?YJ3yk3!NdgYp4#-UZD#F)|QglM_0-5Par zTk*!c^(CzZ*tk$6zw|8$#fhqp=#E~gs!y)!W4-yHY76u0m#PNjssZvV*Tt60VZ=Nx zE@JRbbSCPDG%9H*Rkq8O?S?_Zgw-G$q$QzOrhuVxO_E4$w<3LxpHShpNmXrfRa?Ad zh!11lG)BKA!n8cUOXhb)`CY8z@S%B)Z^q#RIz#p>JoARFIQ>vZXi~%2aJ~e6MSBkP z740zJPENMTp39=K(kG0>XJ~+ptqoMTP3rxVYR`Kw+<5`rn9Gz$Dk0Rq&hK91ci-pa zJ!d5TtjwQ{@@H9@%V4m6o$p-ZJ6DJ0E{MRNl=+iU{-jbN7|UDdo7VWIm3q1PfM)w0 zA8)#tMSrG9!rg=A`?#ffHBnD|f)>e5hIpu#A<_Ms)p;16$1hb104tZm&mr6?v(UY9 zBLFUg83;^PsPD`SmR|^pFr~)$+OCoJJdL9eV}Z$oQNPDG2GEmGePq%hq#g;r9E6Gf z8Ed}y-@swQKyj`#M<5_nlDh1Yf4K#LX*Ywza(RfUk#pu^6VWDJ=^>N()|Sf3TsKSMm1e3*g<8~ zcH3I@@sNCYFtsvtE%j}buMe+2hpl1mUQROe?&hx>V(RCF8qt(W%{9c)=z&@UJQ&B3 z*kOo~-abu|JuIT@i9677aW|>#m@!&;t?P0TF_b=Du6z!nzmxh z1jaD_9xWboUL^ZA=Yg2}CIc}m(>j7e7892DLiSVPc(TpRpCG*WugPJOJ}*&rG)!U+ zRRxQ})4*12eEdE=RZ#dxl#_K7qCi2BEK`WYr&k5i&QmZVD8#b-v$1TL+rR^UxH(M%A4i# z=4g5E%Ednz`@ON%3*VW(H;va1v!voaxwvm};6ZJD(O0< z_b>d}D}VCJ&uahJ`x7r-@moBmmaJjT*|5UjM;&l>POt3jjk2GDhQu2kn5l{eIb5=1 z)4HR6%~7vfEadD|d)bLME3LUYC1;oH z?24+t0FA+)wwCwWOdr{D`dTd?74#JJHCjGywBiNZHmHeU_xojpGHA4F!iO(m@H`JB zHm)=zIJm%8eXIvG?bsF3RtD9F?#7smf26^(^kO8$Cxog&R;sFjUbrN|olCE|l$Ee5rFq5rS5W0s+QI5s5%)P7dWq=&%V8(bU$ z<4FgBEr7tgn1z6}JTV~~x?$+OgeWBOvBWAvpQeS5;q0*WUW$my&E1BWY2l->R~T38 z8FdcZFc$0#&q?Y5HkNYLv6i8&lkf$^u`xKA7`163tB}IC1Ds3`QBy)HeE|gBF&L^) z;T=&u;zv`&+3~-i=|UO|W(BhGC{m~>3Hq^UqJWT$^e5UWP%#rCZ;TuASJ8fjI#;Lw zJ4c)h!~o+z5Fm0QrkG0(lk7?26$%$yu;yX$JdJ*)`}W_ zWLX{fzT=0EXi=(7;$QysRrKQHLpEjjnd&OK4*o>UlTLy83Ba!kd{ z0{=nE;~)w7GP_gDCstsJszokrfftGVnsX0Aj0q+a*7FxXzT{%)Piddu7NASBppxhg&rz* z%9Si}f&-i_>GzcodbA|APR9Ka%9bhpqbDogBnvP z&W4(qxiUA~WY+=cG0N!26gO*3#Nit=Vi^h+uLG0h_2FZH&|?)+D^x&{V11}AJ*Xz+ zMCBw66PzwWqJYz`LfKWb?rK?cwMed3+0}}(PzXu^sHqTxZFSB`&PLhU7@9K4D zTy}ht(9|Y;o%@l?e^_UES>G~bfV>LksWaGTd7m- ziGmaq-!6N%Y`GC<`W7_E1q}@IphU25RZ3Cv!KU{&tyoshOXVGMdB=MB?zQsWQu$uF ze6Qr}mYv;EXSWJYnSGwp->v#a)rw!L?UZZ5h3Jy`t|;FH%SyM8y?bo=SEQnCa?!SE z(aBZr$NUfY`<2r6etA1iay|Jo_o97+PN}im_cKsSTj@x-vck;vpo2Z6q+VV`Ha^&@LcbNaU_CXbsLfrnPWsjZ+fSshcW| z(~%vV&`z9jJIPF4H=X>~UCnKz*@g~jL(_CRtr*ib0WWqT>ha$3ZF4%~JJ~~-x8HfrY zy;-QPWiE(oW*^N|cc|4J(+6Nnlkkq6z&qwkI_63`;QF^@hgz~Dt>2_Iy_2QkTDJ#i z5)w$WfOp!e71Z$Oj$Adm8sy+>&Kf^)s8@sS8S!w_L| z?Y0P6bOiH{Y|SL-yip=PcDzj&a!0^j!*N=;ix^FujK|uTM#dT9pX=0?*gHXA4UU+H zfd(6VNa@2)JbXS6lg%{MX2e7?XSrmvj0YEVV~EFr?4KZgwjef|7#UVzq**&Vp|FjY z8SqMJ670W4`s8MpUDUeMoBhxYpE?#B?z({XCg{jFMBB5z>nrA)ADnA`@V(=i<{q`V zC&f>9^uue~f1oKGyf8phPjH8(pt72t7|0>5cdnk#^Z_bJ^e;M!!5kO`k@a3>AE*2^ ziXeA77$*HFBU{GrQ(4U@njQT3R3x&SQ+9QDm26n{Llye0zoXB>cY%QhSu|vPAQy-= zNskMaE7i&k(*ed@gD{a;m+3V-gvVsIlj1$IaMknX!7mNGIbapjA<`xvyr(;P$ zI?Bc5^qP@2F}41IjNGZpooTrfl1MTlLSC=aksk9cs79ilke4Y3G#O-(TuhF=E)`!c zo@Rf1-y6nvTDCmFecO&+{5V_QB+X*&Ixk_NqSyI_4Fqvt74d8X5x?B!U&v1_VwcMX zA~rJj+}Z|`=emIqmw{dGIKArzLP&_?a%>=QMHTfW_wE(iO^_B4+QUqjYf3T(Qbq^t z&ytT$I$2L>3#@i0U|@bc&Nq({bA{AhKbtK=X~Mu@UnpR^8YCRVGI4GUyIj^wZp6;H zNlc@~Vw0%2UN;}@nu~V5cQzBEm9cPk9|B=iC^|!?1BZE#EF{ znwh$>?i*8J>AT!@v6uRo;=7cfh9|<6;onm2TK?zB6*4D)*)3KJ$uR zFU+lwCq4M#yA-+{nr44|MR(xCEarD;%HW^$;iGr}j{?U|4Fu*{iY4c95Za&5V-yQ| z-+5u};=LgS4*O^4TTybA&EVg_J7f%25UL8n+cTVRa!sB`JgPVC1(h|a!O?9h^j z4W50lJ`Q$B19>m`n0*DY>6&Y{PY|Ud7Zb(0T`Iq5yiq1yfi`y2<|nUJ4ajOz=`n*+Ewrmny(N~C=;jP?;5t56LE6Mp=q8baCmo@r_}0ndtkYssrOtm%>q%1krC`2yx@Y$wGE0fON`~O0>B*8RTbb zjx%GCgUJH=32W=gmHhq4Fce}rNK_&`QMAm&t!H9yCL?FEZzeJ)-Xt^L-7`*h-D1qr zpz>#U%hkj>>50))|3oQu={p&9XO=S<+Y<#4!~7UEM*~}6?bE9msjm&wm%dBceWYc^ zMClHg^MzU>YLts#dpQW?M&(FXG{@|v9UEl z{8!e_jC`s2&1S(LN2IP%HB+AncBsLQbP!Si_O@v**aUwH!B#ccnhv%)Mf33cs;Qq9 zuAmRLpCTaHg1->x$-5ojXG-CLSz&3%+|SO-|j zwtdK~%AQ?2U!833hp!zT0L6{btXw6{!3L(4!V zr-YsWxz`0;}&c_`n;9Dvk}sky%d z3SP{~!XjN|e#8j5jP3RS4Jg_AYp?CT8j!uNJ zBV|l4Pa!d$LQ+$?G|}D78qZS<1%HJc3DQD<8ixM2;MKeEcbg6Ru!vg|G+dixo2$w|+R|A{&r~6Rr~2?C72t8yh~&JjNZ-7+w$Ui7;(Z zaKiW-h=k$gHhvCXZDWac7>A)85bH3F2l7mC61r44{>@!-(b;d!1rX#qwKQj#QXY0Z zaS?ABzoiey5Plq6k1o7Oiz#M{^)9Wi$VQlz_GO|_LDh-Y;Q?N8*`M7v7ld_4~nv^->03EF6?LLxX#e5|NAd|GZGo> zP5yz&lUL+WAWpYJK)YbMu)1mN8kY|(g_1l@PBQx`gC6p{kd9w zG7LSPWir<7Fby>p+Yz~7s&f0t$v8O-L)DgIH+OFC+yY}#7Yxf;%rP(yTz`fDOaz~R zBdA=nov{^C^!h~#n2{)El*z{|E0NKpZmNdZ>x|RReu2||3CXhslM^RS5Z|3?D+QS5 zB74KlLlqq67J=Z&?ASzX3I>_zKh60rQ-0KqxwGw#EE8q@@Yf5Ctk2;&L7g4JrD9c6TtHU9O{>KrlRFUZU6#4BZJdc^N;aq^8|c^-6bG3Cc~?L^ zaA?clGM`bftYSLpjkKiCxR{r>V#gh?k1VnvAYOR@hs?mqE@V<$<~n%Yo9)8O8QR1> zl)X_Xl%j4|)~l6GnaXChvKeg&a4R>^#cIjaKB(3{XfY;3?vG{<-;i!>x&f0vLc`J| z4L)o#tP{K(&@?jRrjOE_5D=e)^Xs@7=!F|QK0TR3Rd+qh-^PHFq7dm|jl)Piv#o)5 z{BoBlGs!ENm)hARPnmq-TiXBMR7gt!8HTvAd4T~oI%U9$N~ z;g!PKC_fE|Ny0Q*A4}J4#yt~z^I866)~ppHbVAr`g2!3h91&WFvOlUkJjoYiqRr&k z+rsC;2XPF8K!x>GpQc}pbFqW;?RL8~#UtD|xVF5LYuvurnkGtLO)wN+O@~vgp0$9+ z3oHoF)W2*l;hDZhAqUG#In|@YmG)lA}+k zC4wzp!bz<`Kq7xy%LaU8s0kyeU0mA4&4Hp8Fa*_mu@GKjeK0ickNFV-q#{JzIq9ej z=CqtGJ`$fKUu+3J;>rCcj1_7#2JP70lfnQgM0S(3_XhfBjiV` z=ZQlDj}EfmZT;>``4Q~Z9|s{Nm)us(FG82xH3F?j3mH3b7r47?^Vv_E*ulIdmq0;m zb$TtS1bS*#;joqq^hjvWVN(*%?&=**6xC9nKsW7ZDQXeSKhz?g-BC=ZP8!f6v0aLJ zhzE0vt>1-F%AH5l@5qzqu83kk#EH`XZ)0mSRAjH$(u=vq)@Mm&jIBxe#ab?UT_Dux zDe;_RY#k;KEcGolwgNRpE#mo}$JUo~Y(X)Q4 z{wV(6uFzaz0>vK_J3krhId^&j=e2kweM+?2K=nBYBqzdLhWHXaX?9>A86A)J#NZh1 z1~S2D=y6`nP#o{FWx)LpjSY{)PlEJ_D@}=?ie9Eac1^a`4N@dhyZ9lcphjF zF9s%N`7fS0TSRn{#R6(*=*(%T+Qf%Wj7=Pa5h&%4sg%$V&!ATZ& zQ~Qhb=A%{FfL1G>IWa-Yo`?wxH4)akm_tcHNOTZRKu0GWghb8E;|66AD(5<(nb0~I zAD#9wD~e?0^Yc?`c>QcB6Yf;Qof&zXDsM~6+iq5^R;xB-sy3=s8>fqI!ZKxD$Gf$e zx-PY@i?H(=#z3y&ra04WyE5`_Roe z(`&=36bfNBNuL)OyzL8$GvNo+uyF?KCeauznaWnRvUQqM813&CWomb-wL2a3MS5db zM&6~$yVCM5W{`FBjh;;XcC{X)lF1}1GxfN}WK*?Fcdx6~wHbDsWYcT&hw(3f1L!p> zbHb}xvaC_%rg^z-PHvl>RNLXdb)PElOUwH>k6x7p)9&<* z72&uSL}-|(AbT9Ue7zhn3g=9XDGOv{|M3nfnPXu;b_0p%oOlJ)kbWnJWl zXodBn9B6KbSE=FXe7J2c+?EMhMUbM&gR4S&xP;jb91_g z{+vBe>m5jq^day0N!~pyT<++}dGImOrvfFK5xr|F#AZRusy$1Z&dg$yQHCqO)SU`t z03feFscT-EcYkbqgqpyOJnWgr_l*vZO`OP9oQO~MoEv4%m-pd7(g-XGOew~o5!w1y z9ydJDfZXh?o95*o6yjn*;b1YadojlFI@v~4c54cZ0ivh}mDx4L6Kx_d6V zI}_cjM)$(DYzf(xEeWtKF$k+PQXx(pL5r?_K4??rgEk*-I%l+zCHtiu$udocw#R_f z+CVB3w2V$uz!uc+(1K!YhEL<2WuS=Vk*u>XD+^G3dWmsrn)Rv8+vc0Q=9;@Q&AZj+ z-LB&V-Aa=Qf|yXxMPhB*O~7KBMEg#0MP09H;>5TSs!0ye`GYP~xp z{z;0VU?a8uEV5ZB@xy7~;YDA)e-8`_E*b8Yy#7w2#kC;d_hU+lpxEz6%a;oHCs9jA z&fkUA&&Oc*lK83`IevCD zhGpEAEEkC*nve4dB^wqm#XDA%l^hp)-pP+ijyw0GT*SC}f3zd8$b*q|UdSVML6o_;&mpPEn-RoZd*9KMC`F9Na=w?$H9k|Ds~;>S z3VktmZ%&pH1#`vmHRd+b`FwWWLx~YBsZC~$m9kfl!Lidd`t!_M{mJ}4PI#H?U#G{} zS(OY^%kLj;C8+%=3Ko!*i3*j?fFsdHgAj*{jrioiv1fpt^$(wlXNv~*KGf4aIMl!A zU{AIfai4%y*om`)WN|9%A3QMlNRRSEDw5`5ZkH}!MiMwj0RwlOs~s@e8-u6W!XXsP zxWG)U1QfnF>H>;B8-RTB$fQE10+i>erms=(3I)GO!DR}V(hJG=l-DU>>My@bPfQ2q z4SJ$YJ)3`caP$-vng9=ZEKZ7COgY9vr@uAYyDUINuYNd zjyOu`v`|R)yh(t9D)hwodPOiT;`Nnxur|T_*31-bXh(VzXBK-Q8+GeBCLBLz0q2S%C z%DrispJg@E_t(#>?fZqv2>9kG43WhNCe1CMiK$JUYLnp*A}x2}c9I!{vH;bSK5WIq z>z+wWQ2jCpMyR*gY(YWV;_V;Zjnt#J5I{P`mV^KBkK6 zJ>n|OZ~}=$YE@o7f316_JQHeELyc*V zilHDz^?eeFEl77`+Shoicx|d@p=?76>SWFSU5lPv!Js6OQrA)eZ#dPLth6p|nC|O` z!58rRP7`hn)7Y@Ff}?;I6kLqWui-N=HfXMXy|~i+i@w!)SLxhy$^jbm28}%$H{V&z-HbNUC4Jb2 z@O4kzb5x3Xjza0-s5h1exh-5%$GjJ9qYg6ChrPw04Gi_4c{a{AiR6|~D8r{=2Lj&V zBJ?(bQ?rbYWCO$&5Se8o<-)TQ%Cm{nVBfb{7?Lz!*)G@QSV@UILT6}=>KD=M{BmCj z=RKBLzsy>&vdDmtndYQ*?t&;1>ta$6C|GSGho#d!fZTvp{B~arYzA&e`>cC;s@QBo z%{nKnJF~8glE=0nw(r=ti6YYwC9BMxWDMP(Qa{m2Oty6dSEY;A+^lcS)VHbiZPNoc zL$vbQ7xYI9ax%HHK|rj@(UDVejCV}AhDBp*i}EVHqu_0-a38BMg-Ypf!Pk}cbuIb= z1Vcqo?a41&%=7tILmAA!`i>VrFFtL&U~l=JNX1%!rC8$Mb96A4xP&E+?$Q#$d=VBWsrG(*}EEK(-jb{r5E|M%BI1NK|g1eU7 z&W}PyL<$kL!{uEY0Wc1enxomg-p=HnCGvyhI-`uc6=O_O>?(z9SK-@(j~bq(o!}rn z5a|Xlo~NLmqr8R*kX@mFH{WmARzr9o@X;?)y!_npb3^Ro@eA}8vkRG(Q4D*ld>OCb z#~3D(h)#KK7m@jTe+U%DbpGw4(B*Ac9?uljsztRaGHw6r`B%4X9s%)dO#z$I@S8bA zrqqUpaD3y_Xb;nb=UQLHigCUrrZK+s1xouFdJZTldZyG0?Ssl=6nvI~mngU>p4QRR zuOo1l`W60mm{PEkSlYMw+Yemds@AuxwfV|?y$KthI&pCNy@LqlXF z6lc9C_&NoIW|GNjvJ4CCFdICh;9S|UHs(1>iBfE__DVBcmCc8rQ6yD-lz)l>n!_3l zh)YHST%%wa>?=MjB zCIxR%@D~)kOTqUk_yGmXDhWHl{REZyGzILK+@sV9#E5~5-u=qNnbUC5t1$jf!C`KB zms^Qs7GvpX3)5d@tR4;l89E*s8X6uShrS3I=}KU{2uD^vhaj6j!Prk69MOtrFfHp% zOeL7rl&q{%Us>O&1>FsBFA9%*gbv&MYi@)V`m--4$w#r{kWC25a# zFL=t+`n}+(OzZc8rz&l|7kz%Ia={l$JxqTKzN)l-V<$`XrTTudP*8^5PO3D5g-`{> zr244UmE`jy-!HW+_&U1s6gQ9uzHvR;BAVXF{7(cx<7U_}!5Sb)*Vz(PL*O)R`)}RaT#Fcsx_~ zcq+6|-jFI>3`kNTb%i~cp1MPC?;26^0ZCfB=y4D4u=jV(u}AYfQiT|{#bBNU|FqU1 z;FlmTwgx4hoxRe++Qvn1xde-qEFz75)Y9g3Y4dw18ADo$Yh(*xi@dc-obKaF{@5%*Vaq1!ZyA>!`1CjYa zbS@Ch1U9IF4e7u@y1i$1N^S4?^O3(inQ0$T+XqtJ3xVqCwoIT#4b>RXxC@5PYE oSA&)$NG$VKV2kG)1zp~6OI_ag^UC&Adfu-L?`ikG-zFjaU+YNGy8r+H literal 0 HcmV?d00001 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, }, },