updated client review page
Deploy / deploy (push) Successful in 2m35s

This commit is contained in:
twotalesanimation
2026-06-22 07:48:27 +02:00
parent 835081c1b9
commit ab062a589a
5 changed files with 1777 additions and 1 deletions
+706
View File
@@ -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.
+298
View File
@@ -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.
+724
View File
@@ -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"<b>{label}</b>")
bd["note_font_size"].setValue(18)
bd["tile_color"].setValue(color)
x_positions = [n.xpos() for n in nodes]
y_positions = [n.ypos() for n in nodes]
x = min(x_positions) - margin
y = min(y_positions) - margin
r = max(n.xpos() + n.screenWidth() for n in nodes) + margin
b = max(n.ypos() + n.screenHeight() for n in nodes) + margin
bd.setXYpos(x, y)
bd["bdwidth"].setValue(r - x)
bd["bdheight"].setValue(b - y)
return bd
def _format_exists(name: str) -> bool:
for fmt in nuke.formats():
if fmt.name() == name:
return True
return False
# ── Shot builder ──────────────────────────────────────────────────────────────
def _find_plates(shot_code: str) -> list[str]:
"""
Scan FOOTAGE_ROOT for EXR sequences matching *shot_code*.
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_())
+5
View File
@@ -764,6 +764,11 @@ export default function ClientPortalPage({
</div> </div>
{ver ? ( {ver ? (
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
{ver.createdAt && (
<span className="text-xs text-zinc-600 hidden sm:block">
{formatDate(ver.createdAt)}
</span>
)}
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium', 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
+44 -1
View File
@@ -26,7 +26,10 @@ import {
MessageSquare, MessageSquare,
Send, Send,
Clock, Clock,
ChevronLeft,
ChevronRight, ChevronRight,
Copy,
Check,
} from "lucide-react"; } from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player"; import { useReviewStore } from "@/hooks/use-review-player";
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate"; import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
@@ -103,6 +106,8 @@ export default function ClientReviewPage({
const [submittingApproval, setSubmittingApproval] = useState(false); const [submittingApproval, setSubmittingApproval] = useState(false);
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW"); const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null); const [nextReview, setNextReview] = useState<{ versionId: string; label: string } | null>(null);
const [prevReview, setPrevReview] = useState<{ versionId: string; label: string } | null>(null);
const [copiedTaskName, setCopiedTaskName] = useState(false);
const playerRef = useRef<ReviewPlayerRef>(null); const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast(); const { toast } = useToast();
@@ -119,12 +124,14 @@ export default function ClientReviewPage({
if (!versionId) return; if (!versionId) return;
try { try {
const queueJson = sessionStorage.getItem(QUEUE_KEY); 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 queue: Array<{ versionId: string; label: string }> = JSON.parse(queueJson);
const idx = queue.findIndex((item) => item.versionId === versionId); const idx = queue.findIndex((item) => item.versionId === versionId);
setNextReview(idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null); setNextReview(idx >= 0 && idx < queue.length - 1 ? queue[idx + 1] : null);
setPrevReview(idx > 0 ? queue[idx - 1] : null);
} catch { } catch {
setNextReview(null); setNextReview(null);
setPrevReview(null);
} }
}, [versionId]); }, [versionId]);
@@ -285,6 +292,7 @@ export default function ClientReviewPage({
version.task?.shot?.shotCode ?? version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ?? version.task?.asset?.assetCode ??
null; null;
const taskName = version.task?.title ?? null;
return ( return (
<> <>
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span> <span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
@@ -294,6 +302,28 @@ export default function ClientReviewPage({
<span className="font-mono font-semibold">{contextCode}</span> <span className="font-mono font-semibold">{contextCode}</span>
</> </>
)} )}
{taskName && (
<>
<span className="text-zinc-600">/</span>
<span className="font-mono text-sm text-zinc-300 truncate hidden sm:block">{taskName}</span>
<button
onClick={() => {
navigator.clipboard.writeText(taskName).then(() => {
setCopiedTaskName(true);
setTimeout(() => setCopiedTaskName(false), 1500);
});
}}
className="shrink-0 p-0.5 rounded text-zinc-500 hover:text-zinc-300 transition-colors"
title="Copy task name"
>
{copiedTaskName ? (
<Check className="h-3 w-3 text-emerald-400" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</>
)}
</> </>
); );
})()} })()}
@@ -320,6 +350,19 @@ export default function ClientReviewPage({
{/* Row 2 on mobile / inline on desktop: decision buttons */} {/* Row 2 on mobile / inline on desktop: decision buttons */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{prevReview && (
<>
<Link
href={`/client/${token}/review/${prevReview.versionId}`}
className="inline-flex items-center gap-1 text-sm font-medium text-zinc-300 hover:text-white transition-colors shrink-0"
>
<ChevronLeft className="h-4 w-4" />
Prev
<span className="font-mono text-xs text-zinc-500 hidden sm:inline">{prevReview.label}</span>
</Link>
<div className="h-6 w-px bg-zinc-700" />
</>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"