6678721fb1
Deploy / deploy (push) Successful in 6m48s
A shot with no COMP task rendered and previewed fine and then showed
nothing in the app. createExport set Export.taskId from the shot's first
COMP task or null, and finalizePreview registered the review Version with
that same null taskId — so the MP4 was uploaded and stored, but the
Version hung off no task and none of the review flows had anywhere to
draw it. From the artist's side the export completed and produced
nothing.
E1 now resolves the shot's comp task inside the version-allocation
transaction and creates one when there isn't any, titled after the
preview file the export will produce: {shotCode}_cmp_TT_{version}, the
same convention the manual batch upload's create-task path already uses.
renderOutputBase() is exported and buildPreviewManifest takes its `base`
from it, so the task title and the MOV/MP4 name come from one function
rather than two copies of the same template. The title therefore already
carries the version, which makes finalizePreview's rename-to-the-
delivered-version a no-op on that first export rather than an immediate
rename of a task created moments earlier.
The lookup and the create sit inside the transaction so the P2002
version-collision retry rolls a losing attempt's task back with
everything else, instead of leaving a duplicate behind.
recalcShotStatus runs only when a task was actually created: a shot with
no tasks derives WAITING and now has one. Approval-driven statuses still
take precedence, so an already-approved shot stays COMPLETE.
Both the queue-time ExportEvent note and the E1 response record what
happened, so a task appearing under a shot on its own is traceable to the
export that made it.
Exports already in flight with a null taskId keep their orphaned
versions; backfilling those is a separate migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1520 lines
96 KiB
Markdown
1520 lines
96 KiB
Markdown
# Render Pipeline 2 — Implementation Specification
|
||
|
||
Status: Approved for implementation — **all design decisions resolved** (Section 18.1); Section 18.2 is a checklist of values to collect during implementation
|
||
Author: Lead Architect
|
||
Date: 2026-07-31 (rev 3 — final studio decisions folded in)
|
||
Source of truth for current state: `TECHNICAL_ARCHITECTURE_REPORT_CURRENT_STATE.md`, `EXT_API_REFERENCE.md`
|
||
|
||
---
|
||
|
||
## 1. Purpose and Scope
|
||
|
||
### 1.1 Goal
|
||
|
||
Reduce the artist workflow to:
|
||
|
||
```
|
||
Artist finishes comp → clicks "Queue Export" → everything else is automatic
|
||
```
|
||
|
||
The pipeline automates: version increment, rendering (aerender), output validation, preview/thumbnail/metadata generation, QC queueing, and Netflix-style delivery package construction. The only remaining manual steps are: building the comp, visually QC-ing the render, and uploading the finished delivery folder to Content Hub (no Content Hub API exists today).
|
||
|
||
### 1.2 What this document is
|
||
|
||
A complete implementation blueprint. Future AI coding agents implement it phase-by-phase (Section 17) without needing further architectural decisions — every design question has been answered by the studio and recorded in Section 18.1; Section 18.2 lists the concrete values and verifications to collect during implementation.
|
||
|
||
### 1.3 What this document is not
|
||
|
||
- Not a redesign of VFXReview. Every existing model, route, and workflow stays intact.
|
||
- Not production code. Schema and payloads below are normative in shape, not character-exact.
|
||
|
||
### 1.4 New capabilities introduced
|
||
|
||
| Capability | Today | After |
|
||
|---|---|---|
|
||
| Render execution | Artist's AE render queue, manual | `RenderWorker` Windows service driving `aerender.exe` |
|
||
| Versioning | Artist clicks "Increment Version" in panel | Automatic on Queue Export |
|
||
| Output validation | None | Automatic frame/metadata/EXR validation |
|
||
| Preview MOV / thumbnail | AE render queue templates, manual | Automatic (worker, ffmpeg) |
|
||
| QC | Ad hoc | Explicit QC queue + Pass/Fail from AE panel |
|
||
| Delivery | PowerShell copy scripts from AE panel | `Build Netflix Delivery` button, DB-driven package builder |
|
||
| Delivery history | None | Permanent `DeliveryPackage` records |
|
||
|
||
---
|
||
|
||
## 2. Guiding Principles and Reuse Map
|
||
|
||
### 2.1 Principles
|
||
|
||
1. **Extend, never replace.** New models reference existing ones by foreign key; no existing column is removed or repurposed.
|
||
2. **The database is the queue.** No Redis/RabbitMQ/BullMQ. PostgreSQL row-level atomic claims are sufficient at this scale (tens of jobs/day, a handful of workers). This is the single most important simplicity decision in this spec.
|
||
3. **The worker owns the filesystem; the server owns the truth.** Workers touch EXRs on the shared render root. The web app never reads render output directly — it trusts worker reports persisted in the DB. The app server (Docker) is never required to mount the SAN.
|
||
4. **Workers talk only HTTP.** Workers never get a `DATABASE_URL`. All state flows through `/api/ext/*` using the existing API-key auth. This is what makes "add more render machines later without changing the API" free.
|
||
5. **Previews become Versions.** The generated MOV preview is registered as a normal `Version` row on the shot's comp task. The entire existing review stack (player, comments, annotations, client portal) works on pipeline renders with zero changes.
|
||
6. **Idempotent, resumable, observable.** Every worker→server report is safe to repeat; every job can be retried; every state change is timestamped and attributable.
|
||
|
||
### 2.2 Reuse map
|
||
|
||
| Existing asset | Reused for |
|
||
|---|---|
|
||
| `/api/ext/*` + `API_SECRET_KEY` bearer auth | All worker and AE-panel endpoints (Section 15 adds per-machine keys as an option) |
|
||
| `Shot.shotVersion`, `Shot.exrOutput` | Remain the canonical "current version" mirror; pipeline updates them on Queue Export exactly as the panel's PATCH does today |
|
||
| `Version` model + upload/review flows | Preview MOVs are created as Versions; internal/client review unchanged |
|
||
| `lib/storage.ts` abstraction | Upload of preview MOV, thumbnail, metadata JSON, delivery reports |
|
||
| `/api/files/{...key}` | Serving previews/thumbnails as today |
|
||
| `lib/frame-utils.ts`, `lib/edl-utils.ts` | Frame counting, timecode math in validation and manifest building |
|
||
| `SystemConfig` table | Pipeline-wide settings (poll intervals, lease durations, tool paths defaults) |
|
||
| NextAuth sessions | All new internal UI pages and internal APIs |
|
||
| Existing ext API conventions (JSON errors, Zod validation, pagination shape) | All new endpoints |
|
||
| AE panel shot lookup / episode / shot list endpoints | Unchanged; panel keeps using them |
|
||
|
||
### 2.3 Explicit non-goals
|
||
|
||
- Uploading EXR sequences to object storage (they stay on the shared filesystem; studio decision 18.1-Q2).
|
||
- Deadline/Tractor-style generic render farm. This is an AE-only, purpose-built queue.
|
||
- Automatic Content Hub upload (no API exists; manual step stays).
|
||
|
||
---
|
||
|
||
## 3. System Overview
|
||
|
||
### 3.1 Component diagram
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph Workstation["Artist Workstation"]
|
||
AE["After Effects + VFXReview Panel (CEP/ExtendScript)"]
|
||
end
|
||
|
||
subgraph Server["VFXReview (Next.js + Prisma + PostgreSQL)"]
|
||
EXT["/api/ext/* (API-key auth)"]
|
||
INT["/api/* internal (NextAuth)"]
|
||
DB[(PostgreSQL)]
|
||
ST["Storage abstraction lib/storage.ts"]
|
||
end
|
||
|
||
subgraph RenderNode["Render Machine(s) — Windows"]
|
||
W["VFXReview RenderWorker (Windows Service)"]
|
||
AER["aerender.exe"]
|
||
FF["ffmpeg / oiiotool"]
|
||
end
|
||
|
||
SAN[("Shared filesystem (projects, AEPs, render roots, delivery roots)")]
|
||
OBJ[("Object storage (Hetzner / S3-compat)")]
|
||
UI["Web UI (Render Queue, QC, Deliveries, Machines)"]
|
||
|
||
AE -- "Queue Export, QC Pass/Fail" --> EXT
|
||
W -- "claim / progress / validate / complete / heartbeat" --> EXT
|
||
EXT --> DB
|
||
INT --> DB
|
||
UI --> INT
|
||
W --> AER
|
||
W --> FF
|
||
AER --> SAN
|
||
FF --> SAN
|
||
AE --> SAN
|
||
W -- "preview MOV / thumb / metadata upload" --> EXT
|
||
EXT --> ST --> OBJ
|
||
```
|
||
|
||
### 3.2 Happy-path sequence
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
participant A as AE Panel
|
||
participant S as VFXReview API
|
||
participant W as RenderWorker
|
||
participant F as Shared FS
|
||
|
||
A->>S: POST /api/ext/exports (manifest)
|
||
S->>S: increment Shot.shotVersion, create Export(QUEUED) + RenderJob(attempt 1)
|
||
S-->>A: exportId, versionString v004
|
||
loop poll (default 10 s)
|
||
W->>S: POST /api/ext/render/jobs/claim
|
||
end
|
||
S-->>W: job + manifest (Export → RENDERING)
|
||
W->>F: launch aerender.exe, parse stdout
|
||
W->>S: PATCH progress (frame N of M, ETA)
|
||
W->>S: POST complete-render (Export → VALIDATING)
|
||
W->>F: scan EXR sequence, run checks
|
||
W->>S: POST validation results (pass) (Export → GENERATING_PREVIEW)
|
||
W->>F: ffmpeg MOV + thumbnail + metadata JSON
|
||
W->>S: presign + upload preview artifacts
|
||
W->>S: POST finalize (Export → READY_FOR_QC, Version row created)
|
||
A->>S: GET /api/ext/qc/queue → artist QCs in AE
|
||
A->>S: POST /api/ext/exports/{id}/qc {result: PASS}
|
||
S->>S: Export → READY_FOR_DELIVERY
|
||
Note over S: Coordinator clicks "Build Netflix Delivery" in web UI
|
||
S->>S: DeliveryPackage(QUEUED) + DeliveryItems (version-locked)
|
||
W->>S: claim build job
|
||
W->>F: hard-link/copy EXRs + MOVs, write report + manifest
|
||
W->>S: POST package complete (PACKAGED)
|
||
Note over S: Human uploads folder to Content Hub, clicks "Mark Delivered"
|
||
S->>S: Package DELIVERED, items' Exports → DELIVERED
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Export Lifecycle State Machine
|
||
|
||
The state machine lives on **`Export`** (not on `Shot`, not on `RenderJob`). `Shot.status` remains derived exactly as today; `RenderJob` has its own small execution status. "Working" is the implicit pre-state before any `Export` row exists.
|
||
|
||
### 4.1 State diagram
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> QUEUED : Queue Export (AE panel)
|
||
QUEUED --> RENDERING : worker claims job
|
||
QUEUED --> CANCELLED : user cancels
|
||
RENDERING --> RENDER_FAILED : aerender error / crash / lease expiry after max retries
|
||
RENDERING --> QUEUED : lease expired, retries remain (new attempt)
|
||
RENDERING --> VALIDATING : aerender exit 0
|
||
RENDERING --> CANCELLED : user cancels (worker kills process)
|
||
RENDER_FAILED --> QUEUED : Retry (new RenderJob attempt)
|
||
VALIDATING --> VALIDATION_FAILED : any check fails
|
||
VALIDATING --> GENERATING_PREVIEW : all checks pass
|
||
VALIDATION_FAILED --> QUEUED : Retry (re-render)
|
||
GENERATING_PREVIEW --> PREVIEW_FAILED : ffmpeg/upload error
|
||
PREVIEW_FAILED --> GENERATING_PREVIEW : Retry preview only
|
||
GENERATING_PREVIEW --> READY_FOR_QC : preview Version registered
|
||
READY_FOR_QC --> QC_FAILED : QC Fail
|
||
READY_FOR_QC --> READY_FOR_DELIVERY : QC Pass
|
||
QC_FAILED --> [*] : artist fixes comp, queues new Export (this one → SUPERSEDED)
|
||
READY_FOR_DELIVERY --> PACKAGED : included in a built DeliveryPackage
|
||
PACKAGED --> DELIVERED : package marked delivered
|
||
READY_FOR_DELIVERY --> SUPERSEDED : newer Export passes QC
|
||
READY_FOR_QC --> SUPERSEDED : newer Export queued for same shot
|
||
QUEUED --> SUPERSEDED : newer Export queued for same shot
|
||
DELIVERED --> ARCHIVED : archival policy / manual
|
||
CANCELLED --> [*]
|
||
SUPERSEDED --> [*]
|
||
ARCHIVED --> [*]
|
||
```
|
||
|
||
### 4.2 Transition table (normative)
|
||
|
||
| # | From | To | Trigger | Actor | Side effects |
|
||
|---|---|---|---|---|---|
|
||
| T1 | *(Working)* | QUEUED | `POST /api/ext/exports` | AE panel | `Shot.shotVersion` incremented; `Shot.exrOutput` updated; `RenderJob` attempt 1 created; any older non-terminal Export for the shot → SUPERSEDED |
|
||
| T2 | QUEUED | RENDERING | Atomic claim | Worker | `RenderJob.machineId/claimedAt/leaseExpiresAt` set |
|
||
| T3 | RENDERING | VALIDATING | `complete-render` report, exit 0 | Worker | render duration recorded |
|
||
| T4 | RENDERING | RENDER_FAILED | `fail` report or lease expiry with `attempt >= maxAttempts` | Worker / reaper | stderr tail + exit code stored on RenderJob |
|
||
| T5 | RENDERING | QUEUED | Lease expiry, `attempt < maxAttempts` | Server reaper | new `RenderJob` row, attempt+1 |
|
||
| T6 | RENDER_FAILED | QUEUED | Retry button / `retry` endpoint | User (web/panel) | new `RenderJob` attempt |
|
||
| T7 | VALIDATING | VALIDATION_FAILED | Validation report contains a FAIL | Worker | `ValidationResult` rows persisted; pipeline stops |
|
||
| T8 | VALIDATING | GENERATING_PREVIEW | All checks PASS/WARN | Worker | `ValidationResult` rows persisted |
|
||
| T9 | VALIDATION_FAILED | QUEUED | Retry (full re-render) | User | new attempt; old validation rows kept for history |
|
||
| T10 | GENERATING_PREVIEW | PREVIEW_FAILED | ffmpeg/upload failure | Worker | error stored |
|
||
| T11 | PREVIEW_FAILED | GENERATING_PREVIEW | Retry preview | User | validation is NOT re-run |
|
||
| T12 | GENERATING_PREVIEW | READY_FOR_QC | `finalize` report | Worker | `Version` row created (isLatest, **never client-shared**), thumbnail set, metadata JSON key stored; `Shot.generatedThumbnailUrl` set when the project opts in (§9.1a) — never `Shot.thumbnailUrl`; **no task/shot status change** — QC typically runs on already client-approved shots and must not disturb review state (Section 10) |
|
||
| T13 | READY_FOR_QC | QC_FAILED | `POST .../qc {FAIL}` | Artist (AE) or user (web) | `QCReview` row; task → CHANGES (drives existing Shot REVISIONS derivation) |
|
||
| T14 | READY_FOR_QC | READY_FOR_DELIVERY | `POST .../qc {PASS}` | Artist / user | `QCReview` row |
|
||
| T15 | READY_FOR_DELIVERY | PACKAGED | Delivery build completes | Worker | `DeliveryItem` links Export to package |
|
||
| T16 | PACKAGED | DELIVERED | "Mark Delivered" | User (web) | `DeliveryPackage.deliveredAt/By` set |
|
||
| T17 | QUEUED / READY_FOR_QC / READY_FOR_DELIVERY / QC_FAILED | SUPERSEDED | Newer Export queued (T1) or newer Export reaches READY_FOR_DELIVERY | Server | automatic; PACKAGED/DELIVERED Exports are never auto-superseded (delivery history is immutable) |
|
||
| T18 | QUEUED / RENDERING | CANCELLED | Cancel endpoint | User | worker told to kill via claim-refresh response |
|
||
| T19 | DELIVERED | ARCHIVED | Manual / retention job | Admin | terminal |
|
||
|
||
Rules:
|
||
|
||
- **Terminal states:** CANCELLED, SUPERSEDED, ARCHIVED. DELIVERED is terminal except T19.
|
||
- **Illegal transitions are rejected server-side** with `409 { "error": "Invalid transition RENDERING → READY_FOR_QC" }`. The server is the sole authority on state; workers *request* transitions.
|
||
- Every transition writes `Export.statusChangedAt` and appends to `ExportEvent` (Section 5.9) for auditability.
|
||
|
||
---
|
||
|
||
## 5. Database Design
|
||
|
||
All additions are new models plus **two nullable columns** on existing models. No existing column is modified or removed. Prisma-style definitions below are normative in shape; implementers map to house style (cuid ids, `createdAt`/`updatedAt` timestamps as in the existing schema).
|
||
|
||
### 5.1 New enums
|
||
|
||
```prisma
|
||
enum ExportStatus {
|
||
QUEUED
|
||
RENDERING
|
||
RENDER_FAILED
|
||
VALIDATING
|
||
VALIDATION_FAILED
|
||
GENERATING_PREVIEW
|
||
PREVIEW_FAILED
|
||
READY_FOR_QC
|
||
QC_FAILED
|
||
READY_FOR_DELIVERY
|
||
PACKAGED
|
||
DELIVERED
|
||
SUPERSEDED
|
||
ARCHIVED
|
||
CANCELLED
|
||
}
|
||
|
||
enum RenderJobStatus { QUEUED CLAIMED RUNNING COMPLETED FAILED CANCELLED EXPIRED }
|
||
enum RenderJobType { AE_RENDER PREVIEW_ONLY DELIVERY_BUILD }
|
||
enum ValidationStatus { PASS FAIL WARN SKIPPED }
|
||
enum QCResult { PASS FAIL }
|
||
enum MachineStatus { ONLINE OFFLINE DISABLED }
|
||
enum DeliveryStatus { DRAFT QUEUED BUILDING READY DELIVERED FAILED CANCELLED }
|
||
```
|
||
|
||
### 5.2 Export
|
||
|
||
The central pipeline entity: one row per "Queue Export" click. Carries the Section 4 state machine.
|
||
|
||
```prisma
|
||
model Export {
|
||
id String @id @default(cuid())
|
||
shotId String
|
||
shot Shot @relation(fields: [shotId], references: [id])
|
||
projectId String
|
||
taskId String? // comp task the preview Version attaches to
|
||
// (E1 creates one named after the preview file
|
||
// when the shot has none, so the preview lands
|
||
// somewhere the app shows it)
|
||
versionId String? // preview Version created at READY_FOR_QC
|
||
versionNumber Int // 4
|
||
versionString String // "v004"
|
||
status ExportStatus @default(QUEUED)
|
||
statusChangedAt DateTime @default(now())
|
||
|
||
// Manifest (denormalised for querying; full manifest JSON on RenderJob)
|
||
aepPath String
|
||
compName String
|
||
rendererType String // "aerender"
|
||
outputDir String // render root for this export
|
||
outputPattern String // "UNG_106_010_020_cmp_TT_v004.[####].exr"
|
||
frameStart Int
|
||
frameEnd Int
|
||
fps Float
|
||
width Int
|
||
height Int
|
||
colorspace String? // expected, e.g. "ACES - ACEScg"
|
||
|
||
// Artifacts
|
||
deliveryMovPath String? // slate/burn-in delivery MOV on the SAN (Section 9); packaged in deliveries
|
||
previewMovKey String? // web H.264 transcode in object storage (also Version.fileUrl)
|
||
thumbnailKey String?
|
||
metadataKey String? // metadata JSON in object storage
|
||
exrFileCount Int?
|
||
exrTotalBytes BigInt?
|
||
checksum String? // sequence-level digest (xxHash of per-file hashes)
|
||
|
||
submittedById String? // resolved User, else null
|
||
submittedByName String? // free text from panel config as fallback
|
||
supersededById String? // newer Export that replaced this one
|
||
|
||
renderJobs RenderJob[]
|
||
validations ValidationResult[]
|
||
qcReviews QCReview[]
|
||
deliveryItems DeliveryItem[]
|
||
events ExportEvent[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([shotId, versionNumber])
|
||
@@index([status])
|
||
@@index([projectId, status])
|
||
}
|
||
```
|
||
|
||
Design note — **why Export is separate from Version**: `Version` is the review-media entity (an MOV with comments/annotations/approvals). An Export is a *render lifecycle* that eventually *produces* a Version (T12). Overloading Version with render state would break the existing review flows this spec promises not to touch. `Export.versionId` links them 1:1 once the preview exists.
|
||
|
||
Design note — **why Export ↔ RenderJob is 1:N**: each retry is a fresh `RenderJob` row (attempt N). This keeps a complete execution history (which machine, which logs, how long) without mutating past attempts, and makes "Render History" (Section 13) a trivial query.
|
||
|
||
### 5.3 RenderJob
|
||
|
||
One execution attempt of work by a worker. Also used (with `type: DELIVERY_BUILD`) for delivery package builds, so there is exactly one queue/claim/lease/heartbeat mechanism in the whole system.
|
||
|
||
```prisma
|
||
model RenderJob {
|
||
id String @id @default(cuid())
|
||
type RenderJobType @default(AE_RENDER)
|
||
exportId String? // set for AE_RENDER / PREVIEW_ONLY
|
||
export Export? @relation(fields: [exportId], references: [id])
|
||
deliveryId String? // set for DELIVERY_BUILD
|
||
attempt Int @default(1)
|
||
maxAttempts Int @default(3)
|
||
status RenderJobStatus @default(QUEUED)
|
||
priority Int @default(50) // lower = sooner
|
||
manifest Json // full Render Manifest snapshot (Section 6.2)
|
||
|
||
machineId String?
|
||
machine Machine? @relation(fields: [machineId], references: [id])
|
||
claimedAt DateTime?
|
||
leaseExpiresAt DateTime? // claim + leaseSeconds; renewed by progress reports
|
||
startedAt DateTime?
|
||
finishedAt DateTime?
|
||
|
||
progress Float @default(0) // 0..1
|
||
currentFrame Int?
|
||
totalFrames Int?
|
||
etaSeconds Int?
|
||
exitCode Int?
|
||
errorMessage String?
|
||
logTail String? // last ~200 lines of aerender output
|
||
logFileKey String? // full log uploaded to object storage on finish/fail
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([status, priority, createdAt])
|
||
@@index([machineId, status])
|
||
}
|
||
```
|
||
|
||
### 5.4 ValidationResult
|
||
|
||
One row per check per validation run, so the UI can show exactly what failed and history is preserved across retries.
|
||
|
||
```prisma
|
||
model ValidationResult {
|
||
id String @id @default(cuid())
|
||
exportId String
|
||
export Export @relation(fields: [exportId], references: [id])
|
||
renderJobId String // which attempt produced this run
|
||
checkName String // "frame_count", "missing_frames", "resolution", ...
|
||
status ValidationStatus
|
||
expected String? // "120"
|
||
actual String? // "119"
|
||
message String? // "Frame 1057 missing"
|
||
details Json? // e.g. list of missing frame numbers
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([exportId, renderJobId])
|
||
}
|
||
```
|
||
|
||
### 5.5 QCReview
|
||
|
||
```prisma
|
||
model QCReview {
|
||
id String @id @default(cuid())
|
||
exportId String
|
||
export Export @relation(fields: [exportId], references: [id])
|
||
result QCResult
|
||
notes String?
|
||
reviewedById String? // resolved User where possible
|
||
reviewerName String? // fallback for API-key reviews from the panel
|
||
source String // "AE_PANEL" | "WEB"
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([exportId])
|
||
}
|
||
```
|
||
|
||
Multiple rows per Export are allowed (fail → re-QC after preview retry); the latest row is authoritative and drives T13/T14.
|
||
|
||
### 5.6 Machine and WorkerHeartbeat
|
||
|
||
```prisma
|
||
model Machine {
|
||
id String @id @default(cuid())
|
||
name String @unique // "RENDER-01"
|
||
hostname String
|
||
status MachineStatus @default(OFFLINE)
|
||
enabled Boolean @default(true) // admin kill-switch: disabled machines cannot claim
|
||
lastSeenAt DateTime?
|
||
workerVersion String?
|
||
aeVersion String? // "2026 (24.x)"
|
||
capabilities Json? // { maxConcurrentJobs: 1, tools: {ffmpeg: "7.1", oiiotool: "2.5"} }
|
||
availability Json? // Section 7.10: { mode, windows, allowUrgentAnytime } — null = ALWAYS
|
||
renderNowUntil DateTime? // manual "Render Now" override (web toggle); claims allowed until this time
|
||
apiKeyHash String? // optional per-machine key (kept for the future; Section 15 — shared key for now)
|
||
renderJobs RenderJob[]
|
||
heartbeats WorkerHeartbeat[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model WorkerHeartbeat {
|
||
id String @id @default(cuid())
|
||
machineId String
|
||
machine Machine @relation(fields: [machineId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
cpuPercent Float?
|
||
memPercent Float?
|
||
diskFreeGb Float?
|
||
currentJobId String?
|
||
|
||
@@index([machineId, createdAt])
|
||
}
|
||
```
|
||
|
||
Heartbeats also update `Machine.lastSeenAt/status` in place. A daily prune keeps 7 days of heartbeat rows (Section 7.8). `ONLINE` = heartbeat within 3× the heartbeat interval; the machine-monitor page derives this, and the reaper derives lease expiry independently of it.
|
||
|
||
### 5.7 DeliveryPackage and DeliveryItem
|
||
|
||
```prisma
|
||
model DeliveryPackage {
|
||
id String @id @default(cuid())
|
||
projectId String
|
||
episode String // "101"
|
||
name String // "260805_Delivery" (per 18.1-Q6 convention)
|
||
kind DeliveryKind @default(EXR_DELIVERY) // EXR_DELIVERY | MOV_REVIEW (§11.6)
|
||
packageNumber Int // sequential per project → "Package #15"
|
||
status DeliveryStatus @default(DRAFT)
|
||
rootPath String? // final path on delivery volume
|
||
configSnapshot Json // naming template + options at build time (immutable record)
|
||
manifest Json? // built manifest: files, sizes, checksums
|
||
reportKey String? // delivery report (object storage) — also written into the folder
|
||
errorMessage String?
|
||
createdById String
|
||
createdAt DateTime @default(now())
|
||
builtAt DateTime?
|
||
deliveredAt DateTime?
|
||
deliveredById String?
|
||
items DeliveryItem[]
|
||
|
||
@@unique([projectId, packageNumber])
|
||
@@index([projectId, episode])
|
||
@@index([projectId, kind])
|
||
}
|
||
|
||
model DeliveryItem {
|
||
id String @id @default(cuid())
|
||
packageId String
|
||
package DeliveryPackage @relation(fields: [packageId], references: [id])
|
||
exportId String // version lock: the exact Export delivered
|
||
export Export @relation(fields: [exportId], references: [id])
|
||
shotId String
|
||
shotCode String // denormalised: survives shot renames
|
||
versionString String // denormalised: "v004"
|
||
exrPath String? // path inside package
|
||
movPath String?
|
||
fileCount Int?
|
||
totalBytes BigInt?
|
||
checksum String?
|
||
status String @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED
|
||
|
||
@@unique([packageId, exportId])
|
||
}
|
||
```
|
||
|
||
**Version locking:** the package references `Export` rows, and items denormalise `shotCode`/`versionString` at creation. Later re-renders of a shot never mutate a package — delivery history is immutable by construction.
|
||
|
||
**`kind`** distinguishes a shippable EXR delivery from a MOV review folder (§11.6). Both use these two models and the same `DELIVERY_BUILD` job; only a review leaves `exrPath` null, and only a delivery advances its exports to PACKAGED/DELIVERED.
|
||
|
||
### 5.8 Changes to existing models (additive only)
|
||
|
||
| Model | Addition | Purpose |
|
||
|---|---|---|
|
||
| `Shot` | `exports Export[]` back-relation | navigation only; no column |
|
||
| `Project` | `deliveryConfig Json?` (nullable) | per-production delivery naming/layout templates (Section 11.2); falls back to `SystemConfig` defaults when null |
|
||
| `Version` | *(no change)* | preview Versions are ordinary rows; `Export.versionId` points at them |
|
||
| `SystemConfig` | new keys (no schema change) | `render.leaseSeconds`, `render.pollSeconds`, `render.maxAttempts`, `delivery.defaultTemplate`, etc. |
|
||
|
||
### 5.9 ExportEvent (audit log)
|
||
|
||
```prisma
|
||
model ExportEvent {
|
||
id String @id @default(cuid())
|
||
exportId String
|
||
export Export @relation(fields: [exportId], references: [id])
|
||
fromStatus String?
|
||
toStatus String
|
||
actorType String // "WORKER" | "USER" | "SYSTEM"
|
||
actorId String? // machineId or userId
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([exportId, createdAt])
|
||
}
|
||
```
|
||
|
||
Cheap, append-only, powers the timeline strip on the Export detail page and answers "why did this fail at 3 am".
|
||
|
||
### 5.10 Entity-relationship diagram (new + touching models)
|
||
|
||
```mermaid
|
||
erDiagram
|
||
Project ||--o{ Shot : has
|
||
Shot ||--o{ Version : has
|
||
Shot ||--o{ Task : has
|
||
Shot ||--o{ Export : has
|
||
Export ||--o{ RenderJob : attempts
|
||
Export ||--o{ ValidationResult : checks
|
||
Export ||--o{ QCReview : reviews
|
||
Export ||--o{ ExportEvent : audit
|
||
Export |o--o| Version : "produces preview"
|
||
Machine ||--o{ RenderJob : executes
|
||
Machine ||--o{ WorkerHeartbeat : reports
|
||
Project ||--o{ DeliveryPackage : has
|
||
DeliveryPackage ||--o{ DeliveryItem : contains
|
||
DeliveryItem }o--|| Export : "locks version of"
|
||
RenderJob }o--o| DeliveryPackage : "builds (type=DELIVERY_BUILD)"
|
||
```
|
||
|
||
---
|
||
|
||
## 6. API Design
|
||
|
||
### 6.1 Conventions (inherited from existing ext API)
|
||
|
||
- Base URL, auth headers (`Authorization: Bearer <key>` / `X-Api-Key`), JSON error shape `{ "error": "...", "details": [...] }`, and status-code table are identical to `EXT_API_REFERENCE.md`.
|
||
- New external namespaces: `/api/ext/exports`, `/api/ext/render`, `/api/ext/qc`, `/api/ext/workers`, `/api/ext/deliveries`.
|
||
- New internal (NextAuth-session) namespaces mirror them: `/api/render/*`, `/api/deliveries/*`, `/api/machines/*` for the web UI. Internal routes are thin wrappers over the same service-layer functions (`lib/render-pipeline/*.ts`) — one implementation, two auth fronts, matching the existing actions/route split.
|
||
- All state-changing worker endpoints are **idempotent**: repeating a report for a job already past that state returns `200 { "alreadyApplied": true }`, not an error — except contradictory reports (e.g. `complete` after `fail`), which return `409`.
|
||
|
||
### 6.2 The Render Manifest
|
||
|
||
Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.manifest`; handed to the worker on claim. The panel builds it from the existing `/api/ext/shots/lookup` payload plus local AE knowledge.
|
||
|
||
```json
|
||
{
|
||
"manifestVersion": 1,
|
||
"projectCode": "UNG_S1",
|
||
"shotCode": "UNG_106_010_020",
|
||
"shotId": "clxxxxxxxxxxxxxx",
|
||
"aepPath": "//SAN/projects/UNG_S1/106/UNG_106_010_020/ae/UNG_106_010_020_comp.aep",
|
||
"compName": "UNG_106_010_020_cmp",
|
||
"rendererType": "aerender",
|
||
"aeVersionHint": "2026",
|
||
"outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
|
||
"outputPattern": "UNG_106_010_020_cmp_TT_v004.[####].exr",
|
||
"outputModuleTemplate": "VFXR_EXR_SEQ",
|
||
"renderSettingsTemplate": "VFXR_FULL",
|
||
"frameStart": 1001,
|
||
"frameEnd": 1120,
|
||
"fps": 24,
|
||
"width": 3840,
|
||
"height": 2160,
|
||
"expected": {
|
||
"colorspace": "ACES - ACEScg",
|
||
"bitDepth": "half",
|
||
"exrCompression": "PIZ",
|
||
"alpha": false,
|
||
"timecodeStart": "00:59:49:12"
|
||
},
|
||
"source": {
|
||
"startSource": "footage metadata",
|
||
"handleFrames": 8,
|
||
"timingPlate": {
|
||
"path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr",
|
||
"frameStart": 1001,
|
||
"frameEnd": 1120
|
||
},
|
||
"plates": [
|
||
{ "element": "BG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 },
|
||
{ "element": "FG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_FG01_TT_v001/UNG_106_010_020_FG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 }
|
||
]
|
||
},
|
||
"preview": {
|
||
"template": "default",
|
||
"burnins": true
|
||
}
|
||
}
|
||
```
|
||
|
||
The server validates the manifest with Zod, cross-checks `shotId`/`shotCode`/frame range against the DB, and **the server (not the panel) decides the new version number** — race-free single source of truth.
|
||
|
||
**`source` (required from manifestVersion 2)** — the plate provenance the timecode check depends on (§8.1a). The panel resolves these from the *comp's own footage layers*, not by re-deriving paths, so the manifest records what was actually comped:
|
||
|
||
- `timingPlate` — the delivered plate the render's timecode is checked against. A shot's elements routinely carry unrelated camera timecodes, so "the plate" must be named explicitly rather than guessed by the worker. **Only footage under the Footage Root qualifies**: a comp usually also holds pre-renders and CG passes of identical length, and anchoring on one of those is circular — it was rendered from this comp, so it can only ever agree with it, including when both are wrong. Among delivered plates, longest duration wins.
|
||
- `frameStart`/`frameEnd` on a plate are its **own file numbering**, and are omitted when the filename carries no frame number. They are never filled from the comp's frame range: the render's first frame is in output numbering, so a comp-frame anchor skews the comparison by the gap between the two systems.
|
||
- `startSource` — verbatim from the panel's existing start-frame resolution: `"footage metadata"` (probed from the plate, trustworthy) or `"API timecode …"` (fallback, suspect). This is known at queue time, so an export built from the API fallback can be flagged **before** it burns render hours.
|
||
- `plates[]` — every element imported into the comp, for the record and for cross-element diagnostics.
|
||
|
||
Manifests without `source` (manifestVersion 1) are still accepted; the timecode check degrades per §8.1a.
|
||
|
||
### 6.3 Endpoint summary
|
||
|
||
| # | Method | Path | Auth | Caller | Purpose |
|
||
|---|---|---|---|---|---|
|
||
| E1 | POST | `/api/ext/exports` | API key | AE panel | Queue Export (creates Export + RenderJob) |
|
||
| E2 | GET | `/api/ext/exports/latest?shotCode=&projectCode=` | API key | AE panel | Latest export + status for panel display |
|
||
| E3 | GET | `/api/ext/exports/{exportId}` | API key | panel/worker | Export detail incl. validations, QC |
|
||
| E4 | POST | `/api/ext/exports/{exportId}/qc` | API key | AE panel | QC Pass / QC Fail |
|
||
| E5 | GET | `/api/ext/qc/queue?projectCode=&episode=` | API key | AE panel | Exports in READY_FOR_QC |
|
||
| E6 | POST | `/api/ext/workers/register` | API key | worker | Register/upsert machine |
|
||
| E7 | POST | `/api/ext/workers/{machineId}/heartbeat` | API key | worker | Heartbeat + cancel-signal channel |
|
||
| E8 | POST | `/api/ext/render/jobs/claim` | API key | worker | Atomically claim next job |
|
||
| E9 | PATCH | `/api/ext/render/jobs/{jobId}/progress` | API key | worker | Progress/ETA; renews lease |
|
||
| E10 | POST | `/api/ext/render/jobs/{jobId}/fail` | API key | worker | Report failure |
|
||
| E11 | POST | `/api/ext/render/jobs/{jobId}/complete-render` | API key | worker | aerender finished OK → VALIDATING |
|
||
| E12 | POST | `/api/ext/render/jobs/{jobId}/validation` | API key | worker | Report all validation results |
|
||
| E13 | POST | `/api/ext/render/jobs/{jobId}/finalize` | API key | worker | Preview artifacts done → READY_FOR_QC |
|
||
| E14 | POST | `/api/ext/exports/{exportId}/retry` | API key | panel | Retry failed export |
|
||
| E15 | POST | `/api/render/jobs/{jobId}/retry` · `/cancel` | session | web UI | Retry / cancel |
|
||
| E16 | POST | `/api/deliveries` | session | web UI | Create package (DRAFT→QUEUED) |
|
||
| E17 | GET | `/api/deliveries?projectId=&episode=` | session | web UI | List packages / history |
|
||
| E18 | GET | `/api/deliveries/ready?projectId=&episode=` | session | web UI | READY_FOR_DELIVERY exports grouped by episode |
|
||
| E19 | POST | `/api/deliveries/{id}/delivered` | session | web UI | Mark Delivered |
|
||
| E20 | POST | `/api/ext/render/jobs/{jobId}/artifact-presign` | API key | worker | Presigned upload for preview/thumb/metadata/log |
|
||
| E21 | GET | `/api/render/queue` · `/api/render/exports` · `/api/machines` | session | web UI | Queue/monitoring pages (standard pagination) |
|
||
|
||
### 6.4 Request/response examples (normative)
|
||
|
||
**E1 — Queue Export**
|
||
|
||
```http
|
||
POST /api/ext/exports
|
||
Authorization: Bearer <API_SECRET_KEY>
|
||
Content-Type: application/json
|
||
|
||
{ "manifest": { ...Section 6.2..., "outputDir": "auto", "outputPattern": "auto" },
|
||
"submittedByEmail": "jane@studio.com" }
|
||
```
|
||
|
||
`outputDir`/`outputPattern` may be `"auto"`: the server generates them from the shot's `exrOutput` convention and the *new* version number, and returns them; the panel does not guess paths for a version it doesn't know yet.
|
||
|
||
```json
|
||
201
|
||
{
|
||
"export": {
|
||
"id": "exp_01",
|
||
"shotCode": "UNG_106_010_020",
|
||
"versionString": "v004",
|
||
"status": "QUEUED",
|
||
"outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
|
||
"outputPattern": "UNG_106_010_020_cmp_TT_v004.[####].exr"
|
||
},
|
||
"renderJob": { "id": "rj_01", "attempt": 1, "priority": 50 },
|
||
"shot": { "id": "clxxx", "shotVersion": "v004", "exrOutput": "UNG_106_010_020_cmp_TT_v004" },
|
||
"superseded": ["exp_00"]
|
||
}
|
||
```
|
||
|
||
Errors: `409` if an active (QUEUED/RENDERING/VALIDATING/GENERATING_PREVIEW) Export exists for the shot and `force` is not set; `422` manifest validation failure.
|
||
|
||
**E2 — Latest export for panel display**
|
||
|
||
```http
|
||
GET /api/ext/exports/latest?shotCode=UNG_106_010_020&projectCode=UNG_S1
|
||
```
|
||
|
||
```json
|
||
200
|
||
{
|
||
"export": {
|
||
"id": "exp_01", "versionString": "v004", "status": "READY_FOR_QC",
|
||
"statusChangedAt": "2026-08-01T12:04:11Z",
|
||
"renderJob": { "progress": 1, "machineName": "RENDER-01" },
|
||
"validation": { "overall": "PASS", "failed": [] },
|
||
"qc": { "latestResult": null },
|
||
"previewUrl": "/api/files/videos/uuid-UNG_106_010_020_cmp_TT_v004.mov"
|
||
}
|
||
}
|
||
```
|
||
|
||
Returns `{ "export": null }` when the shot has never been exported.
|
||
|
||
**E6 — Worker registration** (idempotent upsert on `name`)
|
||
|
||
```http
|
||
POST /api/ext/workers/register
|
||
{ "name": "RENDER-01", "hostname": "render01.studio.local",
|
||
"workerVersion": "1.0.3", "aeVersion": "24.3",
|
||
"capabilities": { "maxConcurrentJobs": 1, "tools": { "ffmpeg": "7.1", "oiiotool": "2.5.4" } } }
|
||
```
|
||
|
||
```json
|
||
200 { "machine": { "id": "mac_01", "name": "RENDER-01", "enabled": true },
|
||
"config": { "pollSeconds": 10, "heartbeatSeconds": 30, "leaseSeconds": 300 } }
|
||
```
|
||
|
||
Server-supplied `config` (from `SystemConfig`) means fleet tuning without touching worker installs.
|
||
|
||
**E7 — Heartbeat** (also the cancel channel)
|
||
|
||
```http
|
||
POST /api/ext/workers/mac_01/heartbeat
|
||
{ "cpuPercent": 82.5, "memPercent": 61.0, "diskFreeGb": 512.3, "currentJobId": "rj_01" }
|
||
```
|
||
|
||
```json
|
||
200 { "ok": true, "commands": [ { "type": "CANCEL_JOB", "jobId": "rj_01" } ] }
|
||
```
|
||
|
||
`commands` is normally `[]`. Piggybacking cancellation on the heartbeat avoids any server→worker connection (workers may be NAT-ed; no websockets needed).
|
||
|
||
**E8 — Claim next job** (the critical atomic operation)
|
||
|
||
```http
|
||
POST /api/ext/render/jobs/claim
|
||
{ "machineId": "mac_01", "types": ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"] }
|
||
```
|
||
|
||
```json
|
||
200
|
||
{ "job": { "id": "rj_01", "type": "AE_RENDER", "attempt": 1, "exportId": "exp_01",
|
||
"leaseExpiresAt": "2026-08-01T11:35:00Z",
|
||
"manifest": { ... } } }
|
||
```
|
||
|
||
`204 No Content` when the queue is empty. Server-side claim must be atomic under concurrent workers:
|
||
|
||
```sql
|
||
UPDATE "RenderJob" SET status='CLAIMED', "machineId"=$1,
|
||
"claimedAt"=now(), "leaseExpiresAt"=now() + ($2 * interval '1 second')
|
||
WHERE id = (
|
||
SELECT id FROM "RenderJob"
|
||
WHERE status='QUEUED' AND type = ANY($3)
|
||
ORDER BY priority ASC, "createdAt" ASC
|
||
FOR UPDATE SKIP LOCKED LIMIT 1 )
|
||
RETURNING *;
|
||
```
|
||
|
||
(Implemented as `prisma.$queryRaw`/`$executeRaw`; `FOR UPDATE SKIP LOCKED` is the standard Postgres queue idiom and needs no extra infrastructure. Claiming also transitions the Export to RENDERING.)
|
||
|
||
The claim additionally enforces **machine availability** (Section 7.10): outside a workstation's render window the server answers `204` even when jobs are queued — unless the machine has an active "Render Now" override (`renderNowUntil`) or the head-of-queue job is urgent priority (`priority <= 20` and the machine's `allowUrgentAnytime` is true). Centralising this in the claim endpoint means workers poll dumbly and all scheduling policy lives in the server/UI.
|
||
|
||
**E9 — Progress** (renews lease; recommended every 10 s or every frame, whichever is coarser)
|
||
|
||
```http
|
||
PATCH /api/ext/render/jobs/rj_01/progress
|
||
{ "machineId": "mac_01", "progress": 0.42, "currentFrame": 1051,
|
||
"totalFrames": 120, "etaSeconds": 96, "logTail": "PROGRESS: 0:00:02:03 (51): ..." }
|
||
```
|
||
|
||
```json
|
||
200 { "ok": true, "leaseExpiresAt": "2026-08-01T11:40:00Z", "cancelRequested": false }
|
||
```
|
||
|
||
**E10 — Fail**
|
||
|
||
```http
|
||
POST /api/ext/render/jobs/rj_01/fail
|
||
{ "machineId": "mac_01", "stage": "RENDER", "exitCode": 9,
|
||
"errorMessage": "aerender ERROR: layer source file missing",
|
||
"logTail": "...", "retryable": true }
|
||
```
|
||
|
||
```json
|
||
200 { "job": { "status": "FAILED" }, "export": { "status": "RENDER_FAILED" },
|
||
"autoRequeued": false }
|
||
```
|
||
|
||
If `retryable: true` and `attempt < maxAttempts`, the server auto-creates the next attempt and returns `"autoRequeued": true` with the new job id. `stage` is one of `RENDER | VALIDATION_RUNNER | PREVIEW | PACKAGE` (a crash *of the validation runner*, as opposed to a failed validation check, which uses E12).
|
||
|
||
**E12 — Validation report** (one call carries the entire run)
|
||
|
||
```http
|
||
POST /api/ext/render/jobs/rj_01/validation
|
||
{ "machineId": "mac_01",
|
||
"overall": "FAIL",
|
||
"sequence": { "fileCount": 119, "totalBytes": 5482196992, "checksum": "xxh64:9f2a..." },
|
||
"checks": [
|
||
{ "checkName": "frame_count", "status": "FAIL", "expected": "120", "actual": "119" },
|
||
{ "checkName": "missing_frames", "status": "FAIL", "expected": "none", "actual": "1057",
|
||
"details": { "missing": [1057] } },
|
||
{ "checkName": "resolution", "status": "PASS", "expected": "3840x2160", "actual": "3840x2160" },
|
||
{ "checkName": "colourspace", "status": "PASS", "expected": "ACES - ACEScg", "actual": "ACES - ACEScg" },
|
||
{ "checkName": "bit_depth", "status": "PASS", "expected": "half", "actual": "half" }
|
||
] }
|
||
```
|
||
|
||
```json
|
||
200 { "export": { "status": "VALIDATION_FAILED" } }
|
||
```
|
||
|
||
**E13 — Finalize** (after successful preview generation and artifact uploads)
|
||
|
||
```http
|
||
POST /api/ext/render/jobs/rj_01/finalize
|
||
{ "machineId": "mac_01",
|
||
"artifacts": { "deliveryMovPath": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004/mov/UNG_106_010_020_cmp_TT_v004.mov",
|
||
"previewMovKey": "videos/uuid-UNG_..._v004.mov",
|
||
"thumbnailKey": "image/uuid-UNG_..._v004_thumb.jpg",
|
||
"metadataKey": "renders/uuid-UNG_..._v004_meta.json",
|
||
"logFileKey": "renders/uuid-rj_01.log" },
|
||
"renderStats": { "renderSeconds": 812, "previewSeconds": 44 } }
|
||
```
|
||
|
||
```json
|
||
200 { "export": { "id": "exp_01", "status": "READY_FOR_QC" },
|
||
"version": { "id": "ver_09", "versionNumber": 4, "isLatest": true } }
|
||
```
|
||
|
||
Server-side, finalize reuses the **existing version-creation service** (previous versions un-latested, new `Version` created with `fileUrl`/`thumbnailUrl`) but in *pipeline mode*: the Version is flagged not-client-visible and **no task or shot status is mutated**. Delivery QC runs on shots the client has usually already approved (Section 10) — a post-approval technical render must never resurface in the review flow or client portal. `finalize` also reports `deliveryMovPath` (the SAN path of the slate/burn-in delivery MOV) alongside the object-storage artifact keys.
|
||
|
||
**E4 — QC**
|
||
|
||
```http
|
||
POST /api/ext/exports/exp_01/qc
|
||
{ "result": "PASS", "notes": "Grain matched, edges clean",
|
||
"reviewerEmail": "jane@studio.com", "source": "AE_PANEL" }
|
||
```
|
||
|
||
```json
|
||
200 { "export": { "status": "READY_FOR_DELIVERY" },
|
||
"qcReview": { "id": "qc_01", "result": "PASS", "createdAt": "..." } }
|
||
```
|
||
|
||
QC FAIL additionally sets the comp task to CHANGES (existing enum), which drives the existing Shot REVISIONS derivation — reviewers see it in today's dashboards with no UI change.
|
||
|
||
**E5 — QC queue**
|
||
|
||
```http
|
||
GET /api/ext/qc/queue?projectCode=UNG_S1&episode=106
|
||
```
|
||
|
||
```json
|
||
200 { "items": [ { "exportId": "exp_01", "shotCode": "UNG_106_010_020",
|
||
"versionString": "v004", "readySince": "2026-08-01T12:04:11Z",
|
||
"outputDir": "//SAN/renders/UNG_S1/106/UNG_106_010_020/v004",
|
||
"previewUrl": "/api/files/videos/uuid-....mov",
|
||
"thumbnailUrl": "/api/files/image/uuid-...jpg" } ],
|
||
"total": 1 }
|
||
```
|
||
|
||
**E16 — Create delivery package**
|
||
|
||
```http
|
||
POST /api/deliveries (NextAuth session)
|
||
{ "projectId": "cmp6l5...", "episode": "101",
|
||
"exportIds": ["exp_01", "exp_02"], // preselected from E18; server re-verifies
|
||
"options": { "includeMovs": true, "date": "2026-08-05" } }
|
||
```
|
||
|
||
```json
|
||
201 { "package": { "id": "dp_15", "packageNumber": 15, "name": "260805_Delivery",
|
||
"episode": "101", "rootPath": "//SAN/DELIVERY/101/260805_Delivery",
|
||
"status": "QUEUED", "itemCount": 18 },
|
||
"buildJob": { "id": "rj_77", "type": "DELIVERY_BUILD" } }
|
||
```
|
||
|
||
Server verifies every export is READY_FOR_DELIVERY (else `422` listing offenders), snapshots `Project.deliveryConfig` into `configSnapshot`, creates items, and enqueues a `DELIVERY_BUILD` RenderJob that any worker can claim via E8.
|
||
|
||
**E19 — Mark Delivered**
|
||
|
||
```http
|
||
POST /api/deliveries/dp_15/delivered
|
||
{ "note": "Uploaded to Content Hub 2026-08-05 16:20 by Chris" }
|
||
```
|
||
|
||
```json
|
||
200 { "package": { "status": "DELIVERED", "deliveredAt": "...", "deliveredBy": "Chris" },
|
||
"exportsUpdated": 18 }
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Render Worker
|
||
|
||
### 7.1 Technology choice
|
||
|
||
**Recommendation: .NET 8 Windows Service (worker-service template).** Trade-offs considered:
|
||
|
||
| Option | Pros | Cons |
|
||
|---|---|---|
|
||
| **.NET 8 Windows Service (recommended)** | First-class Windows Service lifecycle, single self-contained exe, robust process management (`System.Diagnostics.Process`), easy MSI/`sc.exe` install, no runtime to install | Different language than the Next.js codebase |
|
||
| Node.js + node-windows / NSSM | Same language as server; could share TS types | Fragile as a service; process supervision and single-file distribution weaker on Windows |
|
||
| Python + pywin32 | Studio pipeline familiarity | Packaging/service story weakest; dependency management on render nodes painful |
|
||
|
||
The worker shares no code with the server anyway (it only speaks HTTP), so language uniformity buys little; operational robustness on Windows render nodes buys a lot. The API contract in Section 6 is the interface, which is why more machines can be added without any API change.
|
||
|
||
### 7.2 Internal architecture
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph Worker["RenderWorker service"]
|
||
MAIN["Main loop"] --> REG["Registration (E6, startup)"]
|
||
MAIN --> HB["Heartbeat timer (E7, 30 s)"]
|
||
MAIN --> POLL["Claim poller (E8, 10 s, only when idle)"]
|
||
POLL --> EXEC["Job executor (one job at a time)"]
|
||
EXEC --> P1["Stage 1: aerender runner"]
|
||
EXEC --> P2["Stage 2: validation engine (Section 8)"]
|
||
EXEC --> P3["Stage 3: preview generator (Section 9)"]
|
||
EXEC --> P4["Alt: delivery builder (Section 11)"]
|
||
P1 --> REP["Reporter (E9-E13, retry with backoff, offline spool)"]
|
||
P2 --> REP
|
||
P3 --> REP
|
||
P4 --> REP
|
||
EXEC --> LOG["Rolling file logs %ProgramData%\\VFXReviewWorker\\logs"]
|
||
end
|
||
```
|
||
|
||
One job at a time per machine by default (`capabilities.maxConcurrentJobs: 1`) — aerender saturates a box; concurrency is a config knob, not an architecture change.
|
||
|
||
### 7.3 aerender execution
|
||
|
||
```
|
||
aerender.exe -project "<aepPath>" -comp "<compName>"
|
||
-s <frameStart> -e <frameEnd>
|
||
-RStemplate "<renderSettingsTemplate>" -OMtemplate "<outputModuleTemplate>"
|
||
-output "<outputDir>\<outputPattern>"
|
||
-mp -continueOnMissingFootage false
|
||
```
|
||
|
||
- stdout is parsed line-by-line: `PROGRESS: ... (N)` lines yield `currentFrame`; ETA = rolling average seconds/frame × frames remaining.
|
||
- stderr and non-progress stdout accumulate into the rolling `logTail` (last 200 lines) and full log file.
|
||
- Failure detection: non-zero exit code, `aerender ERROR` lines, or no progress line for `stallTimeoutSeconds` (default 600 — some frames legitimately take minutes) → kill process tree, report E10.
|
||
- `outputDir` is created by the worker before launch; a pre-existing non-empty dir for the same attempt is cleared (it can only contain output from a previous crashed attempt of this same immutable version — new versions always get new dirs).
|
||
|
||
### 7.4 Job locking and lease renewal
|
||
|
||
- Claiming (E8) is atomic server-side (`FOR UPDATE SKIP LOCKED`); a job can never be claimed twice.
|
||
- Every progress report renews the lease (`leaseSeconds`, default 300).
|
||
- A server-side **reaper** (see 7.7) requeues or fails jobs whose lease expired — this covers worker power loss, BSOD, and network partition with no worker cooperation.
|
||
- Workers include `machineId` in every job-scoped call; the server rejects reports from a machine that doesn't hold the job's lease (`409`) — a resurrected zombie worker cannot corrupt a reassigned job.
|
||
|
||
### 7.5 Crash recovery (worker side)
|
||
|
||
On service start: register (E6), then reconcile: if a local state file (`current-job.json`, written on claim, deleted on finish) names a job, query E3; if the server reassigned or failed it, discard local state and clean the orphaned output dir. Never resume a partial aerender — re-render from frame 1 of the range (attempt integrity beats partial-resume complexity; typical shots are ≤ 200 frames).
|
||
|
||
### 7.6 Retries
|
||
|
||
| Failure | Retry behaviour |
|
||
|---|---|
|
||
| aerender non-zero exit, `retryable: true` | Server auto-requeues up to `maxAttempts` (3); attempt N+1 is a fresh RenderJob |
|
||
| Deterministic errors (missing footage, missing comp) | Worker sets `retryable: false` → RENDER_FAILED immediately; humans fix the comp |
|
||
| Validation FAIL | Never auto-retried — re-rendering identical inputs reproduces the failure. Human retries (T9) after fixing cause |
|
||
| Preview failure | `PREVIEW_ONLY` retry re-runs ffmpeg only (validated EXRs are fine) |
|
||
| Report HTTP failures | Exponential backoff 1 s→60 s; reports spool to disk and replay in order — rendering continues during server downtime |
|
||
|
||
### 7.7 Server-side reaper
|
||
|
||
A small periodic task (Next.js route invoked by the existing container's cron-less pattern — recommend a `setInterval` in `instrumentation.ts` (Next.js server startup hook), 60 s):
|
||
|
||
1. `RenderJob` CLAIMED/RUNNING with `leaseExpiresAt < now()` → mark EXPIRED; if attempts remain → new QUEUED attempt (T5), else Export → RENDER_FAILED (T4).
|
||
2. `Machine.lastSeenAt` older than 3× heartbeat → status OFFLINE.
|
||
3. Prune `WorkerHeartbeat` older than 7 days.
|
||
|
||
This is the only "background job" the web app gains, and it is a single idempotent SQL sweep. (Mechanism confirmed as studio decision 18.1-Q8.)
|
||
|
||
### 7.8 Configuration
|
||
|
||
`C:\ProgramData\VFXReviewWorker\config.json` (machine-local, minimal — everything tunable lives in server `SystemConfig` and arrives via E6):
|
||
|
||
```json
|
||
{
|
||
"serverUrl": "https://review.twotalesvfx.com",
|
||
"apiKey": "<API_SECRET_KEY or per-machine key>",
|
||
"machineName": "RENDER-01",
|
||
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
|
||
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
|
||
"oiiotoolPath": "C:\\pipeline\\bin\\oiiotool.exe",
|
||
"pathMappings": [ { "from": "//SAN/", "to": "S:/" } ]
|
||
}
|
||
```
|
||
|
||
`pathMappings` translates manifest UNC paths to local drive mappings — manifests store canonical UNC paths; each machine maps them locally.
|
||
|
||
### 7.9 Logging
|
||
|
||
- Rolling local logs (14 days) under `%ProgramData%\VFXReviewWorker\logs`.
|
||
- Per-job full aerender log uploaded via E20 on finish/fail (`RenderJob.logFileKey`) so the web UI can show complete logs without filesystem access.
|
||
|
||
### 7.10 Workstation deployment, render windows and "Render Now"
|
||
|
||
**Studio decision (18.1):** the worker runs on the two artist workstations — no dedicated render nodes initially. That makes availability scheduling a first-class feature, not an afterthought:
|
||
|
||
| Mode (`Machine.availability.mode`) | Behaviour |
|
||
|---|---|
|
||
| `ALWAYS` | Machine claims whenever idle (default when `availability` is null; suits any future dedicated node) |
|
||
| `SCHEDULE` | Machine claims only inside configured windows, e.g. `{ "windows": [{ "days": ["mon","tue","wed","thu","fri"], "from": "19:00", "to": "08:00" }, { "days": ["sat","sun"], "from": "00:00", "to": "24:00" }] }` — the after-hours auto-render case |
|
||
| `MANUAL` | Machine never claims unless explicitly triggered |
|
||
|
||
Overrides, in priority order:
|
||
|
||
1. **Render Now** — a button on the Machine Monitoring page (and available to the artist locally via a small tray companion, optional) sets `renderNowUntil = now() + N hours`. The machine claims normally until it expires. This is the "urgent render during the day" path: the artist queues the export, then hits Render Now on their own machine.
|
||
2. **Urgent jobs** — jobs queued with urgent priority (`priority <= 20`, settable from the panel via a "queue as urgent" checkbox or from the Render Queue page) may be claimed even outside windows on machines with `allowUrgentAnytime: true`.
|
||
3. `Machine.enabled = false` beats everything (admin kill-switch, unchanged).
|
||
|
||
Enforcement is entirely server-side in E8 (workers keep polling on their normal cadence; polling is a no-op HTTP call). Practical workstation considerations:
|
||
|
||
- The service runs under a studio account with SAN access and works whether or not an artist is logged in.
|
||
- aerender competing with a working artist is prevented by the schedule, not by idle-detection heuristics — simple and predictable. If mid-day renders on the *other* artist's machine become routine, revisit with an idle-detection option then.
|
||
- Window times are per-machine config values collected at Phase 2 rollout (18.2-C5); accepted defaults: weekdays 19:00–08:00 + full weekends, Render Now = 4 h, urgent queueing for all users.
|
||
|
||
---
|
||
|
||
## 8. Validation Engine
|
||
|
||
Runs on the worker immediately after a successful render (Export = VALIDATING). Tools: **oiiotool/OpenImageIO** for EXR introspection (recommended over parsing with a hand-rolled EXR reader; ships as a static exe), plus plain directory scanning.
|
||
|
||
### 8.1 Checks (normative list)
|
||
|
||
| checkName | Method | FAIL condition |
|
||
|---|---|---|
|
||
| `file_count` | dir listing vs `frameEnd-frameStart+1` | count mismatch |
|
||
| `missing_frames` | parse frame numbers from filenames, diff against expected range | any gap |
|
||
| `duplicate_frames` | same parse | same frame number twice (e.g. differing padding) |
|
||
| `first_frame` / `last_frame` | min/max parsed frame | ≠ `frameStart` / `frameEnd` |
|
||
| `filename_consistency` | every file matches `outputPattern` regex (prefix, padding, extension) | any nonconforming file |
|
||
| `resolution` | oiiotool on first+middle+last frame | ≠ manifest `width`×`height` |
|
||
| `bit_depth` | EXR channel types | ≠ `expected.bitDepth` (half/float) |
|
||
| `exr_type` | header: scanline vs tiled, compression | ≠ `expected.exrCompression` (WARN if compression differs but readable; FAIL if not valid EXR) |
|
||
| `alpha_channel` | channel list contains A | presence ≠ `expected.alpha` |
|
||
| `colourspace` | EXR metadata (`chromaticities` / ACES container flag / colorspace attr written by AE OCIO) | ≠ `expected.colorspace` (working space is linear ACES2065-1 per 18.1-Q4); `colorPipeline` is required per project (18.1-Q12), so SKIPPED only on legacy projects predating pipeline enablement |
|
||
| `timecode_plate_match` | **primary check — see §8.1a** | render `timeCode` ≠ the timing plate's `timeCode` at the corresponding frame |
|
||
| `timecode_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic (gap, repeat, or frozen value) |
|
||
| `timecode_fps` | EXR `framesPerSecond` on the render vs manifest `fps` | any disagreement |
|
||
| `plate_fps` | EXR `framesPerSecond` on the render vs **the timing plate's** | WARN on any difference — never FAIL: a whole-number multiple is a legitimate over-crank (50 fps plate on a 25 fps show), and anything else is reported as a probable rate mistake |
|
||
| `zero_byte / corrupt` | every file size > 0; oiiotool header-read on sampled frames (first, last, every Nth) | any zero-byte or unreadable file |
|
||
|
||
Full-sequence checks (count, gaps, names, sizes) run on every file; header checks sample first/middle/last + every 25th frame (fast, catches systemic issues; per-frame full decode is not worth minutes per shot — tunable via `SystemConfig: validation.sampleEvery`).
|
||
|
||
**Two coordinate systems, never conflated.** A render carries frame numbers in two unrelated systems, and every check states which one it uses:
|
||
|
||
| | Meaning | Example |
|
||
|---|---|---|
|
||
| `frameStart`/`frameEnd` | The **comp's timeline**, handed to aerender as `-s`/`-e`. A comp anchored to its plate's timecode starts at the absolute frame that timecode represents. | `1247792`–`1247896` |
|
||
| `outputFrameStart` | The number the **first output file** carries, set by the output module template. House convention: every sequence starts at 1001. | `1001` |
|
||
|
||
`file_count`, `missing_frames` and `duplicate_frames` are measured on the *output* numbering; `first_frame`/`last_frame` compare against `outputFrameStart` and are SKIPPED when a manifest predates the field, since the count and contiguity checks still hold. Comparing file numbers against comp frames fails every correctly-rendered shot by ~1.2 million frames, and the timecode reference must be anchored on the render's own first frame for the same reason.
|
||
|
||
### 8.1a Timecode: the primary check (studio decision)
|
||
|
||
Timecode is the check this engine exists for. It is validated **against the source plate**, never against the API, because the API and the plate do not agree by design.
|
||
|
||
**Measured on the live show (2026-08-13, 85 rendered sequences + 106 delivered sequences on `V:/`):**
|
||
|
||
- AE-written EXRs *do* carry `timeCode` (plus a `timeCodeString` mirror) and `framesPerSecond` — the §17.3 "metadata may be absent" risk does not apply to the studio's current EXR output module. A render whose TC is absent means the output module changed, and is a FAIL.
|
||
- `Shot.timecodeStart` from the API is the **editorial in-point**; the plate on disk starts `handleFrames` earlier. Measured on three shots, the API value was consistently the plate's TC **+8 frames** — the exact handle count. Validating a render against the API would therefore **pass the defective exports and fail the correct ones**: on `UNG_102_019_010` the correctly-built render (`18:58:57:12`, matching its BG01 plate) differs from the API value (`18:58:57:20`), while on `UNG_102_040_010` the *defective* render matches the API exactly and is 8 frames off its plate.
|
||
- 95 of 106 delivered sequences match their plate's timecode exactly. The 11 that do not fall into three classes this check catches: **+8 handle offset** (comp anchored to the editorial in-point instead of the plate head), **frame-derived TC** (`00:00:41:17` = frame 1001 at 24 fps, i.e. a comp with no source timecode at all — also seen as `00:00:00:00`), and **one timecode shared across four different shots** in a single delivery package.
|
||
|
||
**Reference precedence** — the check records which source it used and never silently substitutes a weaker one:
|
||
|
||
| Rank | Source | Verdict semantics |
|
||
|---|---|---|
|
||
| 1 | `source.timingPlate` from the manifest — the element the comp was actually anchored to | PASS / FAIL, authoritative |
|
||
| 2 | Plate re-derived on the worker from the footage root + shot code (manifestVersion 1, or a timing plate that no longer resolves). If elements disagree, the one matching the render's frame count wins; still ambiguous → WARN, never PASS | PASS / FAIL, `reference: "derived"` |
|
||
| 3 | API `Shot.timecodeStart` **minus `handleFrames`** | **WARN only, never PASS** — `reference: "api"`, message states the plate could not be read |
|
||
|
||
Rank 3 is a diagnostic, not a verification: it depends on the show's handle count being correct for that specific plate, which is exactly the assumption the defects above violate.
|
||
|
||
**Comparison, given the plate reference:**
|
||
|
||
```
|
||
expectedTc = plateTc + (render.frameStart - plate.frameStart) // frames, at manifest fps
|
||
```
|
||
|
||
The offset term matters — a shot may render a sub-range of a long plate (observed: 37 rendered frames from a 709-frame plate). Sampled frames must then increment by exactly one frame each; a constant value across the sequence is the frame-derived-TC defect, not a rounding artifact. `timeCodeString` is cross-checked against the packed `timeCode` as a writer sanity check.
|
||
|
||
**Queue-time pre-flight.** The panel already knows whether it read the start time from the plate's metadata or fell back to the API (`source.startSource`). When the manifest says the API fallback was used, the Export is flagged at queue time — the render is very likely to fail this check, and there is no point spending machine hours to find out.
|
||
|
||
**Delivery-package cross-check (Phase 5).** Within one package, two shots sharing a first-frame timecode is a FAIL on the package: it is cheap, and it is precisely the defect that reached a built delivery package above.
|
||
|
||
**Tooling note.** `timeCode`, `timeCodeString` and `framesPerSecond` live in the EXR header, so this check needs one 64 KB read per sampled frame and no pixel decode — the header parse is ~40 lines (attribute list: `name\0 type\0 int32 size data`; `timeCode` is two `uint32`, the first packed SMPTE 12M BCD, which reads back as its own hex digits: `0x18585712` → `18:58:57:12`). It does **not** depend on oiiotool, so the timecode check can ship ahead of the OIIO-based resolution/bit-depth/channel checks.
|
||
|
||
### 8.2 Semantics
|
||
|
||
- Any FAIL → whole run FAIL → Export = VALIDATION_FAILED, pipeline stops, exact reasons in `ValidationResult` rows (E12 example above shows a missing-frame report).
|
||
- WARN never stops the pipeline but is shown in QC and delivery UIs.
|
||
- **Manual override.** A check can fail on a render that is genuinely fine — an unreachable plate, an expectation that does not apply to this shot — so a VALIDATION_FAILED export is not a dead end. An admin/producer/supervisor can override it from the Export detail page with a **required reason**, and the pipeline resumes exactly where a pass would have handed off: preview, then READY_FOR_QC. **An override never skips QC and never delivers directly** — a human still looks at the frames. The failed `ValidationResult` rows are left untouched, so the QC queue keeps listing them as warnings, and the reason plus the checks waved through are recorded on the Export's timeline.
|
||
- Checksum: xxHash64 per file, sequence digest = xxHash64 of sorted per-file digests; stored on `Export.checksum` and reused by the delivery builder for copy verification (MD5 available for the client-facing manifest per 18.1-Q13).
|
||
|
||
---
|
||
|
||
## 9. Preview Generation
|
||
|
||
Runs on the worker after validation passes (Export = GENERATING_PREVIEW).
|
||
|
||
**Studio decision (18.1):** previews are generated with the **existing AE slate/burn-in templates**, not ffmpeg drawtext. The studio already maintains: a slate + overlay template (1-frame slate at the head of the shot, overlays/burn-ins throughout), connector functions that build preview comps and pull slate/overlay data from the API, and the per-project colour chain to match reference: **linear ACES2065-1 → per-project log space → show LUT**. The pipeline reuses all of it headlessly.
|
||
|
||
### 9.1 Stages
|
||
|
||
1. **Build + render preview comp (AE, headless).** The worker launches `AfterFX.com -noui -r vfxr_build_preview.jsx`, passing a job-context JSON file (manifest + slate/overlay fields fetched from E3). The script is the connector's existing preview-comp code, packaged with the worker: it imports the *validated* EXR sequence, builds the preview comp from the studio template (head slate populated from API data, overlays/burn-ins throughout), applies the colour chain (linear → log → show LUT from `colorPipeline` config), queues it with the project's MOV output-module template, and renders the **delivery-grade preview MOV** to the SAN beside the EXRs (`Export.deliveryMovPath`). This is the "MOV LT Preview" that ships inside delivery packages (Section 11).
|
||
2. **Web transcode (ffmpeg).** The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (960-wide), uploaded via E20 presign. The thumbnail is taken at **25% of *picture* duration**, seeking past the head slate: the build script reports the SHOT layer's in-point as `pictureStartSeconds`, and the worker seeks to `pictureStart + 25%`. Builds that report no picture start fall back to scanning the whole file. (Until 2026-08-27 this used ffmpeg's `thumbnail` filter, which only considers the first 100 frames — on a slated preview, the slate card. Every thumbnail produced before then is a picture of the slate.)
|
||
3. **Metadata JSON.** Worker-composed (manifest + validation summary + render stats + checksums + tool/template versions), uploaded via E20.
|
||
|
||
| Artifact | Produced by | Destination |
|
||
|---|---|---|
|
||
| Delivery MOV (slate + burn-ins, graded via show LUT, delivery codec from AE output template) | AE headless render | SAN → `Export.deliveryMovPath`; copied/linked into delivery packages |
|
||
| Web preview MOV (H.264) | ffmpeg transcode of the delivery MOV | object storage `videos/` → `Version.fileUrl` |
|
||
| Thumbnail | ffmpeg | `image/` → `Version.thumbnailUrl` + `Export.thumbnailKey`; also `Shot.generatedThumbnailUrl` when the project opts in (§9.3) |
|
||
| Metadata JSON | worker | `renders/` → `Export.metadataKey` |
|
||
|
||
Because the web preview is a transcode of the AE-generated MOV, review media and delivery media are guaranteed to show identical slates, burn-ins, and colour — one source of truth.
|
||
|
||
### 9.1a Render frames as shot thumbnails
|
||
|
||
Shots carry two stills, in two columns, and the distinction is the whole design:
|
||
|
||
| Column | Written by | Meaning |
|
||
|---|---|---|
|
||
| `Shot.thumbnailUrl` | humans only — single upload, batch upload, ext API | The reference frame that came with the brief. Editorial data. |
|
||
| `Shot.generatedThumbnailUrl` | the pipeline, and the manual promote action | A frame lifted from a finished export. |
|
||
|
||
Display resolution is `thumbnailUrl ?? generatedThumbnailUrl` (`lib/shot-thumbnail.ts`), applied at every shot surface: shot grid, kanban, schedule, shot status, storyboard (screen and PDF), shoot log, slate tab, client portal, and the ext APIs the Nuke/AE panel reads. **The reference always wins**, so the pipeline can only fill a gap and can never overwrite the brief — clearing the generated one always falls back.
|
||
|
||
Promotion happens two ways:
|
||
|
||
* **Automatically**, for projects with `pipelineConfig.autoShotThumbnail` set (Project Settings → *Use render frames as shot thumbnails*). Off by default and deliberately per-show: productions that ship a still with every shot already have a reference frame and the renders should not compete with it. It exists for shows that arrive with no stills at all, where the alternative is an empty grid. `finalizePreview` writes the column and notes it on the export timeline.
|
||
* **By hand**, via `promoteExportThumbnail` / `clearGeneratedThumbnail` (Shot Settings → Thumbnail → *From renders*), which work regardless of the project flag — one shot on an otherwise hands-off show. With no explicit export, the newest export of that shot that has a thumbnail is used.
|
||
|
||
Note this is the one place a render-derived artifact reaches the client portal. It is a still, not a `Version`, so §10.0 is intact — no technical render re-enters the review flow and no approval state moves — but a client viewing a shot with no supplied still now sees a frame from the render rather than a placeholder.
|
||
|
||
### 9.2 Colour configuration
|
||
|
||
`Project.deliveryConfig.colorPipeline` (per 18.1-Q4/Q12) — **required and unique per project**; enabling the pipeline on a project without it is a validation error, and values must never be carried over from another show:
|
||
|
||
```json
|
||
{ "workingSpace": "ACES2065-1",
|
||
"logSpace": "<per project, e.g. ACEScct>",
|
||
"showLut": "//SAN/luts/UNG/UNG_show_v02.cube" }
|
||
```
|
||
|
||
The AE template consumes these via the existing connector transform-loading functions; validation's `colourspace` check expects `workingSpace` on the EXRs.
|
||
|
||
### 9.3 Fallback engine
|
||
|
||
`SystemConfig: preview.engine = "ae" | "ffmpeg"` (default `"ae"`). The ffmpeg path (no slate, drawtext burn-ins, LUT applied via `lut3d`) exists only as an emergency fallback if headless AE proves unreliable on a machine — it produces clearly-marked non-delivery-grade previews ("FALLBACK PREVIEW" burn-in) and never populates `deliveryMovPath`, so a fallback preview can never leak into a delivery package.
|
||
|
||
Cost note: the AE preview pass roughly doubles machine time per export. This matches current studio practice (preview comps are rendered today) — it is now simply unattended.
|
||
|
||
Finalize (E13) then creates the `Version` row server-side (Section 6.4) in pipeline mode — visible internally, never client-shared, no task/shot status changes.
|
||
|
||
**Re-exports inherit approval, and carry no artist-facing text.** A shot is routinely re-exported after client approval — most often for an unscaled/un-punched-in delivery pass — and that must not read as new work awaiting review. The new `Version.approvalStatus` is copied from the task's previous latest version rather than left at its `PENDING_REVIEW` default; an export that starts a task's history has nothing to inherit and gets `PENDING_REVIEW` as before. `Version.notes` is left unset rather than stamped with an internal description: `notes` is the artist's message to reviewers and is rendered verbatim in the client portal and the review player, so pipeline provenance belongs on the Export and its `ExportEvent` timeline — where a human would go looking for it — not in a field a client can read.
|
||
|
||
**Known gap:** `shareWithClient` (and the per-task share action) promote whichever `Version` is `isLatest` for a task to `isClientVisible: true`, with no awareness that a pipeline-created version is meant to stay internal permanently (this section, "never client-shared"). Re-sharing a shot after a pipeline re-export will currently expose the internal version if it is the task's latest. Closing this needs a way to identify a pipeline-origin version at the share call sites — `Export.versionId` is presently a one-way scalar with no inverse relation on `Version` — and is not yet implemented.
|
||
|
||
### 9.3a Where a preview sits in the queue
|
||
|
||
The queue is ordered `priority ASC, createdAt ASC`, and a preview job is only created when its render *finishes* — so at equal priority it sorts behind every render queued alongside it. Ten shots queued together would run as ten renders and then ten previews, with nothing reviewable until the end.
|
||
|
||
`SystemConfig: render.previewPriorityBoost` (default 1) subtracts from the render's priority when the preview job is created, so a finished shot completes before the next render starts. Priority is otherwise inherited, which is what keeps an urgent export urgent all the way to READY_FOR_QC. The boost never promotes a job across `render.urgentPriorityThreshold`: a normal preview must not gain permission to run outside the render window that its render never had. Set the boost to `0` for the old phase behaviour.
|
||
|
||
Independently of priority, a worker **does not claim `PREVIEW_ONLY` jobs while an interactive After Effects is running** on that machine (§9.1) — a headless launch would hijack the artist's session. During the working day previews therefore queue regardless of rank, which is usually the larger effect on when they run.
|
||
|
||
### 9.4 Skipping the preview (`Export.skipPreview`)
|
||
|
||
Not every export is framed like its review. A deliverable rendered unscaled or without the punch-in shares a shot and a version history with the review, but a preview rebuilt from *its* frames would show the wrong picture — the earlier export's MOV is the correct one.
|
||
|
||
`skipPreview` (set at Queue Export, or per-action when overriding a validation failure) renders the EXRs and goes straight to READY_FOR_QC, **inheriting** `deliveryMovPath`, `previewMovKey`, `thumbnailKey` and `versionId` from the newest earlier export of that shot which has them. Inheritance matters as much as the skip: the delivery builder takes its MOV from `deliveryMovPath`, so an export with none would package EXRs alone. QC is unaffected — the export still needs a human pass before it can be delivered.
|
||
|
||
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
|
||
|
||
---
|
||
|
||
## 10. QC Workflow
|
||
|
||
### 10.0 Purpose (studio decision, 18.1-Q11)
|
||
|
||
This QC stage is **technical/final-delivery QC on approved shots** — the last human check before EXRs ship. The shot has usually already been client-approved through the existing review process; QC exists to catch what that process can't: mask slips, export glitches/artifacts, and technical faults (missing frames, incorrect metadata/timecodes, wrong format/resolution/colourspace). The automated validation (Section 8) covers the mechanical half; human QC covers the visual half. Consequently:
|
||
|
||
- **QC PASS means "ready to upload" and nothing else.** It never shares anything to the client review platform, never changes approval state, never creates client-visible media.
|
||
- Pipeline-created preview Versions are internal-only, permanently (T12).
|
||
|
||
### 10.1 Flow
|
||
|
||
1. Export reaches READY_FOR_QC; it appears in the panel's QC queue (E5) and the web QC page.
|
||
2. Artist opens the lightweight QC AE project; selecting a queue entry makes the panel import the EXR sequence from `outputDir` into the QC comp (panel already has import-renders machinery) — frame-accurate, full-res QC in AE, not the H.264 preview.
|
||
3. Artist clicks **QC Pass** or **QC Fail** (optional notes) → E4.
|
||
4. PASS → READY_FOR_DELIVERY. FAIL → QC_FAILED + comp task → CHANGES; the artist fixes and Queue Export creates the next version (old Export → SUPERSEDED).
|
||
|
||
### 10.2 Rules
|
||
|
||
- QC is per-Export. A new Export always requires new QC.
|
||
- Web users can also QC from the Export detail page (same service function, session auth) — QC is not locked to AE.
|
||
- Self-QC is allowed (single-artist reality); the `QCReview` row records who, so a policy can be layered later without schema change.
|
||
- **No client-review side effects on PASS** (Section 10.0). QC FAIL still sets the comp task to CHANGES — the shot genuinely needs rework, and the existing REVISIONS derivation correctly surfaces that on internal dashboards.
|
||
|
||
---
|
||
|
||
## 11. Delivery Builder
|
||
|
||
### 11.1 Flow
|
||
|
||
1. Web UI: user picks project + episode → E18 shows e.g. *"Episode 101 — Ready: 18 shots"* with the exact Export versions.
|
||
2. **Build Netflix Delivery** → E16 creates the package (QUEUED) + `DELIVERY_BUILD` job.
|
||
3. A worker claims it (same E8 queue), and for each item: create folders per template, **hard-link** EXRs (fallback copy), copy/link MOV, verify per-file checksums against `Export.checksum` data, write manifest + report, then report completion (package READY, exports PACKAGED). Progress via E9 (items done / total).
|
||
4. Human uploads the folder to Content Hub, clicks **Mark Delivered** (E19) → DELIVERED.
|
||
|
||
### 11.2 Naming configuration — `Project.deliveryConfig`
|
||
|
||
Default template encodes the studio's confirmed structure (18.1-Q6): `/DELIVERY/{episode}/{YYMMDD}_Delivery/{shotCode}/` with EXR sequence and the slate/burn-in preview MOV together in each shot folder.
|
||
|
||
```json
|
||
{
|
||
"packageNameTemplate": "{yymmdd}_Delivery",
|
||
"layout": {
|
||
"packageDir": "{deliveryRoot}/{episode}/{packageName}",
|
||
"shotDir": "{packageDir}/{shotCode}",
|
||
"reportFile": "{packageDir}/{packageName}_report.csv"
|
||
},
|
||
"deliveryRoot": "//SAN/DELIVERY",
|
||
"includeMovs": true,
|
||
"checksumAlgo": "xxh64",
|
||
"reportFormats": ["csv", "json"],
|
||
"colorPipeline": { "workingSpace": "ACES2065-1", "logSpace": "ACEScct", "showLut": "//SAN/luts/UNG/UNG_show_v02.cube" },
|
||
"preview": { "engine": "ae", "movOutputTemplate": "VFXR_PREVIEW_MOV" }
|
||
}
|
||
```
|
||
|
||
Per shot folder the builder places the EXR sequence files and the delivery MOV (`Export.deliveryMovPath`) side by side — no `exr/`/`mov/` subfolders, matching current practice. `{episode}` renders as `101` (season.episode as used today); `{yymmdd}` from the build date (overridable in E16 `options.date`).
|
||
|
||
Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shotCode} {versionString} {packageNumber}`. Null config → `SystemConfig` default template. The resolved config is snapshotted into `DeliveryPackage.configSnapshot`, so historic packages always show the rules they were built with, and future productions with different specs are a config change, not a code change.
|
||
|
||
### 11.3 Hard links vs copying
|
||
|
||
**Recommendation: hard link when `deliveryRoot` and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible).** Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory).
|
||
|
||
**Verified on the studio volume (2026-08-13):** `V:` is `\\TRUENAS\vfx` over SMB, and hard links work on it — `mklink /H` succeeds and a write through one name is visible through the other. Renders (`V:/_EXPORTS/UNG/{ep}/{shot}/{ver}`) and the delivery root (`V:/_EXPORTS/UNG/DELIVERY`) are on the same share, so the fast path applies to real deliveries today.
|
||
|
||
**Implementation trap:** `fsutil hardlink list` fails over SMB (`Error 50: The request is not supported`), so the builder **cannot enumerate links to verify them** on a network volume. Verify instead by hashing the delivery-side file and comparing against the validation-time xxHash — that reads the same bytes whether the entry is a link or a copy, so one verification path covers both modes. Post-copy verification is the same check plus size. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation.
|
||
|
||
### 11.4 Manifest and delivery report
|
||
|
||
- **Manifest** (`DeliveryPackage.manifest`, also `manifest.json` inside the package): every file with relative path, bytes, checksum, source Export id, plus tool/pipeline versions.
|
||
- **Delivery report** (CSV per `reportFormats`, uploaded via storage abstraction to `deliveries/` and written into the folder): one row per shot — shotCode, versionString, frame range, frame count, resolution, colourspace, EXR count/bytes, MOV name, QC reviewer/date, checksums. This is the client-facing document; columns confirmed against the delivery spec before first real delivery (18.2-C2) and configurable via `deliveryConfig`.
|
||
|
||
### 11.5 Delivery history
|
||
|
||
The Delivery Packages page (Section 13) lists every package permanently: `#15 · 101/260805_Delivery · Episode 101 · 18 shots · built 2026-08-05 · delivered 2026-08-05 by Chris`, expandable to items → Export → full render/validation/QC history. Packages are never deleted; a mistaken package is CANCELLED (DRAFT/QUEUED/FAILED only) or superseded by building a new one.
|
||
|
||
### 11.6 MOV review packages (`DeliveryPackage.kind = MOV_REVIEW`)
|
||
|
||
A formal review is shared as a flat, dated folder of the preview MOVs — the same package/job/verification machinery as §11.1–11.5, a different shape on disk:
|
||
|
||
```
|
||
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/
|
||
├── EP6_VFX_012_v004.mov ← client naming where the production supplies it
|
||
├── EP6_VFX_013_v002.mov
|
||
└── UNG_106_010_040_cmp_TT_v001.mov ← ours where it does not
|
||
```
|
||
|
||
The second review that day is `_02`, the third `_03`; the first carries no suffix. Numbering counts every package built under that base — cancelled and failed ones included — so a number is never reused for a different folder. Config: `reviewNameTemplate` (default `{yyyymmdd}_Producer_review`) and `layout.reviewDir` (default `{deliveryRoot}/PRODUCER_REVIEWS/{packageName}`).
|
||
|
||
Three rules separate a review from a delivery:
|
||
|
||
- **It never advances an Export.** Reviews happen throughout a shot's life, long before QC passes it, and the same version is routinely shown twice. Nothing is marked PACKAGED or DELIVERED when the folder is built, and `Mark delivered` is refused on a review package.
|
||
- **Selection is by shot, not by export.** The user picks shots on Shot Status → *Bulk Actions → Build MOV Review Package*; the server resolves each to its **newest export that actually has a `deliveryMovPath`** — not necessarily its newest export, since a shot whose latest version is still rendering should go into the review as the version that can be watched. Shots with no MOV at all are listed in the confirm dialog and left out rather than silently dropped.
|
||
- **The folder is exactly the MOVs.** No CSV, no `manifest.json` written beside them. The manifest still returns to the server, so the record of what was shared is kept without cluttering the share.
|
||
|
||
Verification is proportional as in §11.3, adapted to a single file with no validation-time digest: a hard link is the source and needs nothing; a copy is checked against its source by length always, and by full hash under `"verifyMode": "always-hash"`. A missing MOV **fails the item** here — in an EXR delivery the MOV is a viewing copy beside the real deliverable, but in a review it *is* the deliverable, and a review folder quietly one shot short is worse than one that says so.
|
||
|
||
---
|
||
|
||
## 12. AE Panel Changes
|
||
|
||
The panel keeps all existing capabilities (episode/shot discovery, overlays, picture-lock pull, manual queue buttons remain as escape hatches). New/changed behaviour:
|
||
|
||
### 12.1 Panel status header (always visible per selected shot)
|
||
|
||
| Field | Source |
|
||
|---|---|
|
||
| Shot | existing `/api/ext/shots/lookup` |
|
||
| Current Version | `shot.shotVersion` (lookup) |
|
||
| Current Status | `shot.status` (lookup) |
|
||
| Latest Export + QC status | new E2 `exports/latest` — e.g. `v004 — READY_FOR_QC`, `v004 — RENDERING 42% (ETA 1:36 on RENDER-01)`, `v003 — QC FAILED: "grain mismatch"` |
|
||
|
||
While an export is active the panel polls E2 every 15 s to live-update the header.
|
||
|
||
### 12.2 Buttons and their API calls
|
||
|
||
| Button | Behaviour | API calls |
|
||
|---|---|---|
|
||
| **Queue Export** | Save project (scripted), build manifest from lookup data + active comp, submit. No local rendering, no version click. Confirms: "Queued v004 on render farm." | `GET lookup` → `POST /api/ext/exports` (E1) |
|
||
| **Open Latest Export** | Import latest export's EXR sequence into the current project (existing import-renders code, pointed at E2's `outputDir`) | E2, then local import |
|
||
| **Load QC Queue** | Populate QC list (shot, version, ready-since, thumbnail); selection imports that render into the QC comp | `GET /api/ext/qc/queue` (E5), E3 |
|
||
| **QC Pass** | One click; optional notes field beside it | `POST /api/ext/exports/{id}/qc {PASS}` (E4) |
|
||
| **QC Fail** | Notes strongly encouraged (panel nags if empty) | E4 `{FAIL}` |
|
||
| **Retry Export** (shown only when latest export is in a *_FAILED state) | Re-queue | `POST /api/ext/exports/{id}/retry` (E14) |
|
||
|
||
### 12.3 Removed from the artist's world
|
||
|
||
Increment Version button (server does it on E1 — panel may keep it hidden behind an "advanced" toggle for emergencies), Prep Delivery PowerShell scripts (replaced by Section 11), manual preview-comp/MP4 queueing for review purposes (worker generates the review MOV).
|
||
|
||
---
|
||
|
||
## 13. Web UI Changes
|
||
|
||
All new pages use existing NextAuth sessions, existing layout/navigation, and the internal mirrors of the APIs (E15–E21). Suggested routes under `/(dashboard)`:
|
||
|
||
| Page | Route | Contents |
|
||
|---|---|---|
|
||
| **Pipeline Dashboard** | `/pipeline` | Cards: queued / rendering / failed / ready-for-QC / ready-for-delivery counts; machines online; last 24 h throughput; recent failures |
|
||
| **Render Queue** | `/pipeline/queue` | Live table (poll 5 s): shot, version, status, progress bar, ETA, machine, attempt; row actions Retry/Cancel/Bump priority; filters project/episode/status |
|
||
| **Export detail** | `/pipeline/exports/[id]` | State timeline (ExportEvent), manifest, per-attempt logs (logFileKey), validation table, preview player (existing player component), QC history, delivery membership |
|
||
| **QC Queue** | `/pipeline/qc` | READY_FOR_QC grid with thumbnails; preview player; Pass/Fail with notes (same service as E4) |
|
||
| **Delivery Packages** | `/pipeline/deliveries` | Episode picker → "Ready: N shots" → Build button; package list with status chips; detail = items, report download, Mark Delivered |
|
||
| **Machine Monitoring** | `/pipeline/machines` | Machine cards: status, current job, CPU/mem/disk sparkline (WorkerHeartbeat), AE/worker versions; Enable/Disable toggle |
|
||
| **Validation Results** | inside Export detail + `/pipeline/validation` | Filterable failed-check browser across exports (spot systemic issues, e.g. every shot failing colourspace) |
|
||
| **Render History** | `/pipeline/history` | All RenderJobs: durations, machines, success rate; per-machine and per-episode aggregates |
|
||
|
||
Small touches to existing pages: Shot detail gains an "Exports" tab (list of Exports with status chips); the existing version player is untouched (previews are Versions).
|
||
|
||
---
|
||
|
||
## 14. Configuration Summary
|
||
|
||
### 14.1 New SystemConfig keys (defaults)
|
||
|
||
| Key | Default | Meaning |
|
||
|---|---|---|
|
||
| `render.pollSeconds` | 10 | worker claim poll |
|
||
| `render.heartbeatSeconds` | 30 | heartbeat interval |
|
||
| `render.leaseSeconds` | 300 | job lease |
|
||
| `render.maxAttempts` | 3 | auto-retry ceiling |
|
||
| `render.stallTimeoutSeconds` | 600 | no-progress kill |
|
||
| `validation.sampleEvery` | 25 | header-check sampling stride |
|
||
| `preview.maxWidth` | 1920 | preview scale |
|
||
| `delivery.defaultConfig` | JSON | fallback `deliveryConfig` |
|
||
|
||
### 14.2 New environment variables
|
||
|
||
None required server-side (deliberate — everything is `SystemConfig` or per-project). Worker config is file-based (7.8).
|
||
|
||
---
|
||
|
||
## 15. Security and Authentication
|
||
|
||
| Surface | Mechanism |
|
||
|---|---|
|
||
| Worker + panel endpoints (`/api/ext/*`) | Existing shared `API_SECRET_KEY` bearer/x-api-key — **confirmed studio decision (18.1-Q7): one key for now, all phases**. `Machine.apiKeyHash` stays in the schema so per-machine keys are a config feature, not a migration, if the fleet grows |
|
||
| Web UI + internal APIs | Existing NextAuth session; new pages respect existing role checks (delivery build + mark-delivered restricted to admin/coordinator roles per existing role model) |
|
||
| Attribution | `submittedByEmail`/`reviewerEmail` resolved to `User` rows by email where possible; stored as free text otherwise (panel is API-key-authed, not user-authed — acceptable per 18.1-Q7) |
|
||
| Middleware | `/api/ext/` is already allow-listed in middleware with per-route auth in handlers; new routes follow the identical pattern |
|
||
| Path safety | Server-generated output/delivery paths only from templates + validated tokens (`shotCode` regex, episode regex); worker refuses to delete/clear any directory outside configured render/delivery roots |
|
||
|
||
---
|
||
|
||
## 16. Failure Handling and Operations Summary
|
||
|
||
| Scenario | Behaviour |
|
||
|---|---|
|
||
| Worker crash / power loss mid-render | Lease expires → reaper requeues (T5) or fails after max attempts; orphan output cleaned by next attempt |
|
||
| Server down mid-render | Worker spools reports to disk, keeps rendering, replays in order on reconnect |
|
||
| Two workers race for one job | Impossible: `FOR UPDATE SKIP LOCKED` claim |
|
||
| Zombie worker reports on reassigned job | Rejected: machineId ≠ lease holder |
|
||
| Artist queues while a render is active | 409 unless `force` (which supersedes + cancels the active job) |
|
||
| aerender hangs silently | `stallTimeoutSeconds` kill → fail/retry |
|
||
| Validation fails at 3 am | Pipeline stops at VALIDATION_FAILED; exact check rows recorded; dashboard + (existing Slack webhook, optional Phase 3 nicety) surface it in the morning |
|
||
| Delivery volume out of space | Build job fails with explicit error; package FAILED; retryable after cleanup |
|
||
| Machine misbehaving | Admin toggles `enabled=false` → cannot claim; running job finishes or is cancelled |
|
||
|
||
---
|
||
|
||
## 17. Implementation Plan
|
||
|
||
Five independently deployable phases. Each phase leaves the previous workflow fully functional; artists can fall back to today's manual flow at any point until Phase 5 completes.
|
||
|
||
### 17.1 Phase 1 — Render Queue (schema + queue API + panel Queue Export)
|
||
|
||
| Aspect | Content |
|
||
|---|---|
|
||
| Objectives | Queue Export from panel creates Export/RenderJob; jobs visible in web UI; **no worker yet** (jobs sit QUEUED; a temporary "mark as done manually" admin action lets the studio keep using the old render path while the queue is validated) |
|
||
| DB | Migrations: all Section 5 enums + `Export`, `RenderJob`, `ExportEvent`; `Project.deliveryConfig` column; `@@unique([shotId, versionNumber])` |
|
||
| API | E1, E2, E3, E14, E21 (queue list), internal retry/cancel (E15); manifest Zod schema; service layer `lib/render-pipeline/exports.ts` |
|
||
| UI | Render Queue page (basic table), Export detail (manifest + events), Shot detail Exports tab |
|
||
| Panel | Queue Export button + status header (E2 polling); Increment Version hidden |
|
||
| Risks | Version-increment race between panel-PATCH legacy path and E1 → mitigate: E1 increments transactionally and panel stops PATCHing when the new button is used; duplicate `@@unique` guards |
|
||
| Testing | Unit: manifest validation, version increment transaction, state transitions incl. 409s. Integration: two concurrent E1 calls for one shot. Panel: queue against staging |
|
||
| Migration | Purely additive migration; deploy = normal `prisma migrate deploy` in entrypoint; no data backfill |
|
||
|
||
### 17.2 Phase 2 — Render Worker
|
||
|
||
| Aspect | Content |
|
||
|---|---|
|
||
| Objectives | The two artist workstations render queued jobs end-to-end to "EXRs on disk + job COMPLETED"; after-hours windows + Render Now override working; multi-machine ready by construction |
|
||
| DB | `Machine` (incl. `availability`, `renderNowUntil`), `WorkerHeartbeat` |
|
||
| API | E6–E11, E20; atomic claim SQL with availability gate (7.10); reaper task; internal machines API incl. Render Now toggle |
|
||
| UI | Machine Monitoring page (status, current job, Render Now button, window display); live progress/ETA in Render Queue |
|
||
| Worker | .NET service: registration, heartbeat, claim loop, aerender runner, progress parsing, lease renewal, crash recovery, spooled reporting, path mapping, installer + docs |
|
||
| Risks | Worker shares hardware with working artists → mitigated by schedule windows + server-side claim gate; AE version drift between the two workstations silently changes renders → registration reports `aeVersion`, dashboard warns on mismatch; stdout format differences across AE versions → progress parser tolerant, integration-tested per AE release; SAN permissions for the service account |
|
||
| Testing | Worker unit tests with recorded aerender transcripts (success, error, stall); kill -9 worker mid-render → verify reaper requeue; two workers + three jobs → no double claim; network-cut replay test; availability-window claim tests (in/out of window, Render Now, urgent priority) |
|
||
| Migration | Additive migration; install worker on one workstation first, second after a clean week; `render.maxAttempts=1` for first week (observe before auto-retrying) |
|
||
|
||
### 17.3 Phase 3 — Validation
|
||
|
||
| Aspect | Content |
|
||
|---|---|
|
||
| Objectives | Every render automatically validated; failures stop pipeline with exact reasons |
|
||
| DB | `ValidationResult` |
|
||
| API | E12; transitions T3→T7/T8 activated (Phase 2 interim: complete-render goes straight to a provisional READY_FOR_QC; Phase 3 inserts VALIDATING between) |
|
||
| UI | Validation table on Export detail; Validation Results browser; failure badge in queue |
|
||
| Worker | Validation engine (oiiotool integration, all Section 8.1 checks, checksums); oiiotool bundled with installer |
|
||
| Risks | ~~Timecode metadata may be absent in AE-written EXRs~~ — disproved on the live show (§8.1a): AE writes `timeCode`, so absence is now itself a FAIL. Colourspace metadata may still be absent → that check SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`). **The timecode check is the phase's priority and ships first** — it needs no oiiotool, and 11 of 106 delivered sequences on the current show would have been caught by it |
|
||
| Testing | Golden EXR fixture sets: complete, gapped, duplicate, wrong-res, wrong-depth, no-alpha, corrupt, zero-byte; checksum determinism; large-sequence perf (500 frames < 60 s) |
|
||
| Migration | Additive; existing in-flight exports unaffected (transition map keys off manifest version) |
|
||
|
||
### 17.4 Phase 4 — Preview + QC
|
||
|
||
| Aspect | Content |
|
||
|---|---|
|
||
| Objectives | Automatic slate/burn-in delivery MOV + web preview + thumbnail + metadata; renders appear as internal-only Versions; QC queue live in panel and web |
|
||
| DB | `QCReview` |
|
||
| API | E13 (finalize incl. pipeline-mode Version creation via existing version service), E4, E5; QC internal routes |
|
||
| UI | QC Queue page; QC history on Export detail |
|
||
| Worker | Headless AE preview stage (`AfterFX.com -noui -r` driving the studio slate/overlay template + colour chain, Section 9.1), ffmpeg web transcode + thumbnail, artifact upload via presign; ffmpeg fallback engine |
|
||
| Panel | Load QC Queue, QC Pass/Fail, Open Latest Export; preview-comp JSX refactored so panel and worker share one script source |
|
||
| Risks | Headless AE (`-noui`) reliability on workstations is the phase's main unknown → prove it in week 1 with a spike before building around it (18.2-C4); preview colour must match reference and be supervisor-signed-off before artists trust it; pipeline-mode Version creation must not regress the manual upload path and must never leak client-visible media on approved shots → shared function + explicit regression tests for both |
|
||
| Testing | Preview parity: pipeline MOV vs artist-rendered preview of same shot (slate fields, burn-ins, colour) signed off by supervisor; Version integration: pipeline Version invisible in client portal on a shared shot; QC transition tests incl. task→CHANGES and no-side-effect PASS |
|
||
| Migration | Additive; announce to artists that preview comps/MOVs are now automatic |
|
||
|
||
### 17.5 Phase 5 — Delivery Builder
|
||
|
||
| Aspect | Content |
|
||
|---|---|
|
||
| Objectives | One-click dated Netflix package from DB; permanent delivery history; old PowerShell prep retired |
|
||
| DB | `DeliveryPackage`, `DeliveryItem` |
|
||
| API | E16–E19; DELIVERY_BUILD job type through existing claim path |
|
||
| UI | Delivery Packages page (episode picker, ready counts, build, history, mark delivered); Pipeline Dashboard completed |
|
||
| Worker | Delivery builder stage: template resolution, hard-link/copy, verification, manifest + report writing |
|
||
| Risks | Folder structure and checksum approach are decided (18.1-Q6/Q13); report columns still need confirming against the delivery spec before first real delivery (18.2-C2); hard links on non-NTFS delivery volume → auto-fallback copy is default-safe |
|
||
| Testing | Package build fixture (3 shots) → structure, links vs copies, checksum verification, report content; immutability: re-render a packaged shot → package untouched, new Export not auto-included; permission tests on build/mark-delivered roles |
|
||
| Migration | Additive; run one parallel delivery (old script + new builder) and diff the folders before cutover |
|
||
|
||
### 17.6 Dependency graph
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
P1["Phase 1 Queue"] --> P2["Phase 2 Worker"] --> P3["Phase 3 Validation"] --> P4["Phase 4 Preview+QC"] --> P5["Phase 5 Delivery"]
|
||
```
|
||
|
||
Strictly sequential; each phase is shippable and useful on its own (P1: visibility; P2: hands-off rendering; P3: trust; P4: hands-off review; P5: hands-off delivery).
|
||
|
||
---
|
||
|
||
## 18. Design Decisions — Resolved and Remaining
|
||
|
||
### 18.1 Resolved decisions (studio input, 2026-07-31)
|
||
|
||
The original open questions were answered by the studio. The decisions below are **normative** and already folded into the sections referenced.
|
||
|
||
| Q | Topic | Decision | Where applied |
|
||
|---|---|---|---|
|
||
| Q1 | Render fleet | aerender runs on the **two artist workstations**. After-hours auto-render via per-machine schedule windows; **manual "Render Now" trigger** for urgent daytime renders; urgent-priority jobs may claim anytime | 7.10, 5.6 (`Machine.availability`, `renderNowUntil`), E8 gate |
|
||
| Q2 | EXR storage | EXRs are **never uploaded to object storage**; SAN only | 2.3 |
|
||
| Q3 | Export concurrency | **Single active Export per shot** (E1 409 + `force` supersede) | 6.4 E1 |
|
||
| Q4 | Colour management | Per-project `colorPipeline` config. ACES workflows: plates/renders **linear ACES2065-1**; reference match = **linear → per-project log → show LUT** | 9.2, 8.1 colourspace check |
|
||
| Q5 | Burn-ins / slates | Use the **existing AE slate + overlay templates**: 1-frame head slate, burn-ins throughout, connector functions pull slate/overlay data from the API and load the colour transforms. Worker runs this headlessly; ffmpeg is emergency fallback only | 9 (rewritten) |
|
||
| Q6 | Delivery structure | `/DELIVERY/{episode}/{YYMMDD}_Delivery/{shotCode}/` containing EXR sequence + slate/burn-in preview MOV together | 11.2 default config |
|
||
| Q7 | Auth | **One shared `API_SECRET_KEY` for now**; `Machine.apiKeyHash` remains as dormant schema support | 15 |
|
||
| Q8 | Reaper | As recommended: `instrumentation.ts` interval + defensive sweep on each E8 claim | 7.7 |
|
||
| Q9 | Outputs per shot | **One comp → one EXR sequence per version**; future `ExportOutput` child table is an additive migration if ever needed | 6.2 |
|
||
| Q10 | Retention | As recommended: pipeline never auto-deletes; "reclaimable space" report; humans delete | 16 |
|
||
| Q11 | QC semantics | QC is **technical/final-delivery QC on approved shots** (mask slips, export glitches, missing frames, metadata/timecode, format/res/colourspace). **PASS = ready to upload, nothing shared to client review**; pipeline Versions are internal-only | 10.0, T12/T14, 6.4 E13 |
|
||
| Q12 | Per-project colour values | `colorPipeline` (log space + show LUT) is **required and unique per project** — set at project setup, supervisor signs off one reference comparison before Phase 4 goes live on that project | 9.2 |
|
||
| Q13 | Delivery report / checksums | Dual support as recommended: `xxh64` for internal verification, `md5` available for the client-facing manifest via `checksumAlgo`; report columns confirmed against the delivery spec before the first real delivery (18.2-C2) | 11.4 |
|
||
| Q14 | Preview MOV codec | Lives in the AE output-module template; record the current template name in `deliveryConfig.preview.movOutputTemplate` at Phase 4 setup (18.2-C3) | 9.1, 11.2 |
|
||
| Q15 | Headless AE approach | As recommended: `AfterFX.com -noui` with the week-1 Phase 4 spike; documented fallbacks stand if the spike fails (18.2-C4) | 9.1, 17.4 |
|
||
| Q16 | Render windows / urgent policy | Defaults accepted: weekdays 19:00–08:00 + full weekends, Render Now = 4 h, urgent queueing available to all users; exact per-machine times collected at Phase 2 rollout (18.2-C5) | 7.10 |
|
||
|
||
### 18.2 Implementation-time checklist
|
||
|
||
No design decisions remain. These are concrete values to collect or verifications to run at the noted point — each traces back to a resolved decision in 18.1.
|
||
|
||
| # | Item | When | What to do |
|
||
|---|---|---|---|
|
||
| C1 | Per-project colour values | Pipeline enablement per project (first: Phase 4 setup) | Enter the project's log space and show LUT path into `deliveryConfig.colorPipeline` — **required, unique per project** (Q12); supervisor signs off one pipeline-vs-reference comparison before artists rely on previews |
|
||
| C2 | Delivery report columns + client checksum requirement | Before the first real Phase 5 delivery | Confirm report columns and whether the client mandates MD5 sidecars against the production's delivery spec; set `checksumAlgo` accordingly (Q13) |
|
||
| C3 | Preview MOV output template | Phase 4 setup | Record the studio's current "LT Preview" AE output-module template name in `deliveryConfig.preview.movOutputTemplate` (Q14) |
|
||
| C4 | Headless AE spike | Week 1 of Phase 4, before further preview code | Render one preview via `AfterFX.com -noui -r` under the service account on **both** workstations, after hours, artist logged out. If it fails: fall back to panel-pre-built preview comp in the artist's AEP (plain `aerender` second comp), then ffmpeg engine as last resort (Q15) |
|
||
| C5 | Render windows + Render Now duration | Phase 2 rollout | Confirm per-machine window times against the accepted defaults (weekdays 19:00–08:00 + weekends, Render Now 4 h, urgent for all users) and enter into `Machine.availability` (Q16) |
|
||
|
||
---
|
||
|
||
## 19. Document Map for Implementers
|
||
|
||
| Section | Governs | Primary phase |
|
||
|---|---|---|
|
||
| 4 | All state transitions (server-enforced) | 1 |
|
||
| 5 | Schema migrations | 1, 2, 3, 4, 5 |
|
||
| 6 | API contracts + examples | 1, 2, 3, 4, 5 |
|
||
| 7 | Worker service | 2 |
|
||
| 8 | Validation checks | 3 |
|
||
| 9 | Preview artifacts | 4 |
|
||
| 10 | QC semantics | 4 |
|
||
| 11 | Delivery builder | 5 |
|
||
| 12 | AE panel | 1, 4 |
|
||
| 13 | Web UI | 1–5 |
|
||
| 17 | Sequencing, testing, migration | all |
|
||
| 18.1 | Resolved studio decisions (normative) | all |
|
||
| 18.2 | Implementation-time checklist (values/verifications) | as noted |
|
||
|
||
End of specification.
|