From ab062a589ac8f909a7f46fc3ef51a7dc75c5f6e6 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:48:27 +0200 Subject: [PATCH] updated client review page --- # VFXReview Connector for Nuke.md | 706 +++++++++++++++++ VFXReviewConnector.md | 298 +++++++ VFXReviewConnector.py | 724 ++++++++++++++++++ app/client/[token]/page.tsx | 5 + .../[token]/review/[versionId]/page.tsx | 45 +- 5 files changed, 1777 insertions(+), 1 deletion(-) create mode 100644 # VFXReview Connector for Nuke.md create mode 100644 VFXReviewConnector.md create mode 100644 VFXReviewConnector.py diff --git a/# VFXReview Connector for Nuke.md b/# VFXReview Connector for Nuke.md new file mode 100644 index 0000000..04a2360 --- /dev/null +++ b/# VFXReview Connector for Nuke.md @@ -0,0 +1,706 @@ +# VFXReview Connector for Nuke + +A dockable PySide-based panel for Foundry Nuke that integrates with the VFXReview platform to streamline shot setup, review publishing, render management, and compositing workflows for VFX production pipelines. + +--- + +# Overview + +The Nuke Connector is designed around a single principle: + +> Artists should not spend time building standard node graphs, locating plates, configuring write nodes, or publishing reviews. + +The connector automates these repetitive tasks and standardises shot structure across the project. + +--- + +# Configuration + +The connector uses the following global configuration variables. + +| Variable | Description | +| ------------ | --------------------------- | +| TOKEN | API authentication token | +| BASE_URL | VFXReview platform URL | +| PROJECT_CODE | Project identifier | +| PROJECT_ROOT | Root project directory | +| FOOTAGE_ROOT | Source footage location | +| EXPORT_ROOT | Published render location | +| PICLOCK_ROOT | Picture lock location | +| NK_ROOT | Nuke script location | +| OCIO_CONFIG | Facility OCIO configuration | + +Example: + +```python +TOKEN = "xxxxx" +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" +NK_ROOT = PROJECT_ROOT + "/production/nuke" + +OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio" +``` + +--- + +# Panel Layout + +The panel is divided into six sections. + +1. Shot Builder +2. Publishing +3. References +4. Utilities +5. Color Management +6. Status + +--- + +# Shot Builder + +--- + +## Episode Dropdown + +Queries: + +```http +GET /api/ext/projects/{PROJECT_CODE}/episodes +``` + +Displays: + +```text +UNG_101 +UNG_102 +UNG_103 +... +``` + +--- + +## Shot Dropdown + +Queries: + +```http +GET /api/ext/projects/{PROJECT_CODE}/shots?episode={episode} +``` + +Displays: + +```text +UNG_106_010_020 +UNG_106_010_030 +UNG_106_010_040 +``` + +--- + +## Build Shot + +Creates a complete Nuke script for the selected shot. + +### Process + +1. Retrieve shot metadata from VFXReview. +2. Locate latest plate sequences. +3. Create Read nodes. +4. Create project Backdrop. +5. Configure project settings. +6. Create standard Write nodes. +7. Create Review branch. +8. Create Viewer. +9. Save script. + +### Script Structure + +```text +PLATES + Read_Plate + | + OCIOColorSpace + | + Dot + | +-------------------------------- +| | +COMP REVIEW +| | +Write_EXR ReviewSlate + | + Write_MOV +``` + +### Script Settings + +```python +fps = 24 +format = UHD +colorManagement = OCIO +``` + +### Save Location + +```text +{NK_ROOT}/{episode}/{shotCode}/{shotCode}_comp_v001.nk +``` + +--- + +## Build Cleanup Script + +Creates a prebuilt cleanup template. + +### Graph + +```text +Read + | +FrameHold + | +RotoPaint + | +Merge + | +Write +``` + +Used for: + +* Reflection removal +* Wire removal +* Crew cleanup +* Sign removal +* Set extensions + +--- + +## Build Projection Script + +Creates a standard projection setup. + +### Graph + +```text +Read + | +Project3D + | +Card + | +ScanlineRender +``` + +Additional nodes: + +```text +Camera +Axis +Scene +``` + +--- + +## Build Screen Comp Script + +Creates a standard monitor replacement setup. + +### Graph + +```text +Read + | +PlanarTracker + | +CornerPin + | +Merge +``` + +Creates placeholders for: + +* Screen source +* Reflection source +* Grade stack + +--- + +# Publishing + +--- + +## Publish EXR + +Creates or updates: + +```text +Write_EXR +``` + +### Settings + +```text +Datatype: 16-bit half +Compression: ZIP +Colorspace: ACEScg +``` + +### Output + +```text +{EXPORT_ROOT}/{shotCode}/{exrOutput}.####.exr +``` + +--- + +## Publish Review MOV + +Creates: + +```text +Write_Review +``` + +### Output + +```text +{EXPORT_ROOT}/{shotCode}_cmp_TT_v001.mov +``` + +### Review Slate + +Automatically inserts: + +```text +ReviewSlate +``` + +Populated from API metadata. + +Fields: + +* Shot Name +* Description +* Notes +* Episode +* Scene +* Date +* Version +* Artist + +--- + +## Publish to VFXReview + +One-click publish. + +### Process + +1. Render review MOV. +2. Generate thumbnail. +3. Upload MOV. +4. Upload thumbnail. +5. Update review status. +6. Attach version notes. + +### API + +```http +POST /api/ext/reviews +``` + +--- + +## Version Up + +Automatically increments: + +```text +UNG_106_010_020_comp_v001.nk +``` + +to + +```text +UNG_106_010_020_comp_v002.nk +``` + +Updates: + +* Script filename +* Write nodes +* Review paths +* Slate version + +--- + +# References + +--- + +## Pull Picture Lock + +Retrieves picture lock reference. + +### Process + +1. Query sequence timecodes. +2. Locate offline. +3. Create Read node. +4. Set frame range. +5. Place in REFERENCE backdrop. + +### Graph + +```text +Read_Piclock +``` + +Positioned separately from comp graph. + +--- + +## Pull Latest Plate + +Scans: + +```text +FOOTAGE_ROOT +``` + +Imports latest available plate version. + +Creates: + +```text +Read_Plate +``` + +--- + +## Pull Latest Render + +Scans render folder. + +Creates Read nodes for: + +```text +beauty +matte +atmos +fx +comp +``` + +where available. + +--- + +## Pull Client Notes + +Queries VFXReview. + +Displays: + +* Description +* Notes +* Latest review comments +* Client feedback + +inside panel. + +--- + +# Utilities + +--- + +## Auto Cryptomatte Setup + +Scans channels. + +Automatically creates: + +```text +Cryptomatte_Object +Cryptomatte_Material +Cryptomatte_Asset +``` + +where available. + +--- + +## Auto Shuffle Setup + +Creates shuffles for common passes. + +Examples: + +```text +rgba +depth +motion +position +normal +specular +diffuse +emission +``` + +--- + +## Import Render Package + +Imports all render layers from a render directory. + +Creates: + +```text +Read nodes +Backdrop +``` + +Organised automatically. + +--- + +## Fix Paths + +Searches entire script. + +Updates: + +```text +old path +``` + +to + +```text +current project path +``` + +Useful when artists move between machines. + +--- + +## Script Health Check + +Scans script for: + +* Missing footage +* Missing renders +* Missing OCIO config +* Disabled Write nodes +* Broken file paths + +Returns report. + +--- + +# Color Management + +--- + +## Configure OCIO + +Sets: + +```python +nuke.root()["colorManagement"].setValue("OCIO") +``` + +Loads facility OCIO config. + +--- + +## Configure Project + +Sets: + +```text +FPS = 24 +Format = UHD +Viewer = ACES 1.0 SDR Video +``` + +or project-specific viewer process. + +--- + +## Apply Colorspaces + +Automatically assigns colorspaces to Read nodes based on: + +* Metadata +* Filename conventions +* Folder structure + +Examples: + +```text +plates = ACES2065-1 +cg = ACEScg +jpg = Utility - sRGB +mov = Rec709 +``` + +--- + +# API Reference + +All requests include: + +```http +Authorization: Bearer {TOKEN} +Accept: application/json +``` + +--- + +## Endpoints + +### Episodes + +```http +GET /api/ext/projects/{PROJECT_CODE}/episodes +``` + +### Shots + +```http +GET /api/ext/projects/{PROJECT_CODE}/shots +``` + +### Shot Lookup + +```http +GET /api/ext/shots/lookup +``` + +### Publish Review + +```http +POST /api/ext/reviews +``` + +### Update Status + +```http +POST /api/ext/shots/status +``` + +--- + +# Shot Metadata Fields + +| Field | Purpose | +| ---------------- | -------------------- | +| shotCode | Canonical identifier | +| exrOutput | EXR publish filename | +| description | Review slate | +| notes | Review slate | +| episode | Metadata | +| scene | Metadata | +| seqTimecodeStart | Picture lock | +| seqTimecodeEnd | Picture lock | +| status | Tracking | +| artist | Review metadata | + +--- + +# Standard Node Backdrops + +The connector automatically creates the following backdrops: + +```text +INPUTS +REFERENCE +COMP +REVIEW +WRITE +UTILITIES +``` + +This ensures every artist works with a consistent node graph layout. + +--- + +# Status Panel + +The bottom panel displays: + +### Line 1 + +```text +Current Shot +``` + +Example: + +```text +UNG_106_010_020 +``` + +### Line 2 + +```text +Current Script Version +``` + +Example: + +```text +v012 +``` + +### Line 3 + +```text +Last Action +``` + +Example: + +```text +Published review successfully +``` + +### Line 4 + +```text +Warnings +``` + +Examples: + +```text +Missing Cryptomatte +Missing Plate +Broken Read Node +``` + +--- + +# Future Features + +* Farm submission +* Deadline integration +* Render progress tracking +* Automatic review generation +* AI-assisted cleanup setup +* AI-assisted screen comp setup +* AI-assisted roto setup +* Automatic shot diagnostics +* Direct VFXReview note syncing +* Multi-show support +* Nuke Studio integration + +The primary goal of the connector is to allow an artist to open a shot and begin compositing immediately with all infrastructure, references, review outputs, and publish paths already configured. diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md new file mode 100644 index 0000000..e272899 --- /dev/null +++ b/VFXReviewConnector.md @@ -0,0 +1,298 @@ +# VFXReview Connector + +A dockable ScriptUI panel for Adobe After Effects 2024+ that integrates with the VFXReview platform to streamline shot management, render queuing, and workspace setup for VFX production pipelines. + +--- + +## Configuration + +At the top of the script, four global variables control how the panel connects to your project: + +| Variable | Default | Description | +|---|---|---| +| `TOKEN` | `am3O0PWU…` | Bearer token used to authenticate all API requests | +| `BASE_URL` | `https://review.twotalesvfx.com` | Base URL of the VFXReview platform | +| `PROJECT_CODE` | `UNG_S1` | Project identifier used in all API endpoints | +| `EXPORT_ROOT` | `V:/_EXPORTS/UNG` | Root path where rendered outputs are written | +| `FOOTAGE_ROOT` | `V:/_FOOTAGE/UNG` | Root path where source EXR footage sequences live | +| `BANNER_IMAGE_PATH` | `V:/VFXReviewConnector/banner.png` | Optional banner image displayed at the top of the panel | + +--- + +## Panel Layout + +The panel is divided into five sections: + +1. **Shot Builder** — Episode/shot selection and comp creation +2. **Actions** — Per-shot and batch operations +3. **Workspace** — One-time project initialisation +4. **Color Space** — OCIO effect management +5. **Status** — Live feedback on selected comps and last action + +--- + +## Shot Builder + +### Episode Dropdown +Populated automatically on panel load by calling `GET /api/ext/projects/{PROJECT_CODE}/episodes`. Each item stores the episode label (e.g. `UNG_106`) and the raw API episode value used for subsequent shot queries. + +### Shot Dropdown +Populated when an episode is selected by calling `GET /api/ext/projects/{PROJECT_CODE}/shots?episode={value}`. Displays shot codes (e.g. `UNG_106_010_020`). + +### Build Shot +Constructs a full shot composite in the open AE project from the episode and shot selected in the dropdowns. + +**Process:** +1. Calls the shot lookup API to retrieve metadata (description, notes, episode, scene, etc.). +2. If a comp with the shot code already exists, updates its overlay instead of rebuilding. +3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence at 24 fps. +4. Creates a `{shotCode}_FOOTAGE` precomp containing all imported sequences, each with an OCIO Color Space Transform effect added (disabled by default). +5. Creates the main `{shotCode}` comp and places the footage precomp inside it. +6. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top. +7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp. +8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders. + +--- + +## Actions + +### Refresh +Updates the **Status** panel to show the current count of selected `CompItem`s. Has no effect on the project. + +--- + +### Fix Comp Names +Renames selected comps so their names match the shot code extracted from their current name. + +- Shot codes follow the pattern `XXX_000_000_000` (e.g. `UNG_106_010_020`). +- If a comp name contains a valid shot code but has extra text, it is trimmed to the code only. +- Wrapped in an undo group. + +--- + +### Add Overlay +Adds or updates the `UNG_VFX_OVERLAY` comp as a layer on each selected comp (or the active comp if nothing is selected). + +**Process:** +1. Looks up each shot via the API to confirm it exists. +2. If the overlay layer is not already present, adds the `UNG_VFX_OVERLAY` comp and moves it to the top of the layer stack. +3. Sets two Essential Properties on the overlay layer: + - **DATE** — today's date in `YYYY/MM/DD` format. + - **SHOT NAME** — `{shotCode}_cmp_TT_v001`. +4. Reports how many overlays were added vs. updated vs. skipped. +5. Wrapped in an undo group. + +**Requirement:** A comp named `UNG_VFX_OVERLAY` must exist in the project. + +--- + +### Build Preview +Creates a `{shotCode}_PREVIEW` comp for each selected (or active) comp using a standardised export template. + +**Process:** +1. Duplicates the `UNG_EXPORT_TEMPLATE` comp and names the copy `{shotCode}_PREVIEW`. +2. Moves the copy into the `_PREVIEWS` project folder if it exists. +3. Replaces the `SHOT` and `THUMBNAIL` layers with the shot comp. +4. Populates the `NETFLIX_SLATE` Essential Properties: + - Shot name (`{shotCode}_cmp_TT_v001`) + - Date, Description, Notes, Shot Code, Episode, Scene, Frame count +5. Adjusts the preview comp duration to match the shot comp duration. +6. Wrapped in an undo group. + +**Requirements:** +- `UNG_EXPORT_TEMPLATE` comp must exist in the project. +- Shot metadata must be available from the API. + +--- + +### Queue EXR +Queues each selected comp for EXR sequence render using the **EXR Sequence** output module template. + +**Process:** +1. Looks up each shot via the API to get the `exrOutput` filename stem. +2. Sets the comp's display start frame to `1001`. +3. Disables any layers named `UNG_VFX_OVERLAY` or `_SHOW LUT` in the comp. +4. Ensures the output folder `EXPORT_ROOT/{shotCode}/` exists. +5. Queues the comp with output path `{folderPath}/{exrOutput}.[#####].exr`. +6. Wrapped in an undo group. + +**Requirement:** The `EXR Sequence` output module template must be configured in AE preferences. + +--- + +### Queue MP4 +Queues a preview render for each selected (or active) comp as an MP4 file. + +- Uses the **REVIEW_PREVIEW** output module template. +- Output path: `EXPORT_ROOT/{shotCode}_cmp_TT_v001.mp4` +- Automatically calls **Build Preview** first if a `{shotCode}_PREVIEW` comp does not exist. +- Updates the overlay on the main shot comp before queuing. +- Wrapped in an undo group. + +**Requirement:** The `REVIEW_PREVIEW` output module template must be configured in AE preferences. + +--- + +### Queue MOV +Identical to Queue MP4 but uses the **4444 Tri** output module template and writes a `.mov` file. + +- Output path: `EXPORT_ROOT/{shotCode}_cmp_TT_v001.mov` + +**Requirement:** The `4444 Tri` output module template must be configured in AE preferences. + +--- + +### Import Renders +Imports rendered EXR sequences for the active/selected shot comp from the shared renders folder. + +**Process:** +1. Resolves the renders folder at `X:/shared_projects_2026/UNGO_VFX/production/renders/{shotCode}`. +2. Iterates subfolders first (separate render passes/layers), importing each as an EXR sequence at 24 fps. Falls back to sequences directly in the root folder. +3. Adds each imported footage item to the `_RENDERS` project folder. +4. Adds all footage as layers to the target comp and applies the **Extractor** effect to each layer. +5. Pre-composes all added layers into a single `{shotCode}_RENDER` comp. +6. Wrapped in an undo group. + +--- + +### Pull Picture Lock +Imports a picture-lock (offline cut) video file and trims it to the correct sequence timecode range for the shot, including handles. + +**Process:** +1. Calls the API to retrieve `seqTimecodeStart` and `seqTimecodeEnd` for the shot. +2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix (first 7 characters of the shot code, e.g. `UNG_106`). +3. If not found, scans `FOOTAGE_ROOT/_PICLOCKS/` for a file matching `{episodePrefix}_*` and imports it. +4. Calculates the in/out offsets from the sequence timecodes relative to the piclock's start (assumed `01:00:00:00` / 3590 s unless embedded timecode is available), with **±8 frame handles**. +5. Adds the piclock footage as a layer at the top of the comp, scales it to fill the frame, and sets `startTime`, `inPoint`, and `outPoint` to isolate the correct range. +6. Wrapped in an undo group. + +**Requirement:** Shot must have `seqTimecodeStart` and `seqTimecodeEnd` populated in the VFXReview database. + +--- + +## Workspace + +### Initialise Workspace +One-time setup that creates the standard project folder hierarchy, imports the slate/overlay template AEP, and configures color settings. + +**Folder structure created:** + +``` +__SHOTS/ + UNG_101/ + UNG_102/ + … + UNG_117/ +_PREVIEWS/ +_PRECOMPS/ +_PICLOCKS/ +_FOOTAGE_4K/ +_STOCK/ +_RENDERS/ +_UTILITIES/ +``` + +**Template import:** +Imports `X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep` as a project merge. + +**Color settings (applied outside the undo group):** +- Sets project bit depth to **32-bit**. +- Enables color management via `app.project.colorSettings.enabled`. +- Attempts to set the working color space to **ACES 1.2** by searching the available space list. Falls back to `"ACES - ACES2065-1"` if the list API is unavailable. If the scripting API does not support this, a message prompts manual configuration. + +--- + +## Color Space + +### Add OCIO to Footage Layers +Adds an **OCIO Color Space Transform** effect (disabled) to every footage layer in each selected (or active) comp. + +- Only targets `FootageItem` layers backed by a `FileSource` (skips solids, comps, etc.). +- Sets the **Output Color Space** property to index `93`. +- The effect is added in a disabled state so it can be enabled selectively during grading. +- Wrapped in an undo group. + +--- + +## API Reference + +All HTTP calls use `system.callSystem` with `curl`. Requests include: + +``` +Authorization: Bearer {TOKEN} +Accept: application/json +``` + +| Endpoint | Method | Description | +|---|---|---| +| `/api/ext/projects/{PROJECT_CODE}/episodes` | GET | List all episodes for the project | +| `/api/ext/projects/{PROJECT_CODE}/shots?episode={value}` | GET | List shots for a given episode | +| `/api/ext/shots/lookup?shotCode={code}&projectCode={code}` | GET | Retrieve full metadata for a single shot | + +### Shot Metadata Fields Used + +| Field | Used By | +|---|---| +| `shotCode` | All operations — canonical identifier | +| `exrOutput` | Queue EXR — filename stem for EXR renders | +| `description` | Build Preview — slate Description property | +| `notes` | Build Preview — slate Notes property | +| `episode` | Build Preview — slate Episode property | +| `scene` | Build Preview — slate Scene property | +| `seqTimecodeStart` | Pull Picture Lock — in-point calculation | +| `seqTimecodeEnd` | Pull Picture Lock — out-point calculation | + +--- + +## Shot Code Convention + +Shot codes follow the pattern `AAA_NNN_NNN_NNN` where: +- `AAA` = three-letter show prefix (e.g. `UNG`) +- First `NNN` = episode number (e.g. `106`) +- Second `NNN` = sequence number (e.g. `010`) +- Third `NNN` = shot number (e.g. `020`) + +Example: `UNG_106_010_020` + +Episode codes follow the pattern `AAA_NNN` (e.g. `UNG_106`). + +--- + +## Required Project Assets + +The following comps/items must exist in the AE project for certain features to work: + +| Name | Type | Required By | +|---|---|---| +| `UNG_VFX_OVERLAY` | Comp | Add Overlay, Build Preview, Queue MP4/MOV | +| `UNG_EXPORT_TEMPLATE` | Comp | Build Preview, Queue MP4/MOV | +| `_SHOW LUT` | Comp | Build Shot (optional — applied if present) | +| `_PREVIEWS` | Folder | Build Preview (optional — used if present) | +| `_PRECOMPS` | Folder | Build Shot (optional — used if present) | +| `_FOOTAGE_4K` | Folder | Build Shot (optional — used if present) | +| `_RENDERS` | Folder | Import Renders (optional — created if absent) | +| `_PICLOCKS` | Folder | Pull Picture Lock (optional — created if absent) | +| `__SHOTS` | Folder | Build Shot / Initialise Workspace | + +--- + +## Output Module Templates Required + +These must be created in **After Effects > Edit > Templates > Output Module** before using the corresponding queue buttons: + +| Template Name | Used By | Format | +|---|---|---| +| `EXR Sequence` | Queue EXR | OpenEXR image sequence | +| `REVIEW_PREVIEW` | Queue MP4 | H.264 / MP4 | +| `4444 Tri` | Queue MOV | ProRes 4444 / MOV | + +--- + +## Status Panel + +The **Status** panel at the bottom of the UI shows two lines: + +- **Line 1** — Number of currently selected `CompItem`s. +- **Line 2** — Result or progress message from the last operation. + +Both lines are updated throughout batch operations so progress is visible in real time. diff --git a/VFXReviewConnector.py b/VFXReviewConnector.py new file mode 100644 index 0000000..a7f2d35 --- /dev/null +++ b/VFXReviewConnector.py @@ -0,0 +1,724 @@ +""" +VFXReview Connector for Nuke +Dockable PySide2 panel — Phase 1 + Phase 2 + +Install: + Copy this file to: + ~/.nuke/VFXReviewConnector.py + Add to ~/.nuke/menu.py: + import VFXReviewConnector + VFXReviewConnector.add_menu() +""" + +from __future__ import annotations + +import json +import os +import re +import threading +import urllib.error +import urllib.request +from typing import Any + +try: + import nuke + import nukescripts + _IN_NUKE = True +except ImportError: + _IN_NUKE = False + +try: + from PySide2 import QtCore, QtGui, QtWidgets + from PySide2.QtCore import Qt, Signal +except ImportError: + from PySide6 import QtCore, QtGui, QtWidgets + from PySide6.QtCore import Qt, Signal + + +# ── Configuration ───────────────────────────────────────────────────────────── +# Edit these values for your facility / show. + +TOKEN = "am3O0PWUtqMJkAqsZ+bO7lho4cxQItxgukF6FHteAx4=" +BASE_URL = "https://review.twotalesvfx.com" +PROJECT_CODE = "UNG_S1" + +PROJECT_ROOT = "X:/shared_projects_2026/UNGO_VFX" +FOOTAGE_ROOT = PROJECT_ROOT + "/production/plates" +EXPORT_ROOT = PROJECT_ROOT + "/production/renders" +PICLOCK_ROOT = PROJECT_ROOT + "/production/piclocks" +NK_ROOT = PROJECT_ROOT + "/production/nuke" + +OCIO_CONFIG = PROJECT_ROOT + "/config/aces/config.ocio" + +# ── API client ──────────────────────────────────────────────────────────────── + + +class APIError(Exception): + pass + + +class VFXReviewAPI: + """Thin wrapper around the VFXReview HTTP API.""" + + def __init__(self, base_url: str = BASE_URL, token: str = TOKEN, project_code: str = PROJECT_CODE): + self.base_url = base_url.rstrip("/") + self.token = token + self.project_code = project_code + + # ── Low-level ───────────────────────────────────────────────────────────── + + def _get(self, path: str, params: dict[str, str] | None = None) -> Any: + """Make a GET request and return parsed JSON.""" + url = self.base_url + path + if params: + query = "&".join( + f"{urllib.parse.quote(k)}={urllib.parse.quote(str(v))}" + for k, v in params.items() + if v is not None + ) + url = url + "?" + query + + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise APIError(f"HTTP {exc.code} — {body}") from exc + except Exception as exc: + raise APIError(str(exc)) from exc + + # ── Endpoints ───────────────────────────────────────────────────────────── + + def get_episodes(self) -> list[dict]: + """Return list of episode dicts for the project.""" + data = self._get(f"/api/ext/projects/{self.project_code}/episodes") + if isinstance(data, list): + return data + return data.get("episodes", []) + + def get_shots(self, episode: str | None = None, sequence: str | None = None) -> list[dict]: + """Return shots for the project, optionally filtered by episode / sequence.""" + params: dict[str, str] = {"limit": "500"} + if episode: + params["episode"] = episode + if sequence: + params["sequence"] = sequence + data = self._get(f"/api/ext/projects/{self.project_code}/shots", params) + return data.get("shots", []) + + def lookup_shot(self, shot_code: str) -> dict | None: + """Return full shot metadata dict, or None if not found.""" + try: + data = self._get("/api/ext/shots/lookup", { + "shotCode": shot_code, + "projectCode": self.project_code, + }) + return data.get("shot") or data + except APIError: + return None + + def test_connection(self) -> tuple[bool, str]: + """Return (ok, message) — used by the panel health-check.""" + try: + self.get_episodes() + return True, "Connected" + except APIError as exc: + return False, str(exc) + + +# ── urllib.parse shim — included so the module works without extra imports ──── +import urllib.parse # noqa: E402 (already in stdlib, just ensuring it's imported) + + +# ── Nuke helpers ───────────────────────────────────────────────────────────── + + +def _safe(name: str) -> str: + """Return a Nuke-safe node name from an arbitrary string.""" + return re.sub(r"[^A-Za-z0-9_]", "_", name) + + +def _find_node(name: str) -> "nuke.Node | None": + """Return the first node whose name starts with *name*, or None.""" + for node in nuke.allNodes(): + if node.name().startswith(name): + return node + return None + + +def _create_backdrop(label: str, nodes: list, color: int = 0x2A2A3AFF, + margin: int = 40) -> "nuke.Node": + """Create a backdrop that encloses *nodes*.""" + if not nodes: + return None + bd = nuke.nodes.BackdropNode() + bd["label"].setValue(f"{label}") + bd["note_font_size"].setValue(18) + bd["tile_color"].setValue(color) + + x_positions = [n.xpos() for n in nodes] + y_positions = [n.ypos() for n in nodes] + x = min(x_positions) - margin + y = min(y_positions) - margin + r = max(n.xpos() + n.screenWidth() for n in nodes) + margin + b = max(n.ypos() + n.screenHeight() for n in nodes) + margin + bd.setXYpos(x, y) + bd["bdwidth"].setValue(r - x) + bd["bdheight"].setValue(b - y) + return bd + + +def _format_exists(name: str) -> bool: + for fmt in nuke.formats(): + if fmt.name() == name: + return True + return False + + +# ── Shot builder ────────────────────────────────────────────────────────────── + + +def _find_plates(shot_code: str) -> list[str]: + """ + Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*. + Returns a list of first-frame file paths. + """ + episode = shot_code[:7] # e.g. UNG_106 + shot_dir = os.path.join(FOOTAGE_ROOT, episode, shot_code) + results = [] + + 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 + + results.extend(_scan_for_exr(shot_dir)) + return results + + +def _scan_for_exr(directory: str) -> list[str]: + """Return the first frame path of every EXR sequence found in *directory*.""" + results = [] + try: + files = sorted(f for f in os.listdir(directory) if f.lower().endswith(".exr")) + except OSError: + return results + + seen_stems: set[str] = set() + for f in files: + stem = re.sub(r"[\._]\d+\.exr$", "", f, flags=re.IGNORECASE) + if stem not in seen_stems: + seen_stems.add(stem) + results.append(os.path.join(directory, f)) + return results + + +def _nuke_sequence_path(first_frame_path: str) -> str: + """Convert a concrete first-frame path to a Nuke ####.exr pattern.""" + return re.sub(r"(\d+)(\.exr)$", lambda m: "#" * len(m.group(1)) + m.group(2), + first_frame_path, flags=re.IGNORECASE) + + +def build_shot(shot_code: str, api: VFXReviewAPI) -> bool: + """ + Build a complete Nuke shot script for *shot_code*. + + - Fetches metadata from VFXReview + - Locates plates on disk + - Creates Read → OCIOColorSpace nodes + - Creates Write_EXR and Write_Review nodes + - Saves the script + + Returns True on success. + """ + if not _IN_NUKE: + print(f"[VFXReview] Would build shot: {shot_code}") + return False + + # ── Fetch metadata ──────────────────────────────────────────────────────── + shot = api.lookup_shot(shot_code) + if not shot: + nuke.message(f"Shot not found in VFXReview:\n{shot_code}") + return False + + episode = shot.get("episode", "") + 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) + script_path = os.path.join(script_dir, f"{shot_code}_comp_v001.nk") + + os.makedirs(script_dir, exist_ok=True) + + # ── Project settings ────────────────────────────────────────────────────── + root = nuke.root() + root["fps"].setValue(fps) + root["first_frame"].setValue(frame_start) + root["last_frame"].setValue(frame_end) + + if os.path.isfile(OCIO_CONFIG): + root["colorManagement"].setValue("OCIO") + root["OCIO_config"].setValue("custom") + root["customOCIOConfigPath"].setValue(OCIO_CONFIG) + + # ── Locate plates ───────────────────────────────────────────────────────── + plate_paths = _find_plates(shot_code) + + read_nodes: list[nuke.Node] = [] + x_cursor = 0 + + 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) + + 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()}")) + + 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 ───────────────────────────────────────────────────────────── + 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("\\", "/") + + 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 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) + + # ── Save script ─────────────────────────────────────────────────────────── + nuke.scriptSaveAs(script_path, overwrite=False) + + return True + + +# ── Panel ───────────────────────────────────────────────────────────────────── + + +class StatusBar(QtWidgets.QWidget): + """Three-line status bar shown at the bottom of the panel.""" + + def __init__(self, parent=None): + super().__init__(parent) + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(6, 4, 6, 4) + layout.setSpacing(2) + + self._shot_label = QtWidgets.QLabel("No shot loaded") + self._action_label = QtWidgets.QLabel("Ready") + self._warn_label = QtWidgets.QLabel("") + + for lbl in (self._shot_label, self._action_label, self._warn_label): + lbl.setWordWrap(True) + layout.addWidget(lbl) + + self._warn_label.setStyleSheet("color: #E8A020;") + + frame = QtWidgets.QFrame() + frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + frame_layout = QtWidgets.QVBoxLayout(frame) + frame_layout.setContentsMargins(0, 0, 0, 0) + frame_layout.addWidget(self) + self._frame = frame + + def set_shot(self, text: str): + self._shot_label.setText(f"Shot: {text}") + + def set_action(self, text: str): + self._action_label.setText(text) + + def set_warning(self, text: str): + self._warn_label.setText(text) + self._warn_label.setVisible(bool(text)) + + def clear_warning(self): + self.set_warning("") + + +class ConnectionWidget(QtWidgets.QGroupBox): + """Shows live API connection status with a test button.""" + + def __init__(self, api: VFXReviewAPI, parent=None): + super().__init__("Connection", parent) + self._api = api + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + + self._indicator = QtWidgets.QLabel("●") + self._indicator.setFixedWidth(16) + self._status = QtWidgets.QLabel("Not tested") + self._test_btn = QtWidgets.QPushButton("Test") + self._test_btn.setFixedWidth(50) + self._test_btn.clicked.connect(self._test) + + layout.addWidget(self._indicator) + layout.addWidget(self._status, 1) + layout.addWidget(self._test_btn) + + self._set_state(None) + + def _set_state(self, ok: bool | None, message: str = ""): + if ok is None: + color, text = "#888888", "Not tested" + elif ok: + color, text = "#44CC44", f"OK — {message}" + else: + color, text = "#CC4444", message or "Failed" + self._indicator.setStyleSheet(f"color: {color}; font-size: 14px;") + self._status.setText(text) + + def _test(self): + self._status.setText("Testing…") + QtWidgets.QApplication.processEvents() + ok, msg = self._api.test_connection() + self._set_state(ok, msg) + + +class ShotBuilderWidget(QtWidgets.QGroupBox): + """Episode → Shot dropdowns + Build Shot button.""" + + shot_built = Signal(str) # emitted with shot_code on success + status_msg = Signal(str) # status line updates + warning_msg = Signal(str) # warning line updates + + def __init__(self, api: VFXReviewAPI, parent=None): + super().__init__("Shot Builder", parent) + self._api = api + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + # ── Row 1: episode + shot dropdowns ────────────────────────────────── + row1 = QtWidgets.QHBoxLayout() + self._episode_combo = QtWidgets.QComboBox() + self._episode_combo.setMinimumWidth(90) + self._episode_combo.setToolTip("Episode") + self._shot_combo = QtWidgets.QComboBox() + self._shot_combo.setMinimumWidth(160) + self._shot_combo.setToolTip("Shot code") + row1.addWidget(self._episode_combo) + row1.addWidget(self._shot_combo, 1) + layout.addLayout(row1) + + # ── Row 2: action buttons ───────────────────────────────────────────── + row2 = QtWidgets.QHBoxLayout() + self._refresh_btn = QtWidgets.QPushButton("Refresh") + self._build_btn = QtWidgets.QPushButton("Build Shot") + self._build_btn.setDefault(True) + row2.addWidget(self._refresh_btn) + row2.addWidget(self._build_btn) + layout.addLayout(row2) + + # ── Row 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) + + # ── 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) + + # ── Public ──────────────────────────────────────────────────────────────── + + def load(self): + """Populate the episode dropdown (call once after the panel is shown).""" + self._load_episodes() + + # ── Private ─────────────────────────────────────────────────────────────── + + def _load_episodes(self): + self._episode_combo.blockSignals(True) + self._episode_combo.clear() + self.status_msg.emit("Loading episodes…") + QtWidgets.QApplication.processEvents() + + try: + episodes = self._api.get_episodes() + except APIError as exc: + self.warning_msg.emit(f"API error: {exc}") + self.status_msg.emit("Failed to load episodes") + self._episode_combo.blockSignals(False) + return + + for ep in episodes: + ep_val = str(ep.get("episode") or ep.get("code") or ep) + ep_label = ep_val if len(ep_val) > 3 else f"UNG_{ep_val.zfill(3)}" + self._episode_combo.addItem(ep_label, userData=ep_val) + + self._episode_combo.blockSignals(False) + if self._episode_combo.count(): + self._episode_combo.setCurrentIndex(0) + self._on_episode_changed(0) + else: + self.status_msg.emit("No episodes found") + + def _on_episode_changed(self, _index: int): + ep_val = self._episode_combo.currentData() + if ep_val is None: + return + self._shot_combo.clear() + self.status_msg.emit(f"Loading shots for episode {ep_val}…") + QtWidgets.QApplication.processEvents() + + try: + shots = self._api.get_shots(episode=str(ep_val)) + except APIError as exc: + self.warning_msg.emit(f"API error: {exc}") + self.status_msg.emit("Failed to load shots") + return + + for shot in shots: + code = shot.get("shotCode", "") + if code: + self._shot_combo.addItem(code, userData=shot) + + count = self._shot_combo.count() + self.status_msg.emit(f"{count} shot(s) for episode {ep_val}") + self.warning_msg.emit("") + if count: + self._shot_combo.setCurrentIndex(0) + self._on_shot_changed(0) + + def _on_shot_changed(self, _index: int): + shot = self._shot_combo.currentData() + if not shot: + self._info_label.setText("") + return + parts = [] + if shot.get("description"): + parts.append(shot["description"]) + fr = shot.get("frameStart") + to = shot.get("frameEnd") + if fr and to: + parts.append(f"Frames {fr}–{to}") + status = shot.get("status", "") + if status: + parts.append(status.replace("_", " ").title()) + self._info_label.setText(" | ".join(parts)) + + def _build(self): + shot_data = self._shot_combo.currentData() + if not shot_data: + self.warning_msg.emit("Select a shot first") + return + + shot_code = shot_data.get("shotCode", "") + if not shot_code: + self.warning_msg.emit("Invalid shot selection") + return + + self.status_msg.emit(f"Building {shot_code}…") + self._build_btn.setEnabled(False) + QtWidgets.QApplication.processEvents() + + try: + ok = build_shot(shot_code, self._api) + except Exception as exc: + self.warning_msg.emit(str(exc)) + self.status_msg.emit("Build failed") + self._build_btn.setEnabled(True) + return + + self._build_btn.setEnabled(True) + if ok: + self.shot_built.emit(shot_code) + self.status_msg.emit(f"Built: {shot_code}") + self.warning_msg.emit("") + else: + self.status_msg.emit("Build cancelled or failed") + + +class VFXReviewPanel(QtWidgets.QWidget): + """Main dockable panel widget.""" + + TITLE = "VFXReview Connector" + OBJECT_NAME = "VFXReviewConnectorPanel" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName(self.OBJECT_NAME) + self.setWindowTitle(self.TITLE) + self.setMinimumWidth(300) + + self._api = VFXReviewAPI() + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + + # ── Connection ──────────────────────────────────────────────────────── + self._connection = ConnectionWidget(self._api) + layout.addWidget(self._connection) + + # ── Shot Builder ────────────────────────────────────────────────────── + self._shot_builder = ShotBuilderWidget(self._api) + layout.addWidget(self._shot_builder) + + # ── Spacer ──────────────────────────────────────────────────────────── + layout.addStretch(1) + + # ── Status bar ──────────────────────────────────────────────────────── + self._status = StatusBar() + frame = QtWidgets.QFrame() + frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + frame_layout = QtWidgets.QVBoxLayout(frame) + frame_layout.setContentsMargins(0, 0, 0, 0) + frame_layout.addWidget(self._status) + layout.addWidget(frame) + + # ── Wire inter-widget signals ───────────────────────────────────────── + self._shot_builder.shot_built.connect(self._status.set_shot) + self._shot_builder.status_msg.connect(self._status.set_action) + self._shot_builder.warning_msg.connect(self._status.set_warning) + + # Load episodes asynchronously after the panel is shown + QtCore.QTimer.singleShot(200, self._shot_builder.load) + + def sizeHint(self): + return QtCore.QSize(320, 480) + + +# ── Nuke integration ────────────────────────────────────────────────────────── + + +_panel_instance: VFXReviewPanel | None = None + + +def show_panel(): + """Create (or raise) the floating panel.""" + global _panel_instance + if _panel_instance is None or not _panel_instance.isVisible(): + _panel_instance = VFXReviewPanel() + _panel_instance.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + _panel_instance.show() + else: + _panel_instance.raise_() + _panel_instance.activateWindow() + + +class _NukePanel(nukescripts.PythonPanel if _IN_NUKE else object): + """Wrapper so Nuke can dock the panel in its workspace.""" + + def __init__(self): + if not _IN_NUKE: + return + nukescripts.PythonPanel.__init__( + self, VFXReviewPanel.TITLE, VFXReviewPanel.OBJECT_NAME + ) + self._widget = VFXReviewPanel() + self.customKnob = nuke.PyCustom_Knob( + VFXReviewPanel.OBJECT_NAME, "", "_NukePanel._get_widget()" + ) + self.addKnob(self.customKnob) + + @staticmethod + def _get_widget(): + global _panel_instance + if _panel_instance is None: + _panel_instance = VFXReviewPanel() + return _panel_instance + + +def add_menu(): + """Called from menu.py to register the panel in the Nuke menus.""" + if not _IN_NUKE: + return + + menu = nuke.menu("Nuke") + vfx_menu = menu.addMenu("VFXReview") + vfx_menu.addCommand( + "Open Connector", + "import VFXReviewConnector; VFXReviewConnector.show_panel()", + "F8", + ) + # Also register as a dockable pane + nukescripts.registerPanel( + VFXReviewPanel.OBJECT_NAME, + "import VFXReviewConnector; return VFXReviewConnector._NukePanel()", + ) + + +# ── Standalone test (run outside Nuke with: python VFXReviewConnector.py) ───── + +if __name__ == "__main__": + import sys + + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + win = VFXReviewPanel() + win.setWindowFlags(Qt.Window) + win.show() + sys.exit(app.exec_()) diff --git a/app/client/[token]/page.tsx b/app/client/[token]/page.tsx index 7e7f338..e0b2eda 100644 --- a/app/client/[token]/page.tsx +++ b/app/client/[token]/page.tsx @@ -764,6 +764,11 @@ export default function ClientPortalPage({ {ver ? (
+ {ver.createdAt && ( + + {formatDate(ver.createdAt)} + + )} (null); + const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null); + const [copiedTaskName, setCopiedTaskName] = useState(false); const playerRef = useRef(null); const { toast } = useToast(); @@ -119,12 +124,14 @@ export default function ClientReviewPage({ if (!versionId) return; try { const queueJson = sessionStorage.getItem(QUEUE_KEY); - if (!queueJson) { setNextReview(null); return; } + if (!queueJson) { setNextReview(null); setPrevReview(null); return; } const queue: Array<{ versionId: string; label: string }> = JSON.parse(queueJson); const idx = queue.findIndex((item) => item.versionId === versionId); setNextReview(idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null); + setPrevReview(idx > 0 ? queue[idx - 1] : null); } catch { setNextReview(null); + setPrevReview(null); } }, [versionId]); @@ -285,6 +292,7 @@ export default function ClientReviewPage({ version.task?.shot?.shotCode ?? version.task?.asset?.assetCode ?? null; + const taskName = version.task?.title ?? null; return ( <> {project?.code} @@ -294,6 +302,28 @@ export default function ClientReviewPage({ {contextCode} )} + {taskName && ( + <> + / + {taskName} + + + )} ); })()} @@ -320,6 +350,19 @@ export default function ClientReviewPage({ {/* Row 2 on mobile / inline on desktop: decision buttons */}
+ {prevReview && ( + <> + + + Prev + {prevReview.label} + +
+ + )}