@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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_())
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user