From feb1c7b3e9719545b834045446d3e06ffbf50f6b Mon Sep 17 00:00:00 2001
From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com>
Date: Thu, 13 Aug 2026 13:49:51 +0200
Subject: [PATCH] feat(render-pipeline): validation, QC and delivery builder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Completes RenderPipeline2 phases 3-5. Additive throughout: with
validation.enabled=false the pipeline behaves exactly as before.
Validation (§8) runs on the worker straight after a render, while it
still holds the job and the files are local. Everything is read from the
EXR header — one 64 KB read per sampled frame, no pixel decode — so the
engine needs no oiiotool. The render job deliberately stays open through
the stage, so a worker that dies there is reaped and retried instead of
stranding the export in VALIDATING.
Timecode is the primary check and is anchored on the plate, never the
API: measured on the live show, Shot.timecodeStart is the editorial
in-point and sits one handle-length (8f) ahead of the plate, so checking
against it would pass the defective exports and fail the correct ones.
Reference precedence is manifest timing plate -> plate found on disk ->
API value, and the API rank is WARN-only. The panel now resolves plate
provenance by walking the comp's own footage layers, so the worker never
guesses which element carries the comp's timing.
Verified against real studio renders via the new --validate mode: it
catches both defect classes seen in shipped work — the +8 handle offset
and frame-derived timecode (00:00:41:17 = frame 1001 at 24fps).
QC (§10) is one service behind two front doors, API-key for the panel
and session for the web. PASS means ready to upload and nothing else;
FAIL sets the comp task to CHANGES. The panel gains a QC section that
imports the real EXR sequence at full res rather than the H.264.
Delivery (§11) rides the existing queue as a DELIVERY_BUILD job: hard
links with a copy fallback, verification by re-hashing delivered bytes
against the validation-time digest, manifest.json and the CSV report.
fsutil hardlink list is unavailable over SMB, so link identity is never
queried — reading the bytes back covers links and copies alike.
Also fixes ApiClient.ProgressAsync throwing on transport failure, which
would have aborted work in flight during a brief server outage; progress
reporting is best-effort by contract.
Removes the stale RenderWorker/ rule from the root .gitignore. The
worker's source is tracked like the rest of the repo and its own
.gitignore already covers bin/, obj/ and publish/; the rule silently
excluded new files while the existing 20 stayed tracked.
Not yet applied: migration 20260813000000 was hand-written because the
dev database was unavailable — run prisma migrate deploy and verify
before relying on it. None of this has run against a live server.
Co-Authored-By: Claude Opus 5
---
.gitignore | 4 +-
EXT_API_REFERENCE.md | 30 +
RenderPipeline2.md | 67 +-
RenderWorker/README.md | 46 +-
.../DeliveryStageTests.cs | 177 +++
.../VFXReviewWorker.Tests/ExrHeaderTests.cs | 193 +++
.../VFXReviewWorker.Tests/XxHash64Tests.cs | 61 +
RenderWorker/VFXReviewWorker/ApiClient.cs | 31 +-
RenderWorker/VFXReviewWorker/DeliveryStage.cs | 327 +++++
RenderWorker/VFXReviewWorker/Dtos.cs | 83 +-
RenderWorker/VFXReviewWorker/ExrHeader.cs | 234 ++++
RenderWorker/VFXReviewWorker/JobExecutor.cs | 156 ++-
RenderWorker/VFXReviewWorker/Program.cs | 9 +
RenderWorker/VFXReviewWorker/ReportSpooler.cs | 5 +
.../VFXReviewWorker/ValidateCommand.cs | 98 ++
.../VFXReviewWorker/ValidationStage.cs | 590 +++++++++
RenderWorker/VFXReviewWorker/WorkerOptions.cs | 6 +
RenderWorker/VFXReviewWorker/WorkerService.cs | 6 +-
RenderWorker/VFXReviewWorker/XxHash64.cs | 158 +++
VFXReviewConnector.jsx | 1166 +++++++++++++++--
VFXReviewConnector.md | 96 +-
.../pipeline/PipelineQueueClient.tsx | 8 +-
.../pipeline/deliveries/DeliveriesClient.tsx | 328 +++++
app/(dashboard)/pipeline/deliveries/page.tsx | 12 +
.../exports/[exportId]/ExportDetailClient.tsx | 120 ++
app/(dashboard)/pipeline/status-colors.ts | 10 +
.../deliveries/[packageId]/cancel/route.ts | 23 +
.../deliveries/[packageId]/delivered/route.ts | 24 +
app/api/deliveries/ready/route.ts | 24 +
app/api/deliveries/route.ts | 54 +
app/api/ext/exports/[exportId]/qc/route.ts | 39 +
app/api/ext/qc/queue/route.ts | 28 +
.../jobs/[jobId]/complete-render/route.ts | 6 +-
.../ext/render/jobs/[jobId]/delivery/route.ts | 39 +
.../render/jobs/[jobId]/validation/route.ts | 43 +
app/api/render/exports/[exportId]/qc/route.ts | 40 +
app/api/render/qc/queue/route.ts | 24 +
lib/render-pipeline/config.ts | 41 +-
lib/render-pipeline/deliveries.ts | 611 +++++++++
lib/render-pipeline/exports.ts | 23 +-
lib/render-pipeline/jobs.ts | 45 +-
lib/render-pipeline/manifest.ts | 64 +
lib/render-pipeline/qc.ts | 173 +++
lib/render-pipeline/reaper.ts | 26 +-
lib/render-pipeline/transitions.ts | 14 +-
lib/render-pipeline/validation.ts | 284 ++++
.../migration.sql | 110 ++
prisma/schema.prisma | 268 ++--
48 files changed, 5786 insertions(+), 238 deletions(-)
create mode 100644 RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs
create mode 100644 RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs
create mode 100644 RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs
create mode 100644 RenderWorker/VFXReviewWorker/DeliveryStage.cs
create mode 100644 RenderWorker/VFXReviewWorker/ExrHeader.cs
create mode 100644 RenderWorker/VFXReviewWorker/ValidateCommand.cs
create mode 100644 RenderWorker/VFXReviewWorker/ValidationStage.cs
create mode 100644 RenderWorker/VFXReviewWorker/XxHash64.cs
create mode 100644 app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx
create mode 100644 app/(dashboard)/pipeline/deliveries/page.tsx
create mode 100644 app/api/deliveries/[packageId]/cancel/route.ts
create mode 100644 app/api/deliveries/[packageId]/delivered/route.ts
create mode 100644 app/api/deliveries/ready/route.ts
create mode 100644 app/api/deliveries/route.ts
create mode 100644 app/api/ext/exports/[exportId]/qc/route.ts
create mode 100644 app/api/ext/qc/queue/route.ts
create mode 100644 app/api/ext/render/jobs/[jobId]/delivery/route.ts
create mode 100644 app/api/ext/render/jobs/[jobId]/validation/route.ts
create mode 100644 app/api/render/exports/[exportId]/qc/route.ts
create mode 100644 app/api/render/qc/queue/route.ts
create mode 100644 lib/render-pipeline/deliveries.ts
create mode 100644 lib/render-pipeline/qc.ts
create mode 100644 lib/render-pipeline/validation.ts
create mode 100644 prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql
diff --git a/.gitignore b/.gitignore
index 057b60a..a8c6cae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,5 +32,5 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
-# Local tooling
-RenderWorker/
+# RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/,
+# publish/) — the worker's source is tracked like the rest of the repo.
diff --git a/EXT_API_REFERENCE.md b/EXT_API_REFERENCE.md
index cb484e8..397b1ac 100644
--- a/EXT_API_REFERENCE.md
+++ b/EXT_API_REFERENCE.md
@@ -24,6 +24,30 @@ X-Api-Key:
| `POST` | `/api/ext/shots` | Create a new shot |
| `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) |
+### Render pipeline (RenderPipeline2)
+
+| # | Method | Path | Caller | Description |
+|---|--------|------|--------|-------------|
+| E1 | `POST` | `/api/ext/exports` | panel | Queue Export — creates Export + RenderJob |
+| E2 | `GET` | `/api/ext/exports/latest` | panel | Latest export + live status for the panel header |
+| E3 | `GET` | `/api/ext/exports/{exportId}` | panel/worker | Export detail incl. validations and QC |
+| E4 | `POST` | `/api/ext/exports/{exportId}/qc` | panel | QC Pass / QC Fail |
+| E5 | `GET` | `/api/ext/qc/queue` | panel | Exports in READY_FOR_QC |
+| E6 | `POST` | `/api/ext/workers/register` | worker | Register machine, receive tuning |
+| E7 | `POST` | `/api/ext/workers/{machineId}/heartbeat` | worker | Heartbeat + cancel channel |
+| E8 | `POST` | `/api/ext/render/jobs/claim` | worker | Atomically claim the next job |
+| E9 | `PATCH` | `/api/ext/render/jobs/{jobId}/progress` | worker | Progress/ETA; renews the lease |
+| E10 | `POST` | `/api/ext/render/jobs/{jobId}/fail` | worker | Report failure |
+| E11 | `POST` | `/api/ext/render/jobs/{jobId}/complete-render` | worker | aerender finished → VALIDATING |
+| E12 | `POST` | `/api/ext/render/jobs/{jobId}/validation` | worker | Report all validation results |
+| E13 | `POST` | `/api/ext/render/jobs/{jobId}/finalize` | worker | Preview artifacts done → READY_FOR_QC |
+| E14 | `POST` | `/api/ext/exports/{exportId}/retry` | panel | Retry a failed export |
+| E20 | `POST` | `/api/ext/render/jobs/{jobId}/artifact-presign` | worker | Presigned upload URL |
+| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | worker | Report a DELIVERY_BUILD result |
+
+Session-authenticated twins for the web UI live under `/api/render/*` and
+`/api/deliveries/*` — same service layer, no API key.
+
---
## Shot Status Values
@@ -513,3 +537,9 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`:
| Populate Shot dropdown | `GET /api/ext/projects/{PROJECT_CODE}/shots?episode={value}` |
| All shot operations (overlay, queue, preview, delivery) | `GET /api/ext/shots/lookup?shotCode={code}&projectCode={PROJECT_CODE}` |
| Increment Shot Version | `PATCH /api/ext/shots/{shot.id}` with `{ "shotVersion": "v00X" }` |
+| Queue Export | `POST /api/ext/exports` (E1) |
+| Refresh Status / poll | `GET /api/ext/exports/latest` (E2) |
+| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) |
+| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` |
+| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) |
+| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) |
diff --git a/RenderPipeline2.md b/RenderPipeline2.md
index 9c97af4..207c098 100644
--- a/RenderPipeline2.md
+++ b/RenderPipeline2.md
@@ -579,6 +579,19 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m
"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
@@ -588,6 +601,14 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m
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 element the comp's start time was anchored to (the panel's `primaryFootage`: longest duration, first on tie). A shot's elements routinely carry unrelated camera timecodes, so "the plate" must be named explicitly rather than guessed by the worker.
+- `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 |
@@ -1009,11 +1030,47 @@ Runs on the worker immediately after a successful render (Export = VALIDATING).
| `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_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic, or first frame ≠ `expected.timecodeStart` when set |
+| `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` rational + drop-frame flag on render vs timing plate vs manifest `fps` | any disagreement |
| `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`).
+### 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).
@@ -1126,7 +1183,11 @@ Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shot
### 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). Post-link verification: link target identity check; post-copy verification: size + xxHash against validation-time values. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation.
+**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
@@ -1278,7 +1339,7 @@ Five independently deployable phases. Each phase leaves the previous workflow fu
| 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 | Colourspace/timecode metadata may be absent in AE-written EXRs → those checks SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`) |
+| 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) |
diff --git a/RenderWorker/README.md b/RenderWorker/README.md
index 27578a8..7877253 100644
--- a/RenderWorker/README.md
+++ b/RenderWorker/README.md
@@ -60,8 +60,47 @@ the artist's AEP (then it is pure `aerender`), or the ffmpeg engine.
are never resumed — the next attempt clears its own output files and re-renders.
- Durable reporting: complete/fail reports spool to disk and replay in order with backoff —
rendering continues while the server is down.
+- **Validation (Phase 3)**: after a successful render the worker checks the sequence it just
+ produced and reports every result in one call (E12). See below.
-Preview generation (Phase 4) and validation (Phase 3) plug into this same service later.
+## Validation stage
+
+Runs on the worker straight after the render, while it still holds the job and the files are
+local. The Export sits in `VALIDATING` until the report lands; the server decides what the
+results mean (any FAIL → `VALIDATION_FAILED`, otherwise the preview stage is queued).
+
+Checks: `file_count`, `missing_frames`, `duplicate_frames`, `first_frame`, `last_frame`,
+`filename_consistency`, `zero_byte`, `corrupt`, `resolution`, `bit_depth`, `alpha_channel`,
+`exr_type`, `timecode_plate_match`, `timecode_consistency`, `timecode_fps`.
+
+**No oiiotool needed.** Everything is read from the EXR header — one 64 KB read per sampled
+frame, no pixel decode. Full-sequence checks (count, gaps, names, sizes) touch every file;
+header checks sample first/middle/last plus every Nth frame (`validation.sampleEvery`).
+
+**Timecode is the primary check** (spec §8.1a) and is compared against *the plate*, in this
+order: the timing plate named in the manifest → a plate found under `plateSearchRoots` →
+the API timecode minus handles, which is **WARN-only** because the API stores the editorial
+in-point and is a handle-length ahead of the plate by design.
+
+Checksums are xxHash64 per file, with a sequence digest stored on the Export and reused by
+the delivery builder to verify what it packaged.
+
+Server-side switches (SystemConfig, no worker redeploy): `validation.enabled`,
+`validation.sampleEvery`, `validation.handleFrames`, and `validation.overrides` for per-check
+severity, e.g. `{"exr_type":"warn"}`.
+
+### Checking a render by hand
+
+```powershell
+.\VFXReviewWorker.exe --validate "V:\_EXPORTS\UNG\102\UNG_102_019_010\v005" --plate "V:\_FOOTAGE\UNG\UNG_102\UNG_102_019_010_BG01_TT_v001\UNG_102_019_010_BG01_TT_v001.01001.exr"
+```
+
+Runs the whole engine over an existing folder with no server, job or queue, and prints the
+report. The frame range, pattern and resolution are inferred from the files. Exits non-zero
+if any check fails, so it also works in a script. Useful for auditing work that predates the
+pipeline, and for proving the checks on real shots before turning the stage on.
+
+Preview generation (Phase 4) plugs into this same service.
## Packaging for another machine
@@ -117,6 +156,7 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
"aeVersion": "24.3",
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
+ "plateSearchRoots": ["V:/_FOOTAGE/UNG"],
"pathMappings": [
{ "from": "//SAN/", "to": "S:/" }
]
@@ -124,7 +164,9 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
```
`pathMappings` translate the manifest's canonical UNC paths to this machine's drive
-mappings. Everything tunable (poll interval, lease, stall timeout, max attempts) lives in
+mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no
+timing plate (older panel versions), to find the shot's plates for the timecode check.
+Everything tunable (poll interval, lease, stall timeout, max attempts, validation) lives in
the server's SystemConfig and arrives at registration — no per-machine tuning files.
## Run interactively (first-time smoke test)
diff --git a/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs
new file mode 100644
index 0000000..13ad93b
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs
@@ -0,0 +1,177 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using VFXReviewWorker;
+using Xunit;
+
+namespace VFXReviewWorker.Tests;
+
+///
+/// Packaging is the last step before files leave the studio, so the parts that
+/// could silently ship the wrong thing — checksum verification, frame-count
+/// drift, hard-link vs copy — are pinned here.
+///
+public class DeliveryStageTests : IDisposable
+{
+ private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_delivery_{Guid.NewGuid():N}");
+ private readonly string _sourceDir;
+ private readonly string _packageDir;
+
+ public DeliveryStageTests()
+ {
+ _sourceDir = Path.Combine(_root, "renders", "SHOT_001", "v001");
+ _packageDir = Path.Combine(_root, "DELIVERY", "101", "260813_Delivery");
+ Directory.CreateDirectory(_sourceDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch { }
+ }
+
+ private void WriteFrames(int count, int startFrame = 1001)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ var name = $"SHOT_001_cmp_TT_v001.{startFrame + i:D5}.exr";
+ File.WriteAllText(Path.Combine(_sourceDir, name), $"frame {startFrame + i} pixels");
+ }
+ }
+
+ private DeliveryManifest Manifest(string? checksum = null, int fileCount = 3, string copyMode = "auto") => new()
+ {
+ PackageId = "pkg1",
+ PackageName = "260813_Delivery",
+ PackageNumber = 1,
+ Episode = "101",
+ PackageDir = _packageDir,
+ ReportFile = Path.Combine(_packageDir, "260813_Delivery_report.csv"),
+ CopyMode = copyMode,
+ Items = new List
+ {
+ new()
+ {
+ ItemId = "item1",
+ ExportId = "exp1",
+ ShotCode = "SHOT_001",
+ VersionString = "v001",
+ SourceDir = _sourceDir,
+ OutputPattern = "SHOT_001_cmp_TT_v001.[#####].exr",
+ FrameStart = 1001,
+ FrameEnd = 1000 + fileCount,
+ FileCount = fileCount,
+ Checksum = checksum,
+ ShotDir = Path.Combine(_packageDir, "SHOT_001"),
+ },
+ },
+ };
+
+ private DeliveryStage NewStage() => new(
+ new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" },
+ new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger.Instance),
+ NullLogger.Instance);
+
+ [Fact]
+ public async Task PackagesFramesAndWritesReport()
+ {
+ WriteFrames(3);
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1",
+ CancellationToken.None, CancellationToken.None);
+
+ Assert.True(result.Ok);
+ var item = Assert.Single(result.Items);
+ Assert.Equal("VERIFIED", item.Status);
+ Assert.Equal(3, item.FileCount);
+
+ var delivered = Directory.GetFiles(Path.Combine(_packageDir, "SHOT_001"), "*.exr");
+ Assert.Equal(3, delivered.Length);
+ Assert.True(File.Exists(Path.Combine(_packageDir, "manifest.json")));
+
+ var report = File.ReadAllText(Path.Combine(_packageDir, "260813_Delivery_report.csv"));
+ Assert.Contains("Shot,Version,FrameStart", report);
+ Assert.Contains("SHOT_001,v001,1001,1003,3,3", report);
+ }
+
+ [Fact]
+ public async Task HardLinksOnTheSameVolumeSoNoExtraBytesAreWritten()
+ {
+ WriteFrames(2);
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2), "machine1",
+ CancellationToken.None, CancellationToken.None);
+
+ Assert.Equal("hardlink", result.Items[0].LinkMode);
+
+ // A hard link is the same file: writing through one name is visible via
+ // the other. That is what makes packaging near-instant and free.
+ var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
+ var linked = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
+ File.WriteAllText(source, "rewritten");
+ Assert.Equal("rewritten", File.ReadAllText(linked));
+ }
+
+ [Fact]
+ public async Task AlwaysCopyModeProducesIndependentFiles()
+ {
+ WriteFrames(2);
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2, copyMode: "always-copy"),
+ "machine1", CancellationToken.None, CancellationToken.None);
+
+ Assert.Equal("copy", result.Items[0].LinkMode);
+
+ var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
+ var copied = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
+ File.WriteAllText(source, "rewritten");
+ Assert.NotEqual("rewritten", File.ReadAllText(copied));
+ }
+
+ [Fact]
+ public async Task ChecksumMismatchFailsTheItem()
+ {
+ WriteFrames(3);
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
+ Manifest(checksum: "deadbeefdeadbeef"), "machine1", CancellationToken.None, CancellationToken.None);
+
+ Assert.False(result.Ok);
+ Assert.Equal("FAILED", result.Items[0].Status);
+ Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!);
+ }
+
+ [Fact]
+ public async Task FrameCountDriftSinceQcFailsTheItem()
+ {
+ // The export recorded 3 frames at validation; the folder now has 2.
+ WriteFrames(2);
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 3), "machine1",
+ CancellationToken.None, CancellationToken.None);
+
+ Assert.False(result.Ok);
+ Assert.Contains("the render changed since QC", result.Items[0].ErrorMessage!);
+ }
+
+ [Fact]
+ public async Task MissingSourceFolderFailsWithThePath()
+ {
+ var manifest = Manifest();
+ manifest.Items[0].SourceDir = Path.Combine(_root, "does-not-exist");
+
+ var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
+ CancellationToken.None, CancellationToken.None);
+
+ Assert.False(result.Ok);
+ Assert.Contains("Source folder not found", result.Items[0].ErrorMessage!);
+ }
+
+ [Fact]
+ public async Task ChecksumMatchesTheValidationTimeDigestForIdenticalContent()
+ {
+ WriteFrames(3);
+ var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1",
+ CancellationToken.None, CancellationToken.None);
+
+ // Feeding the recorded digest back in must verify, which is exactly what
+ // the delivery builder does against Export.checksum.
+ Directory.Delete(_packageDir, recursive: true);
+ var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
+ Manifest(checksum: first.Items[0].Checksum), "machine1", CancellationToken.None, CancellationToken.None);
+
+ Assert.True(second.Ok);
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs b/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs
new file mode 100644
index 0000000..abefa23
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs
@@ -0,0 +1,193 @@
+using VFXReviewWorker;
+using Xunit;
+
+namespace VFXReviewWorker.Tests;
+
+///
+/// The timecode check is the reason the validation engine exists (§8.1a), so
+/// the header parse and the timecode arithmetic are pinned here against a
+/// synthetic EXR built byte by byte.
+///
+public class ExrHeaderTests
+{
+ [Fact]
+ public void ReadsTimecodeResolutionChannelsAndFps()
+ {
+ var path = ExrFixture.Write(timecode: "18:58:57:12", width: 1920, height: 1080,
+ channels: new[] { ("A", 1), ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 4);
+ try
+ {
+ var header = ExrHeader.Read(path);
+
+ Assert.Equal(new Timecode(18, 58, 57, 12, false), header.Timecode);
+ Assert.Equal(1920, header.DataWidth);
+ Assert.Equal(1080, header.DataHeight);
+ Assert.Equal(24, header.FramesPerSecond);
+ Assert.Equal("half", header.BitDepth);
+ Assert.True(header.HasAlpha);
+ Assert.Equal("PIZ", header.CompressionName);
+ }
+ finally { File.Delete(path); }
+ }
+
+ [Fact]
+ public void MissingTimecodeIsReportedAsAbsentNotAsZero()
+ {
+ var path = ExrFixture.Write(timecode: null, width: 1920, height: 1080,
+ channels: new[] { ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 3);
+ try
+ {
+ var header = ExrHeader.Read(path);
+ Assert.Null(header.Timecode);
+ Assert.False(header.HasAlpha);
+ }
+ finally { File.Delete(path); }
+ }
+
+ [Fact]
+ public void NonExrFileThrows()
+ {
+ var path = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(path, "this is not an exr at all, not even close");
+ Assert.Throws(() => ExrHeader.Read(path));
+ Assert.Null(ExrHeader.TryRead(path));
+ }
+ finally { File.Delete(path); }
+ }
+
+ [Theory]
+ // The BCD packing means the raw value reads back as its own hex digits.
+ [InlineData(0x18585712u, 18, 58, 57, 12)]
+ [InlineData(0x00004117u, 0, 0, 41, 17)]
+ [InlineData(0x00000000u, 0, 0, 0, 0)]
+ public void DecodesPackedSmpteTimecode(uint packed, int hh, int mm, int ss, int ff)
+ {
+ Assert.Equal(new Timecode(hh, mm, ss, ff, false), Timecode.FromPacked(packed));
+ }
+
+ [Fact]
+ public void FrameArithmeticRoundTrips()
+ {
+ var tc = new Timecode(18, 58, 57, 12, false);
+ Assert.Equal(tc, Timecode.FromFrames(tc.ToFrames(24), 24));
+
+ // 133 frames on from 18:58:57:12 at 24 fps is 18:59:03:01 — the real
+ // first/last frame pair measured on UNG_102_019_010 v005.
+ Assert.Equal(new Timecode(18, 59, 3, 1, false), Timecode.FromFrames(tc.ToFrames(24) + 133, 24));
+ }
+
+ [Fact]
+ public void ApiTimecodeIsOneHandleAheadOfThePlate()
+ {
+ // The defect class from §8.1a: the API stores the editorial in-point and
+ // the plate starts 8 frames earlier, so a comp anchored to the API value
+ // renders 8 frames late.
+ var plate = Timecode.Parse("13:44:06:02")!.Value;
+ var api = Timecode.Parse("13:44:06:10")!.Value;
+ Assert.Equal(8, api.ToFrames(24) - plate.ToFrames(24));
+ }
+
+ [Fact]
+ public void FrameDerivedTimecodeIsFrameNumberOverFps()
+ {
+ // 00:00:41:17 is frame 1001 at 24 fps — the signature of a comp built
+ // with no source timecode at all.
+ Assert.Equal(1001, Timecode.Parse("00:00:41:17")!.Value.ToFrames(24));
+ }
+
+ [Theory]
+ [InlineData("UNG_102_019_010_cmp_TT_v005.01001.exr", 1001)]
+ [InlineData("UNG_108_030_010_cmp_TT_v003.01204.exr", 1204)]
+ [InlineData("no_frame_number.exr", null)]
+ public void ParsesFrameNumbersFromFilenames(string name, int? expected)
+ {
+ Assert.Equal(expected, SequencePattern.FrameFromName(name));
+ }
+
+ [Fact]
+ public void ParsesOutputPattern()
+ {
+ var p = SequencePattern.FromOutputPattern("UNG_102_019_010_cmp_TT_v005.[#####].exr");
+ Assert.Equal("UNG_102_019_010_cmp_TT_v005", p.Prefix);
+ Assert.Equal(".exr", p.Extension);
+ Assert.Equal(5, p.Padding);
+ }
+}
+
+/// Builds a minimal but structurally real EXR header on disk.
+internal static class ExrFixture
+{
+ public static string Write(string? timecode, int width, int height,
+ (string Name, int PixelType)[] channels, int fpsNum, byte compression)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"vfxr_test_{Guid.NewGuid():N}.exr");
+ using var fs = new FileStream(path, FileMode.Create, FileAccess.Write);
+ using var w = new BinaryWriter(fs);
+
+ w.Write(ExrHeader.Magic);
+ w.Write(2); // version 2, no flags
+
+ // channels (chlist)
+ using (var chData = new MemoryStream())
+ using (var cw = new BinaryWriter(chData))
+ {
+ foreach (var (name, pixelType) in channels)
+ {
+ WriteCString(cw, name);
+ cw.Write(pixelType);
+ cw.Write((byte)0); // pLinear
+ cw.Write(new byte[3]); // reserved
+ cw.Write(1); // xSampling
+ cw.Write(1); // ySampling
+ }
+ cw.Write((byte)0); // list terminator
+ WriteAttr(w, "channels", "chlist", chData.ToArray());
+ }
+
+ WriteAttr(w, "compression", "compression", new[] { compression });
+
+ var window = new byte[16];
+ BitConverter.GetBytes(0).CopyTo(window, 0);
+ BitConverter.GetBytes(0).CopyTo(window, 4);
+ BitConverter.GetBytes(width - 1).CopyTo(window, 8);
+ BitConverter.GetBytes(height - 1).CopyTo(window, 12);
+ WriteAttr(w, "dataWindow", "box2i", window);
+ WriteAttr(w, "displayWindow", "box2i", window);
+
+ var rational = new byte[8];
+ BitConverter.GetBytes(fpsNum).CopyTo(rational, 0);
+ BitConverter.GetBytes(1u).CopyTo(rational, 4);
+ WriteAttr(w, "framesPerSecond", "rational", rational);
+
+ if (timecode is not null)
+ {
+ var parts = timecode.Split(':');
+ uint Bcd(int v) => (uint)((v / 10) * 16 + v % 10);
+ var packed = (Bcd(int.Parse(parts[0])) << 24) | (Bcd(int.Parse(parts[1])) << 16)
+ | (Bcd(int.Parse(parts[2])) << 8) | Bcd(int.Parse(parts[3]));
+ var tcData = new byte[8];
+ BitConverter.GetBytes(packed).CopyTo(tcData, 0);
+ WriteAttr(w, "timeCode", "timecode", tcData);
+ WriteAttr(w, "timeCodeString", "string", System.Text.Encoding.ASCII.GetBytes(timecode));
+ }
+
+ w.Write((byte)0); // end of header
+ return path;
+ }
+
+ private static void WriteAttr(BinaryWriter w, string name, string type, byte[] data)
+ {
+ WriteCString(w, name);
+ WriteCString(w, type);
+ w.Write(data.Length);
+ w.Write(data);
+ }
+
+ private static void WriteCString(BinaryWriter w, string s)
+ {
+ w.Write(System.Text.Encoding.ASCII.GetBytes(s));
+ w.Write((byte)0);
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs b/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs
new file mode 100644
index 0000000..5dbc487
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs
@@ -0,0 +1,61 @@
+using System.Text;
+using VFXReviewWorker;
+using Xunit;
+
+namespace VFXReviewWorker.Tests;
+
+///
+/// Reference vectors from the xxHash specification. The delivery builder
+/// verifies packaged files against digests recorded here at validation time, so
+/// a wrong implementation would not be caught by round-tripping our own code —
+/// only by pinning the published values.
+///
+public class XxHash64Tests
+{
+ [Theory]
+ [InlineData("", 0xEF46DB3751D8E999UL)]
+ [InlineData("a", 0xD24EC4F1A98C6E5BUL)]
+ [InlineData("abc", 0x44BC2CF5AD770999UL)]
+ public void MatchesReferenceVectors(string input, ulong expected)
+ {
+ Assert.Equal(expected, XxHash64.Hash(Encoding.ASCII.GetBytes(input)));
+ }
+
+ [Fact]
+ public void StreamingInChunksMatchesSingleShot()
+ {
+ var data = new byte[1000];
+ new Random(42).NextBytes(data);
+ var oneShot = XxHash64.Hash(data);
+
+ // Chunk sizes deliberately straddle the 32-byte stripe boundary.
+ foreach (var chunk in new[] { 1, 7, 31, 32, 33, 64, 999 })
+ {
+ var state = new XxHash64.State(0);
+ for (var i = 0; i < data.Length; i += chunk)
+ {
+ state.Update(new ReadOnlySpan(data, i, Math.Min(chunk, data.Length - i)));
+ }
+ Assert.Equal(oneShot, state.Digest());
+ }
+ }
+
+ [Fact]
+ public void HashFileIsStableAndContentSensitive()
+ {
+ var path = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(path, "frame data");
+ var first = XxHash64.HashFile(path);
+ Assert.Equal(first, XxHash64.HashFile(path));
+
+ File.WriteAllText(path, "frame datb");
+ Assert.NotEqual(first, XxHash64.HashFile(path));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker/ApiClient.cs b/RenderWorker/VFXReviewWorker/ApiClient.cs
index 1f31acf..6d570b8 100644
--- a/RenderWorker/VFXReviewWorker/ApiClient.cs
+++ b/RenderWorker/VFXReviewWorker/ApiClient.cs
@@ -71,17 +71,32 @@ public sealed class ApiClient
return body?.Job;
}
- // E9 — best-effort; failures are swallowed by the caller (lease/reaper covers us)
+ // E9 — best-effort by contract: a progress report is a nicety, and losing
+ // one must never abort work in flight. Transport failures return null here
+ // rather than throwing at the call site; the lease and the reaper are what
+ // actually protect the job.
public async Task ProgressAsync(string jobId, string machineId, double progress,
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
{
- using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
+ try
{
- Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json),
- };
- var res = await _http.SendAsync(req, ct);
- if (!res.IsSuccessStatusCode) return null;
- return await res.Content.ReadFromJsonAsync(Json, ct);
+ using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
+ {
+ Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json),
+ };
+ var res = await _http.SendAsync(req, ct);
+ if (!res.IsSuccessStatusCode) return null;
+ return await res.Content.ReadFromJsonAsync(Json, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw; // a real shutdown still stops us
+ }
+ catch (Exception ex)
+ {
+ _log.LogDebug(ex, "Progress report failed for job {JobId} (non-fatal)", jobId);
+ return null;
+ }
}
// E10 / E11 — lifecycle reports, sent via the spooler for durability.
@@ -93,6 +108,8 @@ public sealed class ApiClient
{
"fail" => $"api/ext/render/jobs/{jobId}/fail",
"complete-render" => $"api/ext/render/jobs/{jobId}/complete-render",
+ "validation" => $"api/ext/render/jobs/{jobId}/validation",
+ "delivery" => $"api/ext/render/jobs/{jobId}/delivery",
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
};
diff --git a/RenderWorker/VFXReviewWorker/DeliveryStage.cs b/RenderWorker/VFXReviewWorker/DeliveryStage.cs
new file mode 100644
index 0000000..f8e3ff8
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/DeliveryStage.cs
@@ -0,0 +1,327 @@
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+
+namespace VFXReviewWorker;
+
+///
+/// Delivery builder (RenderPipeline2 §11). Creates the dated package folder,
+/// hard-links (or copies) each shot's EXRs and delivery MOV into it, verifies
+/// what it wrote, and leaves a manifest + CSV report beside the shots.
+///
+/// Hard links make an 18-shot EXR package near-instant and cost no extra disk.
+/// They are safe here because rendered versions are immutable by pipeline
+/// design — a new version is a new directory, nothing ever rewrites an EXR in
+/// place. Cross-volume targets fall back to copying automatically.
+///
+public sealed class DeliveryStage
+{
+ private readonly WorkerOptions _options;
+ private readonly ApiClient _api;
+ private readonly ILogger _log;
+
+ public DeliveryStage(WorkerOptions options, ApiClient api, ILogger log)
+ {
+ _options = options;
+ _api = api;
+ _log = log;
+ }
+
+ public async Task RunAsync(
+ ClaimedJob job, DeliveryManifest manifest, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
+ {
+ var mapper = new PathMapper(_options.PathMappings);
+ var packageDir = mapper.Map(manifest.PackageDir);
+ var results = new List();
+ var manifestFiles = new List();
+ var alwaysCopy = string.Equals(manifest.CopyMode, "always-copy", StringComparison.OrdinalIgnoreCase);
+
+ _log.LogInformation("Building delivery package {Name} ({Count} shots) at {Dir}",
+ manifest.PackageName, manifest.Items.Count, packageDir);
+ Directory.CreateDirectory(packageDir);
+
+ for (var i = 0; i < manifest.Items.Count; i++)
+ {
+ if (cancelJob.IsCancellationRequested) break;
+ var item = manifest.Items[i];
+
+ await _api.ProgressAsync(job.Id, machineId, (double)i / manifest.Items.Count, i, manifest.Items.Count, null,
+ $"Packaging {item.ShotCode} {item.VersionString} ({i + 1}/{manifest.Items.Count})", shutdown);
+
+ var result = BuildItem(item, mapper, alwaysCopy, manifestFiles);
+ results.Add(result);
+ if (result.Status == "FAILED")
+ {
+ _log.LogError("Delivery item {Shot} failed: {Error}", item.ShotCode, result.ErrorMessage);
+ }
+ }
+
+ var ok = results.Count == manifest.Items.Count && results.All(r => r.Status == "VERIFIED");
+
+ // The report is the client-facing document, so it is written even for a
+ // partial build — it is the fastest way to see what did and did not land.
+ string? reportPath = null;
+ try
+ {
+ reportPath = WriteReport(manifest, results, mapper);
+ WriteManifestJson(manifest, results, manifestFiles, packageDir);
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Could not write delivery report/manifest for {Name}", manifest.PackageName);
+ }
+
+ return new DeliveryResult(ok, results, packageDir, reportPath, manifestFiles);
+ }
+
+ private DeliveryItemResult BuildItem(
+ DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, List manifestFiles)
+ {
+ var sourceDir = mapper.Map(item.SourceDir);
+ var shotDir = mapper.Map(item.ShotDir);
+
+ try
+ {
+ if (!Directory.Exists(sourceDir))
+ {
+ return DeliveryItemResult.Failed(item, $"Source folder not found: {sourceDir}");
+ }
+
+ var pattern = SequencePattern.FromOutputPattern(item.OutputPattern);
+ var sources = Directory.EnumerateFiles(sourceDir)
+ .Where(f => Path.GetFileName(f).StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)
+ && Path.GetFileName(f).EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(f => f, StringComparer.Ordinal)
+ .ToList();
+
+ if (sources.Count == 0)
+ {
+ return DeliveryItemResult.Failed(item, $"No {pattern.Extension} files matching {pattern.Prefix}* in {sourceDir}");
+ }
+ if (item.FileCount is int expected && sources.Count != expected)
+ {
+ return DeliveryItemResult.Failed(item,
+ $"Source has {sources.Count} frames but the export recorded {expected} — the render changed since QC");
+ }
+
+ Directory.CreateDirectory(shotDir);
+
+ long totalBytes = 0;
+ var usedCopy = false;
+ foreach (var src in sources)
+ {
+ var dest = Path.Combine(shotDir, Path.GetFileName(src));
+ if (Place(src, dest, alwaysCopy, out var copied) is string placeError)
+ {
+ return DeliveryItemResult.Failed(item, placeError);
+ }
+ usedCopy |= copied;
+ totalBytes += new FileInfo(dest).Length;
+ }
+
+ // Verification reads the delivered bytes back, which works the same
+ // whether the entry is a link or a copy — `fsutil hardlink list` is
+ // not available over SMB, so link identity cannot be queried.
+ var digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, Path.GetFileName(s))));
+ if (item.Checksum is { Length: > 0 } && !string.Equals(digest, item.Checksum, StringComparison.OrdinalIgnoreCase))
+ {
+ return DeliveryItemResult.Failed(item,
+ $"Checksum mismatch after packaging: expected {item.Checksum}, got {digest}");
+ }
+
+ string? movDest = null;
+ if (!string.IsNullOrWhiteSpace(item.DeliveryMovPath))
+ {
+ var movSrc = mapper.Map(item.DeliveryMovPath!);
+ if (File.Exists(movSrc))
+ {
+ movDest = Path.Combine(shotDir, Path.GetFileName(movSrc));
+ if (Place(movSrc, movDest, alwaysCopy, out var movCopied) is string movError)
+ {
+ return DeliveryItemResult.Failed(item, movError);
+ }
+ usedCopy |= movCopied;
+ totalBytes += new FileInfo(movDest).Length;
+ }
+ else
+ {
+ // Not fatal: the EXRs are the deliverable, the MOV is a
+ // viewing copy. It is recorded so it is not silently absent.
+ _log.LogWarning("Delivery MOV missing for {Shot}: {Path}", item.ShotCode, movSrc);
+ }
+ }
+
+ foreach (var src in sources)
+ {
+ var name = Path.GetFileName(src);
+ manifestFiles.Add(new
+ {
+ shotCode = item.ShotCode,
+ relativePath = Path.Combine(Path.GetFileName(shotDir), name).Replace('\\', '/'),
+ bytes = new FileInfo(Path.Combine(shotDir, name)).Length,
+ exportId = item.ExportId,
+ });
+ }
+
+ return new DeliveryItemResult
+ {
+ ItemId = item.ItemId,
+ ShotCode = item.ShotCode,
+ VersionString = item.VersionString,
+ Status = "VERIFIED",
+ ExrPath = shotDir,
+ MovPath = movDest,
+ FileCount = sources.Count,
+ TotalBytes = totalBytes,
+ Checksum = digest,
+ LinkMode = usedCopy ? "copy" : "hardlink",
+ FrameStart = item.FrameStart,
+ FrameEnd = item.FrameEnd,
+ };
+ }
+ catch (Exception ex)
+ {
+ return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Hard-link when possible, copy when not. Returns null on success or the
+ /// error message. `copied` reports which happened, so the package records
+ /// what it actually did rather than what it intended.
+ ///
+ private string? Place(string source, string dest, bool alwaysCopy, out bool copied)
+ {
+ copied = false;
+ try
+ {
+ if (File.Exists(dest)) File.Delete(dest);
+
+ if (!alwaysCopy && CreateHardLink(dest, source, IntPtr.Zero))
+ {
+ return null;
+ }
+
+ // Cross-volume, a filesystem without link support, or always-copy.
+ File.Copy(source, dest, overwrite: true);
+ copied = true;
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return $"Could not place {Path.GetFileName(source)}: {ex.Message}";
+ }
+ }
+
+ private static string SequenceChecksum(IEnumerable files)
+ {
+ var state = new XxHash64.State(0);
+ foreach (var f in files)
+ {
+ state.Update(Encoding.ASCII.GetBytes(XxHash64.HashFile(f)));
+ }
+ return state.Digest().ToString("x16");
+ }
+
+ ///
+ /// The client-facing delivery report (§11.4): one row per shot. Columns are
+ /// confirmed against the production's delivery spec before the first real
+ /// delivery (18.2-C2), which is why this is a plain, easily-edited writer.
+ ///
+ private string WriteReport(DeliveryManifest manifest, List results, PathMapper mapper)
+ {
+ var path = mapper.Map(manifest.ReportFile);
+ var dir = Path.GetDirectoryName(path);
+ if (dir is not null) Directory.CreateDirectory(dir);
+
+ var sb = new StringBuilder();
+ sb.AppendLine("Shot,Version,FrameStart,FrameEnd,FrameCount,Files,Bytes,Checksum,MOV,Status");
+ foreach (var r in results)
+ {
+ sb.AppendLine(string.Join(",", new[]
+ {
+ Csv(r.ShotCode),
+ Csv(r.VersionString),
+ r.FrameStart.ToString(CultureInfo.InvariantCulture),
+ r.FrameEnd.ToString(CultureInfo.InvariantCulture),
+ (r.FrameEnd - r.FrameStart + 1).ToString(CultureInfo.InvariantCulture),
+ (r.FileCount ?? 0).ToString(CultureInfo.InvariantCulture),
+ (r.TotalBytes ?? 0).ToString(CultureInfo.InvariantCulture),
+ Csv(r.Checksum ?? ""),
+ Csv(r.MovPath is null ? "" : Path.GetFileName(r.MovPath)),
+ Csv(r.Status),
+ }));
+ }
+ File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
+ _log.LogInformation("Wrote delivery report {Path}", path);
+ return path;
+ }
+
+ private void WriteManifestJson(
+ DeliveryManifest manifest, List results, List files, string packageDir)
+ {
+ var payload = new
+ {
+ package = manifest.PackageName,
+ packageNumber = manifest.PackageNumber,
+ episode = manifest.Episode,
+ builtAt = DateTimeOffset.UtcNow,
+ builtBy = Environment.MachineName,
+ checksumAlgo = manifest.ChecksumAlgo,
+ shots = results.Select(r => new
+ {
+ r.ShotCode,
+ r.VersionString,
+ r.FileCount,
+ r.TotalBytes,
+ r.Checksum,
+ r.LinkMode,
+ r.Status,
+ r.ErrorMessage,
+ }),
+ files,
+ };
+ var path = Path.Combine(packageDir, "manifest.json");
+ File.WriteAllText(path, JsonSerializer.Serialize(payload, WorkerOptions.JsonOpts), new UTF8Encoding(false));
+ }
+
+ private static string Csv(string value) =>
+ value.Contains(',') || value.Contains('"') ? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
+
+ [DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes);
+}
+
+public sealed record DeliveryResult(
+ bool Ok, List Items, string PackageDir, string? ReportPath, List Files);
+
+public sealed class DeliveryItemResult
+{
+ public string ItemId { get; set; } = "";
+ public string ShotCode { get; set; } = "";
+ public string VersionString { get; set; } = "";
+ public string Status { get; set; } = "PENDING";
+ public string? ExrPath { get; set; }
+ public string? MovPath { get; set; }
+ public int? FileCount { get; set; }
+ public long? TotalBytes { get; set; }
+ public string? Checksum { get; set; }
+ public string? LinkMode { get; set; }
+ public string? ErrorMessage { get; set; }
+ public int FrameStart { get; set; }
+ public int FrameEnd { get; set; }
+
+ public static DeliveryItemResult Failed(DeliveryManifestItem item, string error) => new()
+ {
+ ItemId = item.ItemId,
+ ShotCode = item.ShotCode,
+ VersionString = item.VersionString,
+ Status = "FAILED",
+ ErrorMessage = error,
+ FrameStart = item.FrameStart,
+ FrameEnd = item.FrameEnd,
+ };
+}
diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs
index 4a347b8..100bb4c 100644
--- a/RenderWorker/VFXReviewWorker/Dtos.cs
+++ b/RenderWorker/VFXReviewWorker/Dtos.cs
@@ -26,6 +26,15 @@ public sealed class ServerConfig
public int LeaseSeconds { get; set; } = 300;
public int MaxAttempts { get; set; } = 3;
public int StallTimeoutSeconds { get; set; } = 600;
+
+ ///
+ /// Validation stage (§8). The server decides whether it runs, so turning it
+ /// off is a config change there rather than a redeploy of every worker.
+ ///
+ public bool ValidationEnabled { get; set; } = true;
+ public int ValidationSampleEvery { get; set; } = 25;
+ /// Head handles the plates carry; only used for the API timecode fallback (§8.1a).
+ public int ValidationHandleFrames { get; set; } = 8;
}
public sealed class ClaimResponse
@@ -52,6 +61,43 @@ public sealed class ClaimedJob
public PreviewManifest AsPreviewManifest() =>
Manifest.Deserialize(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a preview manifest");
+
+ public DeliveryManifest AsDeliveryManifest() =>
+ Manifest.Deserialize(WorkerOptions.JsonOpts)
+ ?? throw new InvalidOperationException("Job manifest is not a delivery manifest");
+}
+
+/// DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11).
+public sealed class DeliveryManifest
+{
+ public string Stage { get; set; } = "DELIVERY";
+ public string PackageId { get; set; } = "";
+ public string PackageName { get; set; } = "";
+ public int PackageNumber { get; set; }
+ public string Episode { get; set; } = "";
+ public string PackageDir { get; set; } = "";
+ public string ReportFile { get; set; } = "";
+ public bool IncludeMovs { get; set; } = true;
+ public string CopyMode { get; set; } = "auto";
+ public string ChecksumAlgo { get; set; } = "xxh64";
+ public List ReportFormats { get; set; } = new();
+ public List Items { get; set; } = new();
+}
+
+public sealed class DeliveryManifestItem
+{
+ public string ItemId { get; set; } = "";
+ public string ExportId { get; set; } = "";
+ public string ShotCode { get; set; } = "";
+ public string VersionString { get; set; } = "";
+ public string SourceDir { get; set; } = "";
+ public string OutputPattern { get; set; } = "";
+ public int FrameStart { get; set; }
+ public int FrameEnd { get; set; }
+ public int? FileCount { get; set; }
+ public string? Checksum { get; set; }
+ public string? DeliveryMovPath { get; set; }
+ public string ShotDir { get; set; } = "";
}
/// PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).
@@ -107,9 +153,10 @@ public sealed class PreviewBuildResult
public int? Height { get; set; }
}
-/// The Render Manifest (§6.2) — only the fields the Phase 2 render stage needs; the rest is preserved raw.
+/// The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.
public sealed class RenderManifest
{
+ public string ShotCode { get; set; } = "";
public string AepPath { get; set; } = "";
public string CompName { get; set; } = "";
public string OutputDir { get; set; } = "";
@@ -118,6 +165,11 @@ public sealed class RenderManifest
public string? RenderSettingsTemplate { get; set; }
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
+ public double Fps { get; set; } = 24;
+ public int Width { get; set; }
+ public int Height { get; set; }
+ public ManifestExpected? Expected { get; set; }
+ public ManifestSource? Source { get; set; }
[JsonExtensionData]
public Dictionary? Extra { get; set; }
@@ -125,6 +177,35 @@ public sealed class RenderManifest
public int TotalFrames => FrameEnd - FrameStart + 1;
}
+/// Per-project expectations the validation engine checks against (§8.1).
+public sealed class ManifestExpected
+{
+ public string? Colorspace { get; set; }
+ public string? BitDepth { get; set; }
+ public string? ExrCompression { get; set; }
+ public bool? Alpha { get; set; }
+ /// The API's editorial in-point — the rank 3 timecode fallback only (§8.1a).
+ public string? TimecodeStart { get; set; }
+}
+
+/// Plate provenance resolved by the panel from the comp's footage layers (§6.2).
+public sealed class ManifestSource
+{
+ public PlateRef? TimingPlate { get; set; }
+ public List Plates { get; set; } = new();
+ public string? StartSource { get; set; }
+ public int? StartFrames { get; set; }
+ public int? HandleFrames { get; set; }
+}
+
+public sealed class PlateRef
+{
+ public string? Element { get; set; }
+ public string Path { get; set; } = "";
+ public int? FrameStart { get; set; }
+ public int? FrameEnd { get; set; }
+}
+
public sealed class ProgressResponse
{
public bool Ok { get; set; }
diff --git a/RenderWorker/VFXReviewWorker/ExrHeader.cs b/RenderWorker/VFXReviewWorker/ExrHeader.cs
new file mode 100644
index 0000000..1375ac2
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/ExrHeader.cs
@@ -0,0 +1,234 @@
+using System.Text;
+
+namespace VFXReviewWorker;
+
+///
+/// Minimal OpenEXR header reader (RenderPipeline2 §8.1a).
+///
+/// Everything validation needs — timecode, fps, resolution, channel types,
+/// compression — lives in the header, so a check costs one 64 KB read and no
+/// pixel decode. That is why the engine needs no oiiotool/OpenImageIO
+/// dependency and can run on a bare workstation.
+///
+/// Header layout: magic(4) version(4) then attributes as
+/// `name\0 type\0 int32 size data`, terminated by an empty name.
+///
+public sealed class ExrHeader
+{
+ public const uint Magic = 20000630;
+ private const int MaxHeaderBytes = 1 << 18; // 256 KB — far beyond any sane header
+
+ public int DataWidth { get; private init; }
+ public int DataHeight { get; private init; }
+ public int DisplayWidth { get; private init; }
+ public int DisplayHeight { get; private init; }
+ public IReadOnlyList Channels { get; private init; } = Array.Empty();
+ public byte? CompressionCode { get; private init; }
+ public Timecode? Timecode { get; private init; }
+ public string? TimecodeString { get; private init; }
+ public double? FramesPerSecond { get; private init; }
+ public bool HasChromaticities { get; private init; }
+ public bool IsTiled { get; private init; }
+ public IReadOnlyList AttributeNames { get; private init; } = Array.Empty();
+
+ public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal));
+
+ /// half / float / uint — the deepest channel type present.
+ public string? BitDepth
+ {
+ get
+ {
+ if (Channels.Count == 0) return null;
+ if (Channels.Any(c => c.PixelType == 2)) return "float";
+ if (Channels.Any(c => c.PixelType == 1)) return "half";
+ return "uint";
+ }
+ }
+
+ public string CompressionName => CompressionCode switch
+ {
+ 0 => "none", 1 => "RLE", 2 => "ZIPS", 3 => "ZIP", 4 => "PIZ",
+ 5 => "PXR24", 6 => "B44", 7 => "B44A", 8 => "DWAA", 9 => "DWAB",
+ null => "unknown",
+ _ => $"code {CompressionCode}",
+ };
+
+ /// Reads the header, or throws when the file is not a readable EXR.
+ public static ExrHeader Read(string path)
+ {
+ using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ var buf = new byte[Math.Min(MaxHeaderBytes, Math.Max(1024, (int)Math.Min(fs.Length, MaxHeaderBytes)))];
+ var read = fs.Read(buf, 0, buf.Length);
+ if (read < 16) throw new InvalidDataException("File is too small to be an EXR");
+
+ var span = new ReadOnlySpan(buf, 0, read);
+ if (BitConverter.ToUInt32(span[..4]) != Magic) throw new InvalidDataException("Not an EXR (bad magic number)");
+ var version = BitConverter.ToInt32(span.Slice(4, 4));
+
+ int pos = 8;
+ var names = new List();
+ var channels = new List();
+ int dataW = 0, dataH = 0, dispW = 0, dispH = 0;
+ byte? compression = null;
+ Timecode? tc = null;
+ string? tcString = null;
+ double? fps = null;
+ bool chroma = false;
+
+ while (pos < read)
+ {
+ var name = ReadCString(span, ref pos);
+ if (name.Length == 0) break; // end of header
+ var type = ReadCString(span, ref pos);
+ if (pos + 4 > read) throw new InvalidDataException("Truncated EXR header (attribute size)");
+ var size = BitConverter.ToInt32(span.Slice(pos, 4));
+ pos += 4;
+ if (size < 0 || pos + size > read) throw new InvalidDataException($"Truncated EXR header (attribute \"{name}\")");
+ var data = span.Slice(pos, size);
+ names.Add(name);
+
+ switch (name)
+ {
+ case "dataWindow" when size >= 16:
+ dataW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
+ dataH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
+ break;
+ case "displayWindow" when size >= 16:
+ dispW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
+ dispH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
+ break;
+ case "compression" when size >= 1:
+ compression = data[0];
+ break;
+ case "timeCode" when size >= 8:
+ tc = VFXReviewWorker.Timecode.FromPacked(
+ BitConverter.ToUInt32(data[..4]), BitConverter.ToUInt32(data.Slice(4, 4)));
+ break;
+ case "timeCodeString":
+ tcString = Encoding.ASCII.GetString(data).TrimEnd('\0');
+ break;
+ case "framesPerSecond" when size >= 8:
+ {
+ var num = BitConverter.ToInt32(data[..4]);
+ var den = BitConverter.ToUInt32(data.Slice(4, 4));
+ if (den != 0) fps = num / (double)den;
+ break;
+ }
+ case "chromaticities":
+ chroma = true;
+ break;
+ case "channels":
+ channels.AddRange(ReadChannels(data));
+ break;
+ }
+
+ pos += size;
+ }
+
+ return new ExrHeader
+ {
+ DataWidth = dataW,
+ DataHeight = dataH,
+ DisplayWidth = dispW,
+ DisplayHeight = dispH,
+ Channels = channels,
+ CompressionCode = compression,
+ Timecode = tc,
+ TimecodeString = tcString,
+ FramesPerSecond = fps,
+ HasChromaticities = chroma,
+ IsTiled = (version & 0x200) != 0,
+ AttributeNames = names,
+ };
+ }
+
+ /// Header read that returns null instead of throwing — for "is this file readable at all" checks.
+ public static ExrHeader? TryRead(string path)
+ {
+ try { return Read(path); }
+ catch { return null; }
+ }
+
+ private static IEnumerable ReadChannels(ReadOnlySpan data)
+ {
+ var result = new List();
+ int p = 0;
+ while (p < data.Length)
+ {
+ var name = ReadCString(data, ref p);
+ if (name.Length == 0) break; // null terminator ends the channel list
+ if (p + 16 > data.Length) break;
+ var pixelType = BitConverter.ToInt32(data.Slice(p, 4));
+ p += 16; // pixelType(4) + pLinear(1) + reserved(3) + xSampling(4) + ySampling(4)
+ result.Add(new ExrChannel(name, pixelType));
+ }
+ return result;
+ }
+
+ private static string ReadCString(ReadOnlySpan span, ref int pos)
+ {
+ int start = pos;
+ while (pos < span.Length && span[pos] != 0) pos++;
+ var s = Encoding.ASCII.GetString(span[start..pos]);
+ pos++; // skip the terminator
+ return s;
+ }
+}
+
+public readonly record struct ExrChannel(string Name, int PixelType)
+{
+ public string TypeName => PixelType switch { 0 => "uint", 1 => "half", 2 => "float", _ => $"type {PixelType}" };
+}
+
+///
+/// SMPTE 12M timecode as stored in the EXR `timeCode` attribute: BCD-packed,
+/// which is why the raw value reads back as its own hex digits
+/// (0x18585712 → 18:58:57:12).
+///
+public readonly record struct Timecode(int Hours, int Minutes, int Seconds, int Frames, bool DropFrame)
+{
+ public static Timecode FromPacked(uint timeAndFlags, uint _userData = 0)
+ {
+ int Bits(int shift, int mask) => (int)((timeAndFlags >> shift) & (uint)mask);
+ return new Timecode(
+ Bits(24, 0xF) + Bits(28, 0x3) * 10,
+ Bits(16, 0xF) + Bits(20, 0x7) * 10,
+ Bits(8, 0xF) + Bits(12, 0x7) * 10,
+ Bits(0, 0xF) + Bits(4, 0x3) * 10,
+ Bits(6, 0x1) == 1);
+ }
+
+ public static Timecode? Parse(string? text, bool dropFrame = false)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var parts = text.Trim().Split(':', ';');
+ if (parts.Length < 4) return null;
+ if (!int.TryParse(parts[0], out var hh) || !int.TryParse(parts[1], out var mm) ||
+ !int.TryParse(parts[2], out var ss) || !int.TryParse(parts[3], out var ff)) return null;
+ return new Timecode(hh, mm, ss, ff, dropFrame || text.Contains(';'));
+ }
+
+ ///
+ /// Non-drop frame count. The studio shoots 24 fps, so drop-frame never
+ /// applies; a drop-frame flag mismatch is reported as its own check rather
+ /// than silently changing this arithmetic.
+ ///
+ public long ToFrames(double fps)
+ {
+ var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
+ return ((Hours * 60L + Minutes) * 60L + Seconds) * rate + Frames;
+ }
+
+ public static Timecode FromFrames(long frames, double fps)
+ {
+ var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
+ if (rate <= 0) rate = 24;
+ var f = Math.Max(0, frames);
+ var ff = (int)(f % rate);
+ var totalSeconds = f / rate;
+ return new Timecode((int)(totalSeconds / 3600), (int)(totalSeconds / 60 % 60), (int)(totalSeconds % 60), ff, false);
+ }
+
+ public override string ToString() =>
+ $"{Hours:D2}:{Minutes:D2}:{Seconds:D2}{(DropFrame ? ';' : ':')}{Frames:D2}";
+}
diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs
index 8c0067a..9556200 100644
--- a/RenderWorker/VFXReviewWorker/JobExecutor.cs
+++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs
@@ -14,15 +14,20 @@ public sealed class JobExecutor
private readonly ApiClient _api;
private readonly AerenderRunner _runner;
private readonly PreviewStage _preview;
+ private readonly ValidationStage _validation;
+ private readonly DeliveryStage _delivery;
private readonly ReportSpooler _spooler;
private readonly ILogger _log;
- public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, ReportSpooler spooler, ILogger log)
+ public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
+ ValidationStage validation, DeliveryStage delivery, ReportSpooler spooler, ILogger log)
{
_options = options;
_api = api;
_runner = runner;
_preview = preview;
+ _validation = validation;
+ _delivery = delivery;
_spooler = spooler;
_log = log;
}
@@ -35,6 +40,12 @@ public sealed class JobExecutor
return;
}
+ if (job.Type == "DELIVERY_BUILD")
+ {
+ await RunDeliveryAsync(job, machineId, cancelJob, shutdown);
+ return;
+ }
+
var manifest = job.AsRenderManifest();
var mapper = new PathMapper(_options.PathMappings);
var outputDirLocal = mapper.Map(manifest.OutputDir);
@@ -101,6 +112,15 @@ public sealed class JobExecutor
["exrFileCount"] = fileCount,
["exrTotalBytes"] = totalBytes,
});
+
+ // Validation runs here, while we still hold the job and the
+ // files are local. It is queued behind complete-render, and the
+ // spooler's in-order replay is what guarantees the server has
+ // moved the Export to VALIDATING before this lands (§8).
+ if (config.ValidationEnabled)
+ {
+ await RunValidationAsync(job, manifest, machineId, outputDirLocal, config, shutdown);
+ }
}
else if (result.ErrorMessage == "Cancelled")
{
@@ -149,6 +169,140 @@ public sealed class JobExecutor
}
}
+ ///
+ /// DELIVERY_BUILD job: build the dated package folder from already-QC'd
+ /// exports. No AE involved — folders, links and verification only.
+ ///
+ private async Task RunDeliveryAsync(ClaimedJob job, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
+ {
+ var manifest = job.AsDeliveryManifest();
+
+ WriteState(new CurrentJobState
+ {
+ JobId = job.Id,
+ ExportId = null,
+ MachineId = machineId,
+ OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.PackageDir),
+ ClaimedAt = DateTimeOffset.UtcNow,
+ });
+
+ try
+ {
+ var result = await _delivery.RunAsync(job, manifest, machineId, cancelJob, shutdown);
+
+ _log.LogInformation("Delivery {Name}: {Ok} ({Verified}/{Total} shots verified)",
+ manifest.PackageName, result.Ok ? "READY" : "FAILED",
+ result.Items.Count(i => i.Status == "VERIFIED"), manifest.Items.Count);
+
+ string? reportKey = null;
+ if (result.ReportPath is not null)
+ {
+ reportKey = await _api.UploadArtifactAsync(job.Id, machineId, "report", result.ReportPath, "text/csv", shutdown);
+ }
+
+ _spooler.Enqueue("delivery", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["ok"] = result.Ok,
+ ["rootPath"] = result.PackageDir,
+ ["reportKey"] = reportKey,
+ ["items"] = result.Items.Select(i => new Dictionary
+ {
+ ["itemId"] = i.ItemId,
+ ["status"] = i.Status,
+ ["exrPath"] = i.ExrPath,
+ ["movPath"] = i.MovPath,
+ ["fileCount"] = i.FileCount,
+ ["totalBytes"] = i.TotalBytes,
+ ["checksum"] = i.Checksum,
+ ["linkMode"] = i.LinkMode,
+ ["errorMessage"] = i.ErrorMessage,
+ }).ToList(),
+ ["manifest"] = new Dictionary
+ {
+ ["packageDir"] = result.PackageDir,
+ ["reportPath"] = result.ReportPath,
+ ["files"] = result.Files,
+ },
+ ["errorMessage"] = result.Ok
+ ? null
+ : string.Join("; ", result.Items.Where(i => i.Status == "FAILED").Select(i => $"{i.ShotCode}: {i.ErrorMessage}").Take(5)),
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Unexpected delivery error for job {JobId}", job.Id);
+ _spooler.Enqueue("delivery", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["ok"] = false,
+ ["items"] = new List(),
+ ["errorMessage"] = $"Worker exception: {ex.Message}",
+ });
+ }
+ finally
+ {
+ ClearState();
+ }
+ }
+
+ ///
+ /// Runs the validation engine and spools the E12 report. Deliberately
+ /// non-fatal on its own errors: a crash in the checks must not lose a
+ /// finished render, so it is reported as a failed validation run rather
+ /// than being allowed to escape.
+ ///
+ private async Task RunValidationAsync(
+ ClaimedJob job, RenderManifest manifest, string machineId, string outputDirLocal,
+ ServerConfig config, CancellationToken shutdown)
+ {
+ var started = DateTimeOffset.UtcNow;
+ try
+ {
+ // Keep the lease alive: the job stays open through validation.
+ await _api.ProgressAsync(job.Id, machineId, 1.0, manifest.FrameEnd, manifest.TotalFrames, 0,
+ "Validating rendered sequence…", shutdown);
+
+ var outcome = await Task.Run(() => _validation.Run(manifest, outputDirLocal, config), shutdown);
+ var seconds = (DateTimeOffset.UtcNow - started).TotalSeconds;
+
+ var failed = outcome.Checks.Where(c => c.Status == "FAIL").Select(c => c.CheckName).ToList();
+ if (failed.Count > 0)
+ {
+ _log.LogWarning("Job {JobId} validation FAILED in {Sec:F1}s: {Checks}", job.Id, seconds, string.Join(", ", failed));
+ }
+ else
+ {
+ _log.LogInformation("Job {JobId} validation passed in {Sec:F1}s ({Count} checks, {Files} files)",
+ job.Id, seconds, outcome.Checks.Count, outcome.FileCount);
+ }
+
+ _spooler.Enqueue("validation", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["checks"] = outcome.Checks,
+ ["checksum"] = outcome.Checksum,
+ ["checksumAlgo"] = "xxh64",
+ ["exrFileCount"] = outcome.FileCount,
+ ["exrTotalBytes"] = outcome.TotalBytes,
+ ["validationSeconds"] = (int)seconds,
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Validation engine error for job {JobId}", job.Id);
+ _spooler.Enqueue("validation", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["checks"] = new[]
+ {
+ ValidationCheck.Fail("engine_error", "validation completes", "worker exception",
+ $"The validation engine threw: {ex.Message}"),
+ },
+ });
+ }
+ }
+
///
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
/// and burn-ins, render the delivery MOV + review MP4, upload the review
diff --git a/RenderWorker/VFXReviewWorker/Program.cs b/RenderWorker/VFXReviewWorker/Program.cs
index c5984d7..e09404b 100644
--- a/RenderWorker/VFXReviewWorker/Program.cs
+++ b/RenderWorker/VFXReviewWorker/Program.cs
@@ -10,6 +10,13 @@ using VFXReviewWorker;
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
// Install as a service: see RenderWorker/README.md
+// Dry-run the validation engine over an existing render folder. No server, no
+// config, no queue — useful for checking work that predates the pipeline.
+if (args.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase)))
+{
+ return ValidateCommand.Run(args);
+}
+
var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
// Install verification on a new machine — resolves paths, checks tools and
@@ -32,6 +39,8 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
diff --git a/RenderWorker/VFXReviewWorker/ReportSpooler.cs b/RenderWorker/VFXReviewWorker/ReportSpooler.cs
index 2e4a889..352c72e 100644
--- a/RenderWorker/VFXReviewWorker/ReportSpooler.cs
+++ b/RenderWorker/VFXReviewWorker/ReportSpooler.cs
@@ -9,6 +9,11 @@ namespace VFXReviewWorker;
/// order with exponential backoff (1 s → 60 s). Rendering continues during
/// server downtime; a report is deleted only once the server accepts it (or
/// permanently rejects it as contradictory).
+///
+/// Strict ordering is load-bearing beyond durability: the validation report
+/// (E12) is only legal once complete-render (E11) has moved the Export to
+/// VALIDATING, and in-order replay is what guarantees that, with no need for
+/// the worker to wait on an ACK.
///
public sealed class ReportSpooler
{
diff --git a/RenderWorker/VFXReviewWorker/ValidateCommand.cs b/RenderWorker/VFXReviewWorker/ValidateCommand.cs
new file mode 100644
index 0000000..08a132d
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/ValidateCommand.cs
@@ -0,0 +1,98 @@
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace VFXReviewWorker;
+
+///
+/// `VFXReviewWorker.exe --validate <renderDir> [--plate <plateFile>] [--fps 24]`
+///
+/// Runs the validation engine (§8) over a folder of already-rendered EXRs and
+/// prints the report, without a server, a job or a queue. Two uses: proving the
+/// checks against real work before the stage goes live, and answering "is this
+/// delivery good?" for a sequence that predates the pipeline.
+///
+public static class ValidateCommand
+{
+ public static int Run(string[] args)
+ {
+ var dir = args.SkipWhile(a => !a.Equals("--validate", StringComparison.OrdinalIgnoreCase)).Skip(1).FirstOrDefault();
+ if (string.IsNullOrWhiteSpace(dir) || dir.StartsWith('-'))
+ {
+ Console.Error.WriteLine("Usage: VFXReviewWorker.exe --validate [--plate ] [--fps 24] [--sample 25]");
+ return 2;
+ }
+ if (!Directory.Exists(dir))
+ {
+ Console.Error.WriteLine($"Not a directory: {dir}");
+ return 2;
+ }
+
+ var plate = ArgValue(args, "--plate");
+ var fps = double.TryParse(ArgValue(args, "--fps"), out var f) ? f : 24;
+ var sample = int.TryParse(ArgValue(args, "--sample"), out var s) ? s : 25;
+
+ var files = Directory.EnumerateFiles(dir, "*.exr").OrderBy(x => x, StringComparer.Ordinal).ToList();
+ if (files.Count == 0)
+ {
+ Console.Error.WriteLine($"No .exr files in {dir}");
+ return 2;
+ }
+
+ // Infer what the manifest would have said from the files themselves.
+ var firstName = Path.GetFileName(files[0]);
+ var frames = files.Select(x => SequencePattern.FrameFromName(Path.GetFileName(x))).Where(x => x is not null).Select(x => x!.Value).ToList();
+ var prefix = firstName.Contains('.') ? firstName[..firstName.IndexOf('.')] : Path.GetFileNameWithoutExtension(firstName);
+ var header = ExrHeader.TryRead(files[0]);
+
+ var manifest = new RenderManifest
+ {
+ ShotCode = prefix.Split("_cmp_")[0],
+ OutputDir = dir,
+ OutputPattern = $"{prefix}.[#####].exr",
+ FrameStart = frames.Count > 0 ? frames.Min() : 1,
+ FrameEnd = frames.Count > 0 ? frames.Max() : files.Count,
+ Fps = fps,
+ Width = header?.DataWidth ?? 0,
+ Height = header?.DataHeight ?? 0,
+ Source = plate is null ? null : new ManifestSource
+ {
+ TimingPlate = new PlateRef { Path = plate, FrameStart = SequencePattern.FrameFromName(Path.GetFileName(plate)) },
+ StartSource = "supplied on the command line",
+ },
+ };
+
+ var options = new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" };
+ var stage = new ValidationStage(options, NullLogger.Instance);
+ var outcome = stage.Run(manifest, dir, new ServerConfig { ValidationSampleEvery = sample });
+
+ Console.WriteLine();
+ Console.WriteLine($"{files.Count} files in {dir}");
+ Console.WriteLine($"inferred range {manifest.FrameStart}-{manifest.FrameEnd} at {fps} fps, pattern {manifest.OutputPattern}");
+ if (plate is not null) Console.WriteLine($"timing plate {plate}");
+ Console.WriteLine();
+
+ foreach (var c in outcome.Checks)
+ {
+ var mark = c.Status switch { "PASS" => " ok ", "FAIL" => " FAIL ", "WARN" => " warn ", _ => " skip " };
+ Console.WriteLine($"[{mark}] {c.CheckName,-24} {Describe(c)}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($"checksum (xxh64): {outcome.Checksum ?? "(not computed)"}");
+ var failed = outcome.Checks.Count(c => c.Status == "FAIL");
+ Console.WriteLine(failed == 0 ? "PASSED" : $"FAILED — {failed} check(s)");
+ return failed == 0 ? 0 : 1;
+ }
+
+ private static string Describe(ValidationCheck c)
+ {
+ if (!string.IsNullOrWhiteSpace(c.Message)) return c.Message!;
+ if (c.Expected is null && c.Actual is null) return "";
+ return c.Expected == c.Actual ? c.Actual ?? "" : $"expected {c.Expected}, got {c.Actual}";
+ }
+
+ private static string? ArgValue(string[] args, string name)
+ {
+ var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
+ return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker/ValidationStage.cs b/RenderWorker/VFXReviewWorker/ValidationStage.cs
new file mode 100644
index 0000000..9ef16a9
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/ValidationStage.cs
@@ -0,0 +1,590 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+
+namespace VFXReviewWorker;
+
+///
+/// Validation engine (RenderPipeline2 §8). Runs on the worker straight after a
+/// successful render, while it still holds the job and the files are local.
+/// It only ever *reports* — the server decides what the results mean.
+///
+/// Full-sequence checks (count, gaps, names, sizes) touch every file; header
+/// checks sample first/middle/last plus every Nth frame.
+///
+public sealed class ValidationStage
+{
+ private readonly WorkerOptions _options;
+ private readonly ILogger _log;
+
+ public ValidationStage(WorkerOptions options, ILogger log)
+ {
+ _options = options;
+ _log = log;
+ }
+
+ public ValidationOutcome Run(RenderManifest manifest, string outputDirLocal, ServerConfig config)
+ {
+ var checks = new List();
+ var files = EnumerateSequence(manifest, outputDirLocal, checks, out var frameNumbers, out var totalBytes);
+
+ if (files.Count == 0)
+ {
+ // Nothing to inspect: the sequence checks already recorded the FAIL.
+ return new ValidationOutcome(checks, 0, 0, null);
+ }
+
+ var sampleEvery = Math.Max(1, config.ValidationSampleEvery);
+ var sampled = PickSamples(files, sampleEvery);
+ var headers = ReadHeaders(sampled, checks);
+
+ if (headers.Count > 0)
+ {
+ CheckResolution(manifest, headers, checks);
+ CheckBitDepthAndChannels(manifest, headers, checks);
+ CheckCompression(manifest, headers, checks);
+ CheckTimecode(manifest, headers, frameNumbers, config, checks);
+ }
+
+ var checksum = ComputeSequenceChecksum(files);
+ return new ValidationOutcome(checks, files.Count, totalBytes, checksum);
+ }
+
+ // ── Sequence-level checks ────────────────────────────────────────────────
+
+ private List EnumerateSequence(
+ RenderManifest manifest, string outputDirLocal, List checks,
+ out List frameNumbers, out long totalBytes)
+ {
+ frameNumbers = new List();
+ totalBytes = 0;
+ var expectedCount = manifest.TotalFrames;
+
+ if (!Directory.Exists(outputDirLocal))
+ {
+ checks.Add(ValidationCheck.Fail("file_count", expectedCount.ToString(), "0",
+ $"Output directory does not exist on this machine: {outputDirLocal}"));
+ return new List();
+ }
+
+ var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
+ var matched = new List<(int Frame, string Path)>();
+ var nonConforming = new List();
+
+ foreach (var path in Directory.EnumerateFiles(outputDirLocal))
+ {
+ var name = Path.GetFileName(path);
+ if (!name.EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase)) continue;
+ // Only files belonging to this render: a version folder can also hold
+ // the preview MOV/MP4, which are not part of the EXR sequence.
+ if (!name.StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)) continue;
+
+ var frame = pattern.FrameNumberOf(name);
+ if (frame is null) { nonConforming.Add(name); continue; }
+ matched.Add((frame.Value, path));
+ }
+
+ matched.Sort((a, b) => a.Frame.CompareTo(b.Frame));
+ var files = matched.Select(m => m.Path).ToList();
+ frameNumbers = matched.Select(m => m.Frame).ToList();
+
+ // file_count
+ checks.Add(files.Count == expectedCount
+ ? ValidationCheck.Pass("file_count", expectedCount.ToString(), files.Count.ToString())
+ : ValidationCheck.Fail("file_count", expectedCount.ToString(), files.Count.ToString(),
+ $"Expected {expectedCount} frames ({manifest.FrameStart}-{manifest.FrameEnd}), found {files.Count} in {outputDirLocal}"));
+
+ // filename_consistency
+ checks.Add(nonConforming.Count == 0
+ ? ValidationCheck.Pass("filename_consistency", pattern.Describe(), "all files match")
+ : ValidationCheck.Fail("filename_consistency", pattern.Describe(), $"{nonConforming.Count} nonconforming",
+ "Files not matching the output pattern: " + string.Join(", ", nonConforming.Take(10)),
+ new { files = nonConforming.Take(50) }));
+
+ if (files.Count == 0) return files;
+
+ // missing_frames / duplicate_frames / first_frame / last_frame
+ var seen = new HashSet();
+ var duplicates = new List();
+ foreach (var f in frameNumbers)
+ {
+ if (!seen.Add(f)) duplicates.Add(f);
+ }
+ var missing = new List();
+ for (var f = manifest.FrameStart; f <= manifest.FrameEnd; f++)
+ {
+ if (!seen.Contains(f)) missing.Add(f);
+ }
+
+ checks.Add(missing.Count == 0
+ ? ValidationCheck.Pass("missing_frames", "none", "none")
+ : ValidationCheck.Fail("missing_frames", "none", $"{missing.Count} missing",
+ "Missing frames: " + Summarise(missing), new { missing = missing.Take(500) }));
+
+ checks.Add(duplicates.Count == 0
+ ? ValidationCheck.Pass("duplicate_frames", "none", "none")
+ : ValidationCheck.Fail("duplicate_frames", "none", $"{duplicates.Count} duplicated",
+ "Duplicate frame numbers (differing padding?): " + Summarise(duplicates),
+ new { duplicates = duplicates.Take(100) }));
+
+ checks.Add(frameNumbers[0] == manifest.FrameStart
+ ? ValidationCheck.Pass("first_frame", manifest.FrameStart.ToString(), frameNumbers[0].ToString())
+ : ValidationCheck.Fail("first_frame", manifest.FrameStart.ToString(), frameNumbers[0].ToString(),
+ $"Sequence starts at {frameNumbers[0]}, expected {manifest.FrameStart}"));
+
+ var last = frameNumbers[^1];
+ checks.Add(last == manifest.FrameEnd
+ ? ValidationCheck.Pass("last_frame", manifest.FrameEnd.ToString(), last.ToString())
+ : ValidationCheck.Fail("last_frame", manifest.FrameEnd.ToString(), last.ToString(),
+ $"Sequence ends at {last}, expected {manifest.FrameEnd}"));
+
+ // zero_byte — every file, it is only a directory stat
+ var zeroByte = new List();
+ foreach (var path in files)
+ {
+ var len = new FileInfo(path).Length;
+ totalBytes += len;
+ if (len == 0) zeroByte.Add(Path.GetFileName(path));
+ }
+ checks.Add(zeroByte.Count == 0
+ ? ValidationCheck.Pass("zero_byte", "none", "none")
+ : ValidationCheck.Fail("zero_byte", "none", $"{zeroByte.Count} empty",
+ "Zero-byte files: " + string.Join(", ", zeroByte.Take(10)), new { files = zeroByte.Take(50) }));
+
+ return files;
+ }
+
+ // ── Header checks (sampled) ──────────────────────────────────────────────
+
+ private static List PickSamples(List files, int sampleEvery)
+ {
+ var picks = new SortedSet { 0, files.Count / 2, files.Count - 1 };
+ for (var i = 0; i < files.Count; i += sampleEvery) picks.Add(i);
+ return picks.Where(i => i >= 0 && i < files.Count).Select(i => files[i]).ToList();
+ }
+
+ private List<(string Path, ExrHeader Header)> ReadHeaders(List sampled, List checks)
+ {
+ var headers = new List<(string, ExrHeader)>();
+ var unreadable = new List();
+
+ foreach (var path in sampled)
+ {
+ try
+ {
+ headers.Add((path, ExrHeader.Read(path)));
+ }
+ catch (Exception ex)
+ {
+ unreadable.Add($"{Path.GetFileName(path)}: {ex.Message}");
+ _log.LogWarning("Unreadable EXR header: {Path} ({Error})", path, ex.Message);
+ }
+ }
+
+ checks.Add(unreadable.Count == 0
+ ? ValidationCheck.Pass("corrupt", "all sampled frames readable", $"{headers.Count} sampled")
+ : ValidationCheck.Fail("corrupt", "all sampled frames readable", $"{unreadable.Count} unreadable",
+ "Unreadable EXR headers: " + string.Join("; ", unreadable.Take(5)), new { files = unreadable.Take(50) }));
+
+ return headers;
+ }
+
+ private static void CheckResolution(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks)
+ {
+ if (m.Width <= 0 || m.Height <= 0)
+ {
+ checks.Add(ValidationCheck.Skipped("resolution", "manifest carries no expected resolution"));
+ return;
+ }
+ var expected = $"{m.Width}x{m.Height}";
+ var bad = headers.Where(h => h.H.DataWidth != m.Width || h.H.DataHeight != m.Height).ToList();
+ checks.Add(bad.Count == 0
+ ? ValidationCheck.Pass("resolution", expected, expected)
+ : ValidationCheck.Fail("resolution", expected, $"{bad[0].H.DataWidth}x{bad[0].H.DataHeight}",
+ $"{bad.Count} of {headers.Count} sampled frames are not {expected} (first: {Path.GetFileName(bad[0].Path)})"));
+ }
+
+ private static void CheckBitDepthAndChannels(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks)
+ {
+ var expectedDepth = m.Expected?.BitDepth;
+ var actualDepth = headers[0].H.BitDepth ?? "unknown";
+ if (string.IsNullOrWhiteSpace(expectedDepth))
+ {
+ checks.Add(ValidationCheck.Skipped("bit_depth", $"no expected bit depth configured (rendered {actualDepth})"));
+ }
+ else
+ {
+ var mismatched = headers.Where(h => !string.Equals(h.H.BitDepth, expectedDepth, StringComparison.OrdinalIgnoreCase)).ToList();
+ checks.Add(mismatched.Count == 0
+ ? ValidationCheck.Pass("bit_depth", expectedDepth, actualDepth)
+ : ValidationCheck.Fail("bit_depth", expectedDepth, mismatched[0].H.BitDepth ?? "unknown",
+ $"{mismatched.Count} of {headers.Count} sampled frames are not {expectedDepth}"));
+ }
+
+ var hasAlpha = headers[0].H.HasAlpha;
+ var channelList = string.Join(",", headers[0].H.Channels.Select(c => c.Name));
+ if (m.Expected?.Alpha is null)
+ {
+ checks.Add(ValidationCheck.Skipped("alpha_channel", $"no expectation configured (channels: {channelList})"));
+ }
+ else
+ {
+ checks.Add(hasAlpha == m.Expected.Alpha.Value
+ ? ValidationCheck.Pass("alpha_channel", m.Expected.Alpha.Value ? "alpha present" : "no alpha", channelList)
+ : ValidationCheck.Fail("alpha_channel",
+ m.Expected.Alpha.Value ? "alpha present" : "no alpha",
+ hasAlpha ? "alpha present" : "no alpha",
+ $"Channel list is {channelList}"));
+ }
+ }
+
+ private static void CheckCompression(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks)
+ {
+ var actual = headers[0].H.CompressionName;
+ if (headers[0].H.CompressionCode is null)
+ {
+ checks.Add(ValidationCheck.Fail("exr_type", "readable EXR", "no compression attribute",
+ "Sampled frame has no compression attribute — not a valid EXR header"));
+ return;
+ }
+ var expected = m.Expected?.ExrCompression;
+ if (string.IsNullOrWhiteSpace(expected))
+ {
+ checks.Add(ValidationCheck.Pass("exr_type", "valid EXR", $"{actual}{(headers[0].H.IsTiled ? ", tiled" : ", scanline")}"));
+ return;
+ }
+ // Compression differing but readable is a WARN, never a stop (§8.1).
+ checks.Add(string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)
+ ? ValidationCheck.Pass("exr_type", expected, actual)
+ : ValidationCheck.Warn("exr_type", expected, actual,
+ $"Compression is {actual}, expected {expected} — readable, so not blocking"));
+ }
+
+ // ── Timecode: the primary check (§8.1a) ──────────────────────────────────
+
+ private void CheckTimecode(
+ RenderManifest m, List<(string Path, ExrHeader H)> headers, List frameNumbers,
+ ServerConfig config, List checks)
+ {
+ var fps = m.Fps > 0 ? m.Fps : 24;
+ var first = headers[0];
+
+ if (first.H.Timecode is null)
+ {
+ // AE's EXR output module writes timeCode; its absence means the
+ // output module changed, so this is a stop rather than a skip.
+ checks.Add(ValidationCheck.Fail("timecode_plate_match", "timecode present", "absent",
+ $"No timeCode attribute in {Path.GetFileName(first.Path)} — the EXR output module no longer writes timecode",
+ new { attributes = first.H.AttributeNames.Take(40) }));
+ return;
+ }
+
+ var renderTc = first.H.Timecode.Value;
+ var renderFirstFrame = frameNumbers.Count > 0 ? frameNumbers[0] : m.FrameStart;
+
+ // timecode_consistency — sampled frames must advance exactly one frame
+ // each. A constant value is the frame-derived-timecode defect.
+ var drift = new List();
+ for (var i = 0; i < headers.Count; i++)
+ {
+ var h = headers[i].H;
+ if (h.Timecode is null) { drift.Add($"{Path.GetFileName(headers[i].Path)}: no timecode"); continue; }
+ var frameNo = SequencePattern.FrameFromName(Path.GetFileName(headers[i].Path)) ?? (renderFirstFrame + i);
+ var expectedTc = Timecode.FromFrames(renderTc.ToFrames(fps) + (frameNo - renderFirstFrame), fps);
+ if (h.Timecode.Value.ToFrames(fps) != expectedTc.ToFrames(fps))
+ {
+ drift.Add($"frame {frameNo}: {h.Timecode.Value} (expected {expectedTc})");
+ }
+ }
+ checks.Add(drift.Count == 0
+ ? ValidationCheck.Pass("timecode_consistency", "monotonic, 1 frame per frame", $"{headers.Count} frames sampled")
+ : ValidationCheck.Fail("timecode_consistency", "monotonic, 1 frame per frame", $"{drift.Count} sampled frames off",
+ "Timecode does not advance with the frames: " + string.Join("; ", drift.Take(5)),
+ new { drift = drift.Take(50) }));
+
+ // timecode_fps
+ if (first.H.FramesPerSecond is double headerFps)
+ {
+ checks.Add(Math.Abs(headerFps - fps) < 0.01
+ ? ValidationCheck.Pass("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"))
+ : ValidationCheck.Fail("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"),
+ $"EXR framesPerSecond is {headerFps:0.###} but the comp rendered at {fps:0.###}"));
+ }
+ else
+ {
+ checks.Add(ValidationCheck.Skipped("timecode_fps", "no framesPerSecond attribute in the rendered EXRs"));
+ }
+
+ // timecode_plate_match — reference precedence, rank 1 → 3.
+ var reference = ResolveTimecodeReference(m, config);
+ if (reference is null)
+ {
+ checks.Add(ValidationCheck.Warn("timecode_plate_match", "plate timecode", renderTc.ToString(),
+ "No plate reference available: the manifest carries no timing plate, none was found on disk, " +
+ "and no API timecode was supplied. Render timecode is unverified.",
+ new { renderTimecode = renderTc.ToString() }));
+ return;
+ }
+
+ var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame);
+ var expected = Timecode.FromFrames(expectedFrames, fps);
+ var actualFrames = renderTc.ToFrames(fps);
+ var deltaFrames = actualFrames - expectedFrames;
+ var details = new
+ {
+ reference = reference.Kind,
+ platePath = reference.PlatePath,
+ plateTimecode = reference.PlateTimecode.ToString(),
+ plateFirstFrame = reference.PlateFirstFrame,
+ renderFirstFrame,
+ deltaFrames,
+ };
+
+ if (deltaFrames == 0)
+ {
+ // The API fallback proves nothing on its own (§8.1a): it is the
+ // editorial in-point minus a handle count that may not be this
+ // plate's, so a match is reported as a WARN, never a PASS.
+ checks.Add(reference.Kind == "api"
+ ? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
+ "Matches the API timecode less handles, but the plate itself could not be read — unverified.", details)
+ : ValidationCheck.Pass("timecode_plate_match", expected.ToString(), renderTc.ToString(),
+ $"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}", details));
+ return;
+ }
+
+ var message =
+ $"Render starts at {renderTc} but the {reference.Kind} plate says it should be {expected} " +
+ $"({(deltaFrames > 0 ? "+" : "")}{deltaFrames} frames). " +
+ (Math.Abs(deltaFrames) == reference.HandleFrames && reference.HandleFrames > 0
+ ? $"That is exactly the {reference.HandleFrames}-frame handle: the comp was anchored to the editorial in-point instead of the plate's first frame."
+ : renderTc.Hours == 0 && renderTc.Minutes == 0
+ ? "A near-zero timecode means the comp had no source timecode at all and AE derived it from the frame number."
+ : "");
+
+ checks.Add(reference.Kind == "api"
+ ? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
+ message + " (checked against the API value, not the plate — treat as advisory)", details)
+ : ValidationCheck.Fail("timecode_plate_match", expected.ToString(), renderTc.ToString(), message, details));
+ }
+
+ ///
+ /// Reference precedence (§8.1a): manifest timing plate → plate found under a
+ /// configured search root → API timecode minus handles (advisory only).
+ ///
+ private TimecodeReference? ResolveTimecodeReference(RenderManifest m, ServerConfig config)
+ {
+ var mapper = new PathMapper(_options.PathMappings);
+ var handleFrames = m.Source?.HandleFrames ?? config.ValidationHandleFrames;
+
+ // Rank 1 — the plate the comp was actually built from.
+ var timing = m.Source?.TimingPlate;
+ if (timing is not null && !string.IsNullOrWhiteSpace(timing.Path))
+ {
+ var local = mapper.Map(timing.Path);
+ var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
+ if (header?.Timecode is not null)
+ {
+ var frame = SequencePattern.FrameFromName(Path.GetFileName(local)) ?? timing.FrameStart ?? m.FrameStart;
+ return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames);
+ }
+ _log.LogWarning("Timing plate from manifest is unreadable: {Path}", local);
+ }
+
+ // Rank 2 — derive the plate from a configured search root.
+ var derived = FindPlateOnDisk(m, mapper);
+ if (derived is not null) return derived with { HandleFrames = handleFrames };
+
+ // Rank 3 — the API's editorial in-point, less handles. Advisory only.
+ var apiTc = Timecode.Parse(m.Expected?.TimecodeStart);
+ if (apiTc is not null)
+ {
+ var fps = m.Fps > 0 ? m.Fps : 24;
+ var plateStart = Timecode.FromFrames(apiTc.Value.ToFrames(fps) - handleFrames, fps);
+ return new TimecodeReference("api", null, plateStart, m.FrameStart, handleFrames);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Rank 2: look for the shot's plate folders under the configured roots,
+ /// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and
+ /// {shotCode}_{element}/). Ambiguity is resolved by frame count; still
+ /// ambiguous means no reference rather than a guess.
+ ///
+ private TimecodeReference? FindPlateOnDisk(RenderManifest m, PathMapper mapper)
+ {
+ if (_options.PlateSearchRoots.Count == 0 || string.IsNullOrWhiteSpace(m.ShotCode)) return null;
+
+ var candidates = new List<(string Dir, int Count)>();
+ foreach (var rootRaw in _options.PlateSearchRoots)
+ {
+ var root = mapper.Map(rootRaw);
+ if (!Directory.Exists(root)) continue;
+ // The show/episode level is unknown here, so scan the root and one
+ // level below it — bounded, and enough for both layouts.
+ var levels = new List { root };
+ try { levels.AddRange(Directory.EnumerateDirectories(root)); } catch { }
+
+ foreach (var level in levels)
+ {
+ try
+ {
+ var grouped = Path.Combine(level, m.ShotCode);
+ if (Directory.Exists(grouped))
+ {
+ foreach (var element in Directory.EnumerateDirectories(grouped))
+ {
+ AddCandidate(candidates, element);
+ }
+ }
+ foreach (var element in Directory.EnumerateDirectories(level, m.ShotCode + "_*"))
+ {
+ AddCandidate(candidates, element);
+ }
+ }
+ catch { /* unreadable branch — keep scanning the rest */ }
+ }
+ }
+
+ if (candidates.Count == 0) return null;
+
+ // Prefer the element whose frame count matches the render: that is the
+ // one the comp's timing came from.
+ var exact = candidates.Where(c => c.Count == m.TotalFrames).ToList();
+ (string Dir, int Count)? chosen =
+ exact.Count == 1 ? exact[0]
+ : exact.Count == 0 && candidates.Count == 1 ? candidates[0]
+ : null;
+
+ if (chosen is null)
+ {
+ _log.LogWarning("Plate reference ambiguous for {Shot}: {Candidates}",
+ m.ShotCode, string.Join(", ", candidates.Select(c => Path.GetFileName(c.Dir))));
+ return null;
+ }
+
+ var firstFile = Directory.EnumerateFiles(chosen.Value.Dir, "*.exr").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
+ if (firstFile is null) return null;
+ var header = ExrHeader.TryRead(firstFile);
+ if (header?.Timecode is null) return null;
+
+ var frame = SequencePattern.FrameFromName(Path.GetFileName(firstFile)) ?? m.FrameStart;
+ return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0);
+ }
+
+ private static void AddCandidate(List<(string Dir, int Count)> candidates, string dir)
+ {
+ try
+ {
+ var count = Directory.EnumerateFiles(dir, "*.exr").Count();
+ if (count > 0) candidates.Add((dir, count));
+ }
+ catch { }
+ }
+
+ // ── Checksums ────────────────────────────────────────────────────────────
+
+ ///
+ /// Sequence digest = xxHash64 over the per-file digests in frame order
+ /// (§8.2). Reused by the delivery builder to verify what it linked/copied.
+ ///
+ private string? ComputeSequenceChecksum(List files)
+ {
+ try
+ {
+ var state = new XxHash64.State(0);
+ foreach (var f in files)
+ {
+ var digest = XxHash64.HashFile(f);
+ state.Update(System.Text.Encoding.ASCII.GetBytes(digest));
+ }
+ return state.Digest().ToString("x16");
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Sequence checksum failed");
+ return null;
+ }
+ }
+
+ private static string Summarise(IEnumerable numbers)
+ {
+ var list = numbers.Take(12).ToList();
+ var text = string.Join(", ", list);
+ return numbers.Count() > 12 ? text + ", …" : text;
+ }
+}
+
+public sealed record TimecodeReference(
+ string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames);
+
+public sealed record ValidationOutcome(
+ List Checks, int FileCount, long TotalBytes, string? Checksum)
+{
+ public bool HasFailure => Checks.Any(c => c.Status == "FAIL");
+}
+
+/// One row of the E12 report.
+public sealed class ValidationCheck
+{
+ public string CheckName { get; set; } = "";
+ public string Status { get; set; } = "PASS";
+ public string? Expected { get; set; }
+ public string? Actual { get; set; }
+ public string? Message { get; set; }
+ public object? Details { get; set; }
+
+ public static ValidationCheck Pass(string name, string? expected = null, string? actual = null, string? message = null, object? details = null) =>
+ new() { CheckName = name, Status = "PASS", Expected = expected, Actual = actual, Message = message, Details = details };
+
+ public static ValidationCheck Fail(string name, string? expected, string? actual, string message, object? details = null) =>
+ new() { CheckName = name, Status = "FAIL", Expected = expected, Actual = actual, Message = message, Details = details };
+
+ public static ValidationCheck Warn(string name, string? expected, string? actual, string message, object? details = null) =>
+ new() { CheckName = name, Status = "WARN", Expected = expected, Actual = actual, Message = message, Details = details };
+
+ public static ValidationCheck Skipped(string name, string message) =>
+ new() { CheckName = name, Status = "SKIPPED", Message = message };
+}
+
+///
+/// Parses `{base}.[#####].exr` output patterns and the filenames they produce.
+///
+public sealed class SequencePattern
+{
+ public string Prefix { get; private init; } = "";
+ public string Extension { get; private init; } = ".exr";
+ public int Padding { get; private init; }
+
+ private static readonly Regex FrameInName = new(@"\.(\d{2,9})\.[A-Za-z0-9]+$", RegexOptions.Compiled);
+
+ public static SequencePattern FromOutputPattern(string outputPattern)
+ {
+ var pattern = outputPattern ?? "";
+ var ext = Path.GetExtension(pattern);
+ if (string.IsNullOrEmpty(ext)) ext = ".exr";
+
+ var hashStart = pattern.IndexOf('[');
+ var prefix = hashStart > 0 ? pattern[..hashStart].TrimEnd('.') : Path.GetFileNameWithoutExtension(pattern);
+ var padding = 0;
+ foreach (var ch in pattern)
+ {
+ if (ch == '#') padding++;
+ }
+
+ return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
+ }
+
+ public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
+
+ /// `UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.
+ public static int? FrameFromName(string fileName)
+ {
+ var m = FrameInName.Match(fileName);
+ if (!m.Success) return null;
+ return int.TryParse(m.Groups[1].Value, out var n) ? n : null;
+ }
+
+ public string Describe() => $"{Prefix}.[{new string('#', Math.Max(1, Padding))}]{Extension}";
+}
diff --git a/RenderWorker/VFXReviewWorker/WorkerOptions.cs b/RenderWorker/VFXReviewWorker/WorkerOptions.cs
index da43ca8..35aead4 100644
--- a/RenderWorker/VFXReviewWorker/WorkerOptions.cs
+++ b/RenderWorker/VFXReviewWorker/WorkerOptions.cs
@@ -21,6 +21,12 @@ public sealed class WorkerOptions
public string? FfmpegPath { get; set; }
public string? OiiotoolPath { get; set; }
public List PathMappings { get; set; } = new();
+ ///
+ /// Optional footage roots searched for a shot's plates when a manifest
+ /// carries no timing plate (rank 2 of §8.1a) — e.g. ["V:/_FOOTAGE/UNG"].
+ /// Leave empty and old manifests simply fall through to the API fallback.
+ ///
+ public List PlateSearchRoots { get; set; } = new();
public string WorkerVersion { get; set; } = "1.0.0";
public string? AeVersion { get; set; }
diff --git a/RenderWorker/VFXReviewWorker/WorkerService.cs b/RenderWorker/VFXReviewWorker/WorkerService.cs
index bb8bf24..7f6bccb 100644
--- a/RenderWorker/VFXReviewWorker/WorkerService.cs
+++ b/RenderWorker/VFXReviewWorker/WorkerService.cs
@@ -61,9 +61,11 @@ public sealed class WorkerService : BackgroundService
: "No interactive After Effects — preview jobs enabled");
_lastAeOpen = aeOpen;
}
+ // Delivery builds are file work with no AE involvement, so they
+ // are always claimable — someone is usually waiting on the folder.
var types = aeOpen
- ? new[] { "AE_RENDER" }
- : new[] { "AE_RENDER", "PREVIEW_ONLY" };
+ ? new[] { "AE_RENDER", "DELIVERY_BUILD" }
+ : new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD" };
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
if (job is not null)
diff --git a/RenderWorker/VFXReviewWorker/XxHash64.cs b/RenderWorker/VFXReviewWorker/XxHash64.cs
new file mode 100644
index 0000000..6fccf75
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/XxHash64.cs
@@ -0,0 +1,158 @@
+namespace VFXReviewWorker;
+
+///
+/// xxHash64 (§8.2 `checksumAlgo: "xxh64"`). Implemented here rather than taken
+/// from System.IO.Hashing so the worker stays a dependency-free single-file
+/// publish for offline workstation installs. Verified against the reference
+/// test vectors in XxHash64Tests.
+///
+public static class XxHash64
+{
+ private const ulong Prime1 = 11400714785074694791UL;
+ private const ulong Prime2 = 14029467366897019727UL;
+ private const ulong Prime3 = 1609587929392839161UL;
+ private const ulong Prime4 = 9650029242287828579UL;
+ private const ulong Prime5 = 2870177450012600261UL;
+
+ public static ulong Hash(ReadOnlySpan data, ulong seed = 0)
+ {
+ var state = new State(seed);
+ state.Update(data);
+ return state.Digest();
+ }
+
+ public static string HashFile(string path, int bufferSize = 1 << 20)
+ {
+ var state = new State(0);
+ using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize, FileOptions.SequentialScan);
+ var buffer = new byte[bufferSize];
+ int read;
+ while ((read = fs.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ state.Update(new ReadOnlySpan(buffer, 0, read));
+ }
+ return state.Digest().ToString("x16");
+ }
+
+ /// Streaming state so multi-GB sequences hash without being held in memory.
+ public struct State
+ {
+ private ulong _v1, _v2, _v3, _v4;
+ private readonly ulong _seed;
+ private ulong _length;
+ private readonly byte[] _buffer;
+ private int _bufferLen;
+
+ public State(ulong seed)
+ {
+ _seed = seed;
+ _v1 = seed + Prime1 + Prime2;
+ _v2 = seed + Prime2;
+ _v3 = seed;
+ _v4 = seed - Prime1;
+ _length = 0;
+ _buffer = new byte[32];
+ _bufferLen = 0;
+ }
+
+ public void Update(ReadOnlySpan data)
+ {
+ _length += (ulong)data.Length;
+
+ if (_bufferLen > 0)
+ {
+ var need = Math.Min(32 - _bufferLen, data.Length);
+ data[..need].CopyTo(_buffer.AsSpan(_bufferLen));
+ _bufferLen += need;
+ data = data[need..];
+ if (_bufferLen < 32) return;
+ ProcessStripe(_buffer);
+ _bufferLen = 0;
+ }
+
+ while (data.Length >= 32)
+ {
+ ProcessStripe(data[..32]);
+ data = data[32..];
+ }
+
+ if (data.Length > 0)
+ {
+ data.CopyTo(_buffer.AsSpan(0));
+ _bufferLen = data.Length;
+ }
+ }
+
+ private void ProcessStripe(ReadOnlySpan stripe)
+ {
+ _v1 = Round(_v1, BitConverter.ToUInt64(stripe[..8]));
+ _v2 = Round(_v2, BitConverter.ToUInt64(stripe.Slice(8, 8)));
+ _v3 = Round(_v3, BitConverter.ToUInt64(stripe.Slice(16, 8)));
+ _v4 = Round(_v4, BitConverter.ToUInt64(stripe.Slice(24, 8)));
+ }
+
+ public ulong Digest()
+ {
+ ulong h;
+ if (_length >= 32)
+ {
+ h = RotateLeft(_v1, 1) + RotateLeft(_v2, 7) + RotateLeft(_v3, 12) + RotateLeft(_v4, 18);
+ h = MergeRound(h, _v1);
+ h = MergeRound(h, _v2);
+ h = MergeRound(h, _v3);
+ h = MergeRound(h, _v4);
+ }
+ else
+ {
+ h = _seed + Prime5;
+ }
+
+ h += _length;
+
+ var tail = new ReadOnlySpan(_buffer, 0, _bufferLen);
+ int i = 0;
+ while (i + 8 <= tail.Length)
+ {
+ h ^= Round(0, BitConverter.ToUInt64(tail.Slice(i, 8)));
+ h = RotateLeft(h, 27) * Prime1 + Prime4;
+ i += 8;
+ }
+ if (i + 4 <= tail.Length)
+ {
+ h ^= BitConverter.ToUInt32(tail.Slice(i, 4)) * Prime1;
+ h = RotateLeft(h, 23) * Prime2 + Prime3;
+ i += 4;
+ }
+ while (i < tail.Length)
+ {
+ h ^= tail[i] * Prime5;
+ h = RotateLeft(h, 11) * Prime1;
+ i++;
+ }
+
+ h ^= h >> 33;
+ h *= Prime2;
+ h ^= h >> 29;
+ h *= Prime3;
+ h ^= h >> 32;
+ return h;
+ }
+
+ private static ulong Round(ulong acc, ulong input)
+ {
+ acc += input * Prime2;
+ acc = RotateLeft(acc, 31);
+ acc *= Prime1;
+ return acc;
+ }
+
+ private static ulong MergeRound(ulong acc, ulong val)
+ {
+ acc ^= Round(0, val);
+ acc = acc * Prime1 + Prime4;
+ return acc;
+ }
+
+ private static ulong RotateLeft(ulong value, int bits) => (value << bits) | (value >> (64 - bits));
+ }
+}
diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx
index 5476fb2..b409b06 100644
--- a/VFXReviewConnector.jsx
+++ b/VFXReviewConnector.jsx
@@ -28,7 +28,14 @@ var SETTINGS_KEY = "cfg";
lastActionText: null,
pipelineStatusText: null,
urgentCheckbox: null,
- submissionNoteField: null
+ submissionNoteField: null,
+ qcList: null,
+ qcNotesField: null,
+ qcStatusText: null
+ };
+
+ var qcState = {
+ entries: []
};
var CURRENT_PROJECT = null; // { code, name, showId, projectType }
@@ -47,7 +54,8 @@ var SETTINGS_KEY = "cfg";
var buildState = {
lastStartSource: "",
- lastStartFrames: 0
+ lastStartFrames: 0,
+ lastStartShotCode: ""
};
function parseJSON(text) {
@@ -78,6 +86,264 @@ var SETTINGS_KEY = "cfg";
return String(value).replace(/^\s+/, "").replace(/\s+$/, "");
}
+ // ── Diagnostics ───────────────────────────────────────────────────────────
+ //
+ // Every button records what it tried, in order, to an in-memory trace and to
+ // a log file next to config.json. A failure therefore reports the stage it
+ // died at and the paths/HTTP codes it was working with, instead of a bare
+ // "failed" that can only be diagnosed by sitting at the machine.
+
+ var DIAG = {
+ lines: [],
+ maxLines: 300,
+ lastFailure: null
+ };
+
+ function getLogPath() {
+ return Folder.userData.fsName + "/VFXReviewConnector/connector.log";
+ }
+
+ function padTwo(value) {
+ return ("0" + value).slice(-2);
+ }
+
+ function diagTimestamp() {
+ var now = new Date();
+
+ return now.getFullYear() + "-" + padTwo(now.getMonth() + 1) + "-" + padTwo(now.getDate()) +
+ " " + padTwo(now.getHours()) + ":" + padTwo(now.getMinutes()) + ":" + padTwo(now.getSeconds());
+ }
+
+ function diagWriteToFile(text) {
+ var dir;
+ var file;
+
+ try {
+ dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector");
+ if (!dir.exists) {
+ dir.create();
+ }
+
+ file = new File(getLogPath());
+ // Keep the log small enough to paste into a message or email.
+ if (file.exists && file.length > 1048576) {
+ file.remove();
+ file = new File(getLogPath());
+ }
+
+ file.encoding = "UTF-8";
+ if (!file.open("a")) {
+ return;
+ }
+ file.write(text + "\n");
+ file.close();
+ } catch (logError) {
+ }
+ }
+
+ function diagLog(message) {
+ var line = "[" + diagTimestamp() + "] " + message;
+
+ DIAG.lines.push(line);
+ if (DIAG.lines.length > DIAG.maxLines) {
+ DIAG.lines.shift();
+ }
+ diagWriteToFile(line);
+ }
+
+ function describeError(err) {
+ var text;
+
+ if (!err) {
+ return "unknown error";
+ }
+
+ text = err.toString ? err.toString() : String(err);
+ if (err.line) {
+ text += " (line " + err.line + (err.fileName ? " of " + err.fileName : "") + ")";
+ }
+ return text;
+ }
+
+ // The token is never logged — only whether one is configured, which is what
+ // an artist actually needs to know when the API says 401.
+ function describeConfig() {
+ return "baseUrl=" + (CONFIG.baseUrl || "(empty)") +
+ " | token=" + (trimText(CONFIG.token) ? trimText(CONFIG.token).length + " chars" : "EMPTY") +
+ " | footageRoot=" + (CONFIG.footageRoot || "(empty)") +
+ " | exportRoot=" + (CONFIG.exportRoot || "(empty)") +
+ " | artistEmail=" + (CONFIG.artistEmail || "(empty)") +
+ " | handleFrames=" + CONFIG.handleFrames;
+ }
+
+ function diagStart(action) {
+ DIAG.lines = [];
+ DIAG.lastFailure = null;
+ diagWriteToFile("");
+ diagLog("=== " + action + " ===");
+ diagLog("AE " + app.version + " | " + $.os + " | project " +
+ (app.project && app.project.file ? app.project.file.fsName : "(unsaved)"));
+ diagLog("config: " + describeConfig());
+ diagLog("selected project: " + (CURRENT_PROJECT
+ ? CURRENT_PROJECT.code + " (showId " + CURRENT_PROJECT.showId + ", " + CURRENT_PROJECT.projectType + ")"
+ : "(none)"));
+ }
+
+ function diagFail(stage, detail, hints) {
+ var i;
+
+ DIAG.lastFailure = { stage: stage, detail: detail, hints: hints || [] };
+ diagLog("FAILED [" + stage + "] " + detail);
+ for (i = 0; hints && i < hints.length; i += 1) {
+ diagLog(" - " + hints[i]);
+ }
+ }
+
+ function diagReport() {
+ var text = DIAG.lines.join("\n");
+ var failure = DIAG.lastFailure;
+ var i;
+
+ if (failure) {
+ text += "\n\n--- Failure -------------------------------------------------\n";
+ text += "Stage: " + failure.stage + "\n";
+ text += "Detail: " + failure.detail + "\n";
+ for (i = 0; i < failure.hints.length; i += 1) {
+ text += " - " + failure.hints[i] + "\n";
+ }
+ }
+
+ return text + "\n\nFull log: " + getLogPath();
+ }
+
+ // A scrollable, selectable dialog rather than alert(): the point is that the
+ // artist can copy the whole trace and send it on.
+ function showDiagDialog(titleText, bodyText) {
+ var dlg;
+ var heading;
+ var box;
+ var row;
+ var openLogButton;
+
+ try {
+ dlg = new Window("dialog", "VFXReview Connector — Diagnostics");
+ dlg.orientation = "column";
+ dlg.alignChildren = ["fill", "top"];
+ dlg.margins = 14;
+ dlg.spacing = 8;
+
+ heading = dlg.add("statictext", undefined, titleText, { multiline: true });
+ heading.preferredSize = [620, 34];
+
+ box = dlg.add("edittext", undefined, bodyText, { multiline: true, scrolling: true });
+ box.preferredSize = [620, 340];
+
+ row = dlg.add("group");
+ row.orientation = "row";
+ row.alignment = ["right", "top"];
+ row.spacing = 8;
+
+ openLogButton = row.add("button", undefined, "Open Log Folder");
+ openLogButton.onClick = function () {
+ try {
+ new File(getLogPath()).parent.execute();
+ } catch (openError) {
+ }
+ };
+ row.add("button", undefined, "Close", { name: "ok" });
+
+ dlg.layout.layout(true);
+ dlg.center();
+ dlg.show();
+ } catch (dialogError) {
+ alert(titleText + "\n\n" + bodyText);
+ }
+ }
+
+ // ScriptUI silently swallows exceptions thrown inside onClick handlers, so a
+ // hard failure looks like a button that did nothing at all. Every handler is
+ // wrapped: the click is traced, and anything thrown is surfaced.
+ function guarded(label, handler) {
+ return function () {
+ diagStart(label);
+ try {
+ handler();
+ } catch (handlerError) {
+ diagFail("unexpected error", describeError(handlerError),
+ [$.stack ? "stack: " + String($.stack).replace(/[\r\n]+/g, " > ") : "no stack available"]);
+ try {
+ app.endUndoGroup();
+ } catch (undoError) {
+ }
+ updateStatus(getSelectedComps().length, label + " failed: " + describeError(handlerError));
+ showDiagDialog(label + " failed unexpectedly. Send this to the pipeline TD.", diagReport());
+ }
+ };
+ }
+
+ // The first ancestor that does exist separates "drive not mapped" from
+ // "show folder misnamed" from "only the leaf is missing".
+ function describeMissingPath(path) {
+ var probe = new Folder(path);
+ var guard = 0;
+
+ while (probe && !probe.exists && guard < 24) {
+ probe = probe.parent;
+ guard += 1;
+ }
+
+ if (!probe || !probe.exists) {
+ return "no part of that path exists on this machine — is the drive mapped?";
+ }
+ return "deepest folder that does exist: " + probe.fsName;
+ }
+
+ function listFolderNames(folder, limit) {
+ var names = [];
+ var entries;
+ var i;
+
+ try {
+ entries = folder.getFiles();
+ } catch (listError) {
+ return names;
+ }
+
+ for (i = 0; i < entries.length && names.length < limit; i += 1) {
+ if (entries[i] instanceof Folder) {
+ names.push(entries[i].name);
+ }
+ }
+
+ return names;
+ }
+
+ function describeFolderContents(folder) {
+ var entries;
+ var exrCount = 0;
+ var otherCount = 0;
+ var folderCount = 0;
+ var i;
+
+ try {
+ entries = folder.getFiles();
+ } catch (contentsError) {
+ return "unreadable (permissions?)";
+ }
+
+ for (i = 0; i < entries.length; i += 1) {
+ if (entries[i] instanceof Folder) {
+ folderCount += 1;
+ } else if (/\.exr$/i.test(entries[i].name)) {
+ exrCount += 1;
+ } else {
+ otherCount += 1;
+ }
+ }
+
+ return exrCount + " .exr, " + otherCount + " other file(s), " + folderCount + " sub-folder(s)";
+ }
+
function getSelectedComps() {
var comps = [];
var selection;
@@ -97,57 +363,99 @@ var SETTINGS_KEY = "cfg";
return comps;
}
- function getShotData(shotCode) {
- var url;
+ // GET that keeps the HTTP code and the raw response body, so "could not
+ // reach the server" and "server said 401" stay distinguishable instead of
+ // both arriving as null. curl's own errors are folded into stdout with
+ // --stderr - rather than a 2>&1 redirect, which needs a shell that
+ // system.callSystem does not reliably give us. Returns { status, body, data }.
+ function httpGetDetailed(url) {
var command;
- var response;
- var parsed;
+ var response = null;
+ var markerIndex;
+ var result = { status: 0, body: "", data: null };
+
+ command = "curl -s -S --stderr - " +
+ "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
+ "-H \"Accept: application/json\" " +
+ "-w \"__HTTP__:%{http_code}\" " +
+ "\"" + url + "\"";
try {
- url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
- "?shotCode=" + encodeURIComponent(shotCode) +
- "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
-
- command = "curl -s " +
- "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
- "-H \"Accept: application/json\" " +
- "\"" + url + "\"";
-
response = system.callSystem(command);
} catch (callError) {
- return null;
+ result.body = "curl could not be run: " + describeError(callError);
+ return result;
}
- parsed = parseJSON(response);
- if (!parsed) {
- return null;
+ if (!response) {
+ result.body = "empty response (curl produced no output)";
+ return result;
}
- return parsed;
+ markerIndex = String(response).lastIndexOf("__HTTP__:");
+ if (markerIndex >= 0) {
+ result.status = parseInt(String(response).substring(markerIndex + 9), 10) || 0;
+ result.body = String(response).substring(0, markerIndex);
+ } else {
+ result.body = String(response);
+ }
+
+ result.data = parseJSON(result.body);
+ return result;
+ }
+
+ function summariseResponse(result) {
+ var body = trimText(result.body).replace(/[\r\n]+/g, " ");
+
+ if (body.length > 200) {
+ body = body.substring(0, 200) + "...";
+ }
+ return "HTTP " + result.status + (body ? " — " + body : "");
+ }
+
+ // Hints that turn the HTTP code into the thing to actually go and check.
+ function hintsForResponse(result) {
+ if (result.status === 0) {
+ return ["The server was not reached at all: check Server URL in Settings, that the machine can " +
+ "resolve/route to it, and that curl.exe exists (Windows 10 1803+ ships it)."];
+ }
+ if (result.status === 401 || result.status === 403) {
+ return ["The API token was rejected: paste the token from the server .env (API_SECRET_KEY) into Settings."];
+ }
+ if (result.status === 404) {
+ return ["The server answered but has no such record — check the shot/project exists in the web app."];
+ }
+ if (result.status >= 500) {
+ return ["The server errored. Check the web app logs for the same timestamp."];
+ }
+ return [];
+ }
+
+ function getShotData(shotCode) {
+ var url = CONFIG.baseUrl + "/api/ext/shots/lookup" +
+ "?shotCode=" + encodeURIComponent(shotCode) +
+ "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : "");
+ var result = httpGetDetailed(url);
+
+ DIAG.lastResponse = result;
+
+ if (!result.data || !result.data.shot) {
+ diagLog("shot lookup " + shotCode + ": " + summariseResponse(result));
+ }
+
+ return result.data;
}
function getAPIData(url) {
- var command;
- var response;
- var parsed;
+ var result = httpGetDetailed(url);
- try {
- command = "curl -s " +
- "-H \"Authorization: Bearer " + CONFIG.token + "\" " +
- "-H \"Accept: application/json\" " +
- "\"" + url + "\"";
+ DIAG.lastResponse = result;
- response = system.callSystem(command);
- } catch (callError) {
- return null;
+ if (!result.data) {
+ diagLog("GET " + url + ": " + summariseResponse(result));
}
- parsed = parseJSON(response);
- if (!parsed) {
- return null;
- }
-
- return parsed;
+ return result.data;
}
function getListFromResponse(data, keyName) {
@@ -1239,25 +1547,85 @@ var SETTINGS_KEY = "cfg";
var primaryFootage = null;
var startInfo;
var i;
+ var groupFolder;
+ var folderNames;
+ var emptyFolderNotes = [];
+ var importFailure;
- apiData = getShotData(shotCode);
- if (!apiData || !apiData.shot) {
+ diagLog("build: shot=" + shotCode + " group=" + (groupCode || "(none)") +
+ " episodic=" + isEpisodicProject());
+
+ if (!CURRENT_PROJECT) {
+ diagFail("project", "no project is selected in the panel",
+ ["Pick a project in the Project dropdown. If it is empty, the server URL or token is wrong."]);
return null;
}
+ apiData = getShotData(shotCode);
+ if (!apiData || !apiData.shot) {
+ diagFail("shot lookup", "the server returned no shot record for " + shotCode +
+ " (" + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response") + ")",
+ hintsForResponse(DIAG.lastResponse || { status: 0, body: "" }).concat(
+ ["Looked up under project " + CURRENT_PROJECT.code + " — a shot belonging to another project will not be found."]));
+ return null;
+ }
+ diagLog("shot found: version " + (apiData.shot.shotVersion || "?") +
+ ", episode " + (apiData.shot.episode || "-") + ", scene " + (apiData.shot.scene || "-") +
+ ", tc " + (apiData.shot.timecodeStart || "-"));
+
if (existingShotComp) {
- updateOverlayForComp(existingShotComp, shotCode, apiData);
+ diagLog("comp " + shotCode + " already exists — refreshing its overlay only");
+ if (!updateOverlayForComp(existingShotComp, shotCode, apiData)) {
+ diagLog("overlay refresh failed (non-fatal): UNG_VFX_OVERLAY comp or its Essential Properties are missing");
+ }
return existingShotComp;
}
+ if (!trimText(CONFIG.footageRoot)) {
+ diagFail("configuration", "Footage Root is empty in Settings",
+ ["Open Settings (the gear button) and set Footage Root — the bare root, without the show folder, e.g. V:/_FOOTAGE.",
+ "Settings are per-machine — a workstation that has never been configured starts blank."]);
+ return null;
+ }
+
+ groupFolder = getFootageGroupFolder(groupCode);
+ diagLog("footage root: " + CONFIG.footageRoot + " -> show root: " + getShowFootageRoot());
+ diagLog("plate search folder: " + groupFolder.fsName);
+
+ if (!groupFolder.exists) {
+ diagFail("footage folder", groupFolder.fsName + " does not exist on this machine",
+ [describeMissingPath(groupFolder.fsName),
+ "Check the drive letter/UNC in Settings matches this machine's mapping — the path is stored per-machine.",
+ "Check the show folder is named " + (getShowId() || "(showId missing)") + ", which comes from the project's Show ID."]);
+ return null;
+ }
+
sequenceFolders = findSequenceFolders(shotCode, groupCode);
+ diagLog(sequenceFolders.length + " plate folder(s) matched for " + shotCode);
if (sequenceFolders.length < 1) {
+ folderNames = listFolderNames(groupFolder, 15);
+ diagFail("plate folders", "no plate folder for " + shotCode + " under " + groupFolder.fsName,
+ [folderNames.length
+ ? "folders present there: " + folderNames.join(", ")
+ : "that folder holds no sub-folders at all (" + describeFolderContents(groupFolder) + ")",
+ "Expected either " + shotCode + "/ with plate sub-folders, or folders named " + shotCode + "_*.",
+ "A " + shotCode + "/ folder with no plate sub-folders inside also lands here — it is treated as 'no plates yet'."]);
return null;
}
for (i = 0; i < sequenceFolders.length; i += 1) {
- footage = importEXRSequence(sequenceFolders[i]);
+ footage = null;
+ importFailure = "";
+ try {
+ footage = importEXRSequence(sequenceFolders[i]);
+ } catch (importError) {
+ importFailure = describeError(importError);
+ diagLog("import threw for " + sequenceFolders[i].fsName + ": " + importFailure);
+ }
+
if (footage) {
+ diagLog("imported " + sequenceFolders[i].name + " (" + footage.width + "x" + footage.height +
+ ", " + Math.round(footage.duration * 24) + " frames)");
footage.name = sequenceFolders[i].name;
if (footageFolder) {
footage.parentFolder = footageFolder;
@@ -1270,48 +1638,70 @@ var SETTINGS_KEY = "cfg";
height = footage.height;
primaryFootage = footage;
}
+ } else {
+ emptyFolderNotes.push(sequenceFolders[i].name + ": " +
+ (importFailure ? "import failed — " + importFailure : describeFolderContents(sequenceFolders[i])));
}
}
if (footageItems.length < 1) {
+ diagFail("plate import", sequenceFolders.length + " plate folder(s) matched but none held an importable EXR sequence",
+ emptyFolderNotes.concat(["Only *.exr files are imported — DPX/JPEG plates and .exr files inside a further sub-folder are ignored."]));
return null;
}
if (!primaryFootage) {
primaryFootage = footageItems[0];
}
- // Both comps start at the plate's native start timecode (as AE would
- // when creating a comp from the footage), not at frame 0.
- startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24);
- buildState.lastStartSource = startInfo.source;
- buildState.lastStartFrames = startInfo.frames;
+ try {
+ // Both comps start at the plate's native start timecode (as AE would
+ // when creating a comp from the footage), not at frame 0.
+ startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24);
+ buildState.lastStartSource = startInfo.source;
+ buildState.lastStartFrames = startInfo.frames;
+ buildState.lastStartShotCode = shotCode;
+ diagLog("start frame " + startInfo.frames + " from " + startInfo.source);
- footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24);
- setCompStartFrame(footageComp, startInfo.frames);
- if (precompFolder) {
- footageComp.parentFolder = precompFolder;
+ footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24);
+ setCompStartFrame(footageComp, startInfo.frames);
+ if (precompFolder) {
+ footageComp.parentFolder = precompFolder;
+ }
+
+ for (i = footageItems.length - 1; i >= 0; i -= 1) {
+ footageLayer = footageComp.layers.add(footageItems[i]);
+ addOCIOToLayer(footageLayer);
+ }
+
+ shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24);
+ setCompStartFrame(shotComp, startInfo.frames);
+ if (episodeProjectFolder) {
+ shotComp.parentFolder = episodeProjectFolder;
+ } else {
+ diagLog("no " + groupCode + " folder under __SHOTS — comp left at the project root");
+ }
+
+ shotComp.layers.add(footageComp);
+ } catch (compError) {
+ diagFail("comp creation", describeError(compError),
+ ["The plates imported, so this is an After Effects error rather than a path or server problem.",
+ "Run Initialise Workspace if the project's folder structure is missing."]);
+ return null;
}
- for (i = footageItems.length - 1; i >= 0; i -= 1) {
- footageLayer = footageComp.layers.add(footageItems[i]);
- addOCIOToLayer(footageLayer);
- }
-
- shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24);
- setCompStartFrame(shotComp, startInfo.frames);
- if (episodeProjectFolder) {
- shotComp.parentFolder = episodeProjectFolder;
- }
-
- shotComp.layers.add(footageComp);
-
showLut = findComp("_SHOW LUT");
if (showLut) {
lutLayer = shotComp.layers.add(showLut);
lutLayer.collapseTransformation = true;
+ } else {
+ diagLog("no _SHOW LUT comp in this project — shot built without it");
}
- updateOverlayForComp(shotComp, shotCode, apiData);
+ if (!updateOverlayForComp(shotComp, shotCode, apiData)) {
+ diagLog("overlay not applied (non-fatal): UNG_VFX_OVERLAY comp missing, or its DATE/SHOT NAME Essential Properties are not exposed");
+ }
+ diagLog("built comp " + shotComp.name + " (" + width + "x" + height + ", " +
+ Math.round(maxDuration * 24) + " frames, " + footageItems.length + " plate layer(s))");
return shotComp;
}
@@ -1320,6 +1710,7 @@ var SETTINGS_KEY = "cfg";
var shotCode;
var shotComp;
var isEpisodic = isEpisodicProject();
+ var failure;
if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) {
updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first.");
@@ -1343,9 +1734,159 @@ var SETTINGS_KEY = "cfg";
updateStatus(getSelectedComps().length, "Shot built: " + shotCode +
" (start frame " + buildState.lastStartFrames +
(buildState.lastStartSource ? " from " + buildState.lastStartSource : "") + ")");
- } else {
- updateStatus(getSelectedComps().length, "Shot build failed: " + shotCode);
+ return;
}
+
+ // Report the stage it died at rather than a bare "failed", and hand the
+ // artist the whole trace so it can be sent on without a screen-share.
+ failure = DIAG.lastFailure || { stage: "unknown", detail: "no reason was recorded", hints: [] };
+ updateStatus(getSelectedComps().length, "Build failed at " + failure.stage + ": " + failure.detail);
+ showDiagDialog("Could not build " + shotCode + " — failed at: " + failure.stage +
+ "\n" + failure.detail, diagReport());
+ }
+
+ // Checks everything Build Shot depends on, without building anything, so a
+ // workstation can be cleared start to finish before an artist is blocked.
+ function runDiagnostics() {
+ var problems = 0;
+ var curlVersion;
+ var projects;
+ var groupCode = "";
+ var shotCode = "";
+ var groupFolder;
+ var sequenceFolders;
+ var exportRoot;
+ var names;
+ var missingItems = [];
+ var requiredComps = ["UNG_VFX_OVERLAY", "UNG_EXPORT_TEMPLATE", "_SHOW LUT"];
+ var requiredFolders = ["__SHOTS", "_FOOTAGE_4K", "_PRECOMPS", "_PREVIEWS"];
+ var i;
+
+ diagLog("config file: " + getConfigPath());
+
+ try {
+ curlVersion = trimText(String(system.callSystem("curl --version")).split(/[\r\n]/)[0]);
+ } catch (curlError) {
+ curlVersion = "";
+ }
+ if (/^curl /i.test(curlVersion)) {
+ diagLog("curl: " + curlVersion);
+ } else {
+ problems += 1;
+ diagLog("PROBLEM curl not usable: " + (curlVersion || "no output") +
+ " — every server call goes through curl.exe, so nothing will load until it is on PATH");
+ }
+
+ if (!trimText(CONFIG.baseUrl)) {
+ problems += 1;
+ diagLog("PROBLEM Server URL is empty in Settings");
+ }
+ if (!trimText(CONFIG.token)) {
+ problems += 1;
+ diagLog("PROBLEM API Token is empty in Settings");
+ }
+
+ projects = getAPIData(CONFIG.baseUrl + "/api/ext/projects");
+ if (projects) {
+ diagLog("API OK: " + getListFromResponse(projects, "projects").length + " project(s) visible");
+ } else {
+ problems += 1;
+ diagLog("PROBLEM API call failed: " +
+ (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
+ names = hintsForResponse(DIAG.lastResponse || { status: 0, body: "" });
+ for (i = 0; i < names.length; i += 1) {
+ diagLog(" - " + names[i]);
+ }
+ }
+
+ if (!CURRENT_PROJECT) {
+ problems += 1;
+ diagLog("PROBLEM no project selected — the rest of the path checks need one");
+ } else {
+ if (uiState.episodeDropdown && uiState.episodeDropdown.selection) {
+ groupCode = isEpisodicProject()
+ ? (uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text))
+ : (uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text);
+ }
+ if (uiState.shotDropdown && uiState.shotDropdown.selection) {
+ shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text;
+ }
+
+ if (!trimText(CONFIG.footageRoot)) {
+ problems += 1;
+ diagLog("PROBLEM Footage Root is empty in Settings");
+ } else {
+ groupFolder = getFootageGroupFolder(groupCode);
+ if (groupFolder.exists) {
+ diagLog("footage folder OK: " + groupFolder.fsName + " (" + describeFolderContents(groupFolder) + ")");
+ if (shotCode) {
+ sequenceFolders = findSequenceFolders(shotCode, groupCode);
+ if (sequenceFolders.length) {
+ for (i = 0; i < sequenceFolders.length; i += 1) {
+ diagLog(" plate folder " + sequenceFolders[i].name + ": " +
+ describeFolderContents(sequenceFolders[i]));
+ }
+ } else {
+ problems += 1;
+ names = listFolderNames(groupFolder, 15);
+ diagLog("PROBLEM no plate folder for " + shotCode +
+ (names.length ? " — folders present: " + names.join(", ") : " — folder is empty"));
+ }
+ }
+ } else {
+ problems += 1;
+ diagLog("PROBLEM footage folder missing: " + groupFolder.fsName);
+ diagLog(" - " + describeMissingPath(groupFolder.fsName));
+ }
+ }
+
+ exportRoot = getShowExportRoot();
+ if (!trimText(CONFIG.exportRoot)) {
+ problems += 1;
+ diagLog("PROBLEM Export Root is empty in Settings — renders and deliveries have nowhere to go");
+ } else if (new Folder(exportRoot).exists) {
+ diagLog("export root OK: " + exportRoot);
+ } else {
+ problems += 1;
+ diagLog("PROBLEM export root missing: " + exportRoot);
+ diagLog(" - " + describeMissingPath(exportRoot));
+ }
+
+ if (shotCode) {
+ if (getShotData(shotCode)) {
+ diagLog("shot lookup OK for " + shotCode);
+ } else {
+ problems += 1;
+ diagLog("PROBLEM shot lookup failed for " + shotCode + ": " +
+ (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
+ }
+ }
+ }
+
+ for (i = 0; i < requiredComps.length; i += 1) {
+ if (!findComp(requiredComps[i])) {
+ missingItems.push("comp " + requiredComps[i]);
+ }
+ }
+ for (i = 0; i < requiredFolders.length; i += 1) {
+ if (!findFolder(requiredFolders[i])) {
+ missingItems.push("folder " + requiredFolders[i]);
+ }
+ }
+ if (missingItems.length) {
+ diagLog("missing from the open AE project: " + missingItems.join(", ") +
+ " — run Initialise Workspace, or open the show template");
+ } else {
+ diagLog("AE project template items all present");
+ }
+
+ diagLog(problems ? problems + " problem(s) found" : "no problems found");
+ updateStatus(getSelectedComps().length, problems
+ ? "Diagnostics: " + problems + " problem(s) — see the report"
+ : "Diagnostics: no problems found");
+ showDiagDialog(problems
+ ? problems + " problem(s) found. Send this report to the pipeline TD."
+ : "No problems found — server, paths and template items all check out.", diagReport());
}
function buildPreviews() {
@@ -2831,11 +3372,122 @@ var SETTINGS_KEY = "cfg";
}
}
+ // Every file-backed footage item the comp actually renders from, found by
+ // walking its layers rather than re-deriving paths from a convention: what
+ // was comped is the only thing the render's timecode can be checked against
+ // (RenderPipeline2 §8.1a). Recurses through precomps, so the plates inside
+ // {shotCode}_FOOTAGE are found from the shot comp.
+ function collectPlateRefs(comp) {
+ var plates = [];
+ var seen = {};
+
+ function visit(item, depth) {
+ var i;
+ var layer;
+ var source;
+ var file;
+ var path;
+ var fps;
+
+ if (!item || depth > 4) {
+ return;
+ }
+
+ for (i = 1; i <= item.numLayers; i += 1) {
+ source = null;
+ try {
+ layer = item.layer(i);
+ source = layer.source;
+ } catch (layerError) {
+ continue;
+ }
+ if (!source) {
+ continue;
+ }
+
+ if (source instanceof CompItem) {
+ visit(source, depth + 1);
+ continue;
+ }
+ if (!(source instanceof FootageItem)) {
+ continue;
+ }
+
+ // Solids, placeholders and missing footage have no file on disk.
+ file = null;
+ try {
+ file = source.file;
+ } catch (fileError) {
+ file = null;
+ }
+ if (!file) {
+ continue;
+ }
+
+ path = String(file.fsName).replace(/\\/g, "/");
+ if (seen[path]) {
+ continue;
+ }
+ seen[path] = true;
+
+ fps = source.frameRate || item.frameRate || 24;
+ plates.push({
+ element: source.name,
+ path: path,
+ frames: Math.round(source.duration * fps),
+ duration: source.duration
+ });
+ }
+ }
+
+ visit(comp, 0);
+ return plates;
+ }
+
+ // The element the comp's timing came from: longest duration, first on tie —
+ // the same rule buildShotFromCode uses to pick primaryFootage.
+ function pickTimingPlate(plates) {
+ var best = null;
+ var i;
+
+ for (i = 0; i < plates.length; i += 1) {
+ if (!best || plates[i].duration > best.duration) {
+ best = plates[i];
+ }
+ }
+
+ return best;
+ }
+
+ function toManifestPlate(plate, frameStart) {
+ var ref = { element: plate.element, path: plate.path };
+ var parsed = parseFrameNumberFromPath(plate.path);
+ var start = parsed !== null ? parsed : frameStart;
+
+ ref.frameStart = start;
+ ref.frameEnd = start + Math.max(0, plate.frames - 1);
+ return ref;
+ }
+
+ // "UNG_102_019_010_BG01_TT_v001.01001.exr" -> 1001
+ function parseFrameNumberFromPath(path) {
+ var match = String(path).match(/\.(\d{2,9})\.[A-Za-z0-9]+$/);
+
+ if (!match) {
+ return null;
+ }
+ return parseInt(match[1], 10);
+ }
+
function buildRenderManifest(comp, shot) {
var frameStart;
var frameEnd;
var frames;
var aepPath = app.project.file.fsName.replace(/\\/g, "/");
+ var plates;
+ var timingPlate;
+ var manifest;
+ var i;
// Prefer the shot record's authoritative range (the server cross-checks
// it) — but only when it looks real: shots imported without ranges
@@ -2852,8 +3504,8 @@ var SETTINGS_KEY = "cfg";
frameEnd = frameStart + frames - 1;
}
- return {
- manifestVersion: 1,
+ manifest = {
+ manifestVersion: 2,
projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "",
shotCode: shot.shotCode,
shotId: shot.id,
@@ -2867,8 +3519,39 @@ var SETTINGS_KEY = "cfg";
frameEnd: frameEnd,
fps: Math.round(comp.frameRate * 1000) / 1000,
width: comp.width,
- height: comp.height
+ height: comp.height,
+ expected: {
+ // The API's editorial in-point. Only ever the worker's last-resort
+ // timecode reference, and advisory even then (§8.1a).
+ timecodeStart: shot.timecodeStart || undefined
+ }
};
+
+ plates = collectPlateRefs(comp);
+ timingPlate = pickTimingPlate(plates);
+ if (timingPlate) {
+ manifest.source = {
+ timingPlate: toManifestPlate(timingPlate, frameStart),
+ plates: [],
+ handleFrames: CONFIG.handleFrames
+ };
+ for (i = 0; i < plates.length; i += 1) {
+ manifest.source.plates.push(toManifestPlate(plates[i], frameStart));
+ }
+ // Only claim a start source when this session actually resolved one
+ // for this shot — an AEP opened from disk has no such history, and a
+ // guess here would either cry wolf or hide a real problem.
+ if (buildState.lastStartShotCode === shot.shotCode && buildState.lastStartSource) {
+ manifest.source.startSource = buildState.lastStartSource;
+ manifest.source.startFrames = buildState.lastStartFrames;
+ }
+ diagLog("manifest plates: " + plates.length + ", timing plate " + timingPlate.element);
+ } else {
+ diagLog("no file-backed footage found in " + comp.name +
+ " — the render's timecode cannot be checked against a plate");
+ }
+
+ return manifest;
}
function submitExport(manifest, urgent, force) {
@@ -3061,6 +3744,224 @@ var SETTINGS_KEY = "cfg";
}
}
+ // ── QC (RenderPipeline2 §10) ──────────────────────────────────────────────
+ //
+ // Technical QC on the *rendered EXRs*, not the H.264 preview: the point is
+ // catching mask slips, export glitches and metadata faults at full res, so
+ // selecting a queue entry imports the real sequence.
+
+ function loadQCQueue() {
+ var url = CONFIG.baseUrl + "/api/ext/qc/queue";
+ var data;
+ var entries;
+ var i;
+ var entry;
+ var label;
+
+ if (!uiState.qcList) {
+ return;
+ }
+ if (CURRENT_PROJECT) {
+ url += "?projectCode=" + encodeURIComponent(CURRENT_PROJECT.code);
+ }
+
+ setQCStatus("Loading QC queue...");
+ data = getAPIData(url);
+ if (!data) {
+ setQCStatus("QC queue failed: " + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response"));
+ return;
+ }
+
+ entries = getListFromResponse(data, "queue");
+ qcState.entries = entries;
+ uiState.qcList.removeAll();
+
+ for (i = 0; i < entries.length; i += 1) {
+ entry = entries[i];
+ label = entry.shotCode + " " + entry.versionString;
+ if (entry.warnings && entry.warnings.length) {
+ label += " (" + entry.warnings.length + " warning" + (entry.warnings.length > 1 ? "s" : "") + ")";
+ }
+ uiState.qcList.add("item", label);
+ }
+
+ if (entries.length) {
+ uiState.qcList.selection = 0;
+ setQCStatus(entries.length + " export(s) awaiting QC");
+ } else {
+ setQCStatus("Nothing waiting for QC");
+ }
+ }
+
+ function getSelectedQCEntry() {
+ var index;
+
+ if (!uiState.qcList || !uiState.qcList.selection) {
+ return null;
+ }
+ index = uiState.qcList.selection.index;
+ if (index < 0 || index >= qcState.entries.length) {
+ return null;
+ }
+ return qcState.entries[index];
+ }
+
+ function describeQCEntry(entry) {
+ var text = entry.shotCode + " " + entry.versionString;
+ var i;
+
+ if (entry.readySince) {
+ text += " — ready since " + String(entry.readySince).substring(0, 10);
+ }
+ for (i = 0; entry.warnings && i < entry.warnings.length; i += 1) {
+ text += "\n" + entry.warnings[i].status + " " + entry.warnings[i].checkName +
+ (entry.warnings[i].message ? ": " + entry.warnings[i].message : "");
+ }
+ return text;
+ }
+
+ // The export's own outputPattern prefix, so a version folder that also holds
+ // the preview MOV/MP4 still resolves to the EXR sequence.
+ function importQCSequence(entry) {
+ var folder = new Folder(entry.outputDir);
+ var prefix = String(entry.outputPattern || "").split("[")[0].replace(/\.$/, "");
+ var files;
+ var importOptions;
+ var footage;
+ var comp;
+ var compName = "QC_" + entry.shotCode + "_" + entry.versionString;
+ var existing = findComp(compName);
+
+ if (existing) {
+ existing.openInViewer();
+ setQCStatus(compName + " already open");
+ return true;
+ }
+
+ if (!folder.exists) {
+ setQCStatus("Render folder not found: " + entry.outputDir);
+ diagFail("QC import", entry.outputDir + " does not exist on this machine",
+ [describeMissingPath(entry.outputDir),
+ "The render folder is written by the farm — check this machine maps the same drive."]);
+ return false;
+ }
+
+ files = folder.getFiles(function (f) {
+ return f instanceof File && /\.exr$/i.test(f.name) &&
+ (!prefix || f.name.indexOf(prefix) === 0);
+ });
+ if (!files || files.length < 1) {
+ files = folder.getFiles("*.exr");
+ }
+ if (!files || files.length < 1) {
+ setQCStatus("No EXRs in " + entry.outputDir);
+ return false;
+ }
+
+ files.sort();
+ try {
+ importOptions = new ImportOptions(files[0]);
+ importOptions.sequence = true;
+ footage = app.project.importFile(importOptions);
+ footage.mainSource.conformFrameRate = entry.fps || 24;
+ } catch (importError) {
+ setQCStatus("Import failed: " + describeError(importError));
+ return false;
+ }
+
+ try {
+ comp = app.project.items.addComp(compName, footage.width, footage.height, 1,
+ footage.duration, entry.fps || 24);
+ setCompStartFrame(comp, entry.frameStart || 0);
+ comp.layers.add(footage);
+ comp.openInViewer();
+ } catch (compError) {
+ setQCStatus("Could not build QC comp: " + describeError(compError));
+ return false;
+ }
+
+ diagLog("QC import: " + files.length + " frames from " + entry.outputDir);
+ return true;
+ }
+
+ function openSelectedQCEntry() {
+ var entry = getSelectedQCEntry();
+
+ if (!entry) {
+ setQCStatus("Select a queue entry first");
+ return;
+ }
+
+ setQCStatus("Importing " + entry.shotCode + " " + entry.versionString + "...");
+ app.beginUndoGroup("VFXReview Connector - QC Import");
+ if (importQCSequence(entry)) {
+ setQCStatus(describeQCEntry(entry));
+ }
+ app.endUndoGroup();
+ }
+
+ function submitQC(result) {
+ var entry = getSelectedQCEntry();
+ var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : "";
+ var body;
+ var response;
+
+ if (!entry) {
+ setQCStatus("Select a queue entry first");
+ return;
+ }
+
+ // A bare FAIL leaves the artist guessing at what to fix.
+ if (result === "FAIL" && !notes) {
+ if (!confirm("Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" +
+ "The artist only sees the reason you write here.")) {
+ return;
+ }
+ }
+ if (result === "PASS" && !confirm("QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" +
+ "This marks the version ready for delivery. Nothing is sent to the client.")) {
+ return;
+ }
+
+ body = { result: result };
+ if (notes) {
+ body.notes = notes;
+ }
+ if (CONFIG.artistEmail) {
+ body.reviewerEmail = CONFIG.artistEmail;
+ }
+
+ setQCStatus("Submitting QC " + result + " for " + entry.shotCode + "...");
+ response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + entry.exportId + "/qc", body);
+
+ if (response.status === 200) {
+ if (uiState.qcNotesField) {
+ uiState.qcNotesField.text = "";
+ }
+ setQCStatus(entry.shotCode + " " + entry.versionString + " — QC " + result +
+ (result === "FAIL" ? " (comp task set to CHANGES)" : " — ready for delivery"));
+ loadQCQueue();
+ } else {
+ setQCStatus("QC " + result + " failed: " +
+ ((response.data && response.data.error) ? response.data.error : "HTTP " + response.status));
+ }
+ }
+
+ function qcPass() {
+ submitQC("PASS");
+ }
+
+ function qcFail() {
+ submitQC("FAIL");
+ }
+
+ function setQCStatus(message) {
+ if (uiState.qcStatusText) {
+ uiState.qcStatusText.text = message || "";
+ }
+ diagLog("QC: " + message);
+ }
+
function buildUI(thisObjRef) {
var win = (thisObjRef instanceof Panel) ? thisObjRef : new Window("palette", "VFXReview Connector", undefined, { resizeable: true });
var bannerFile;
@@ -3107,6 +4008,16 @@ var SETTINGS_KEY = "cfg";
var workspacePanel;
var initWorkspaceButton;
var statusPanel;
+ var statusRow;
+ var diagnosticsButton;
+ var showLogButton;
+ var qcPanel;
+ var qcRow1;
+ var qcRow2;
+ var loadQCButton;
+ var openQCButton;
+ var qcPassButton;
+ var qcFailButton;
win.orientation = "column";
win.alignChildren = ["fill", "fill"];
@@ -3221,6 +4132,47 @@ var SETTINGS_KEY = "cfg";
"Reuses the rendered EXRs and keeps the version number. Slate text is\n" +
"re-read from the shot, so fix it on the web app's Slate tab first.";
+ qcPanel = scrollContent.add("panel", undefined, "QC");
+ qcPanel.orientation = "column";
+ qcPanel.alignChildren = ["fill", "top"];
+ qcPanel.margins = 8;
+ qcPanel.spacing = 4;
+
+ uiState.qcStatusText = qcPanel.add("statictext", undefined, "QC queue: not loaded", { multiline: true });
+ uiState.qcStatusText.preferredSize = [340, 34];
+
+ uiState.qcList = qcPanel.add("listbox", undefined, []);
+ uiState.qcList.preferredSize = [340, 90];
+ uiState.qcList.helpTip =
+ "Exports waiting for technical QC, oldest first.\n" +
+ "Double-click (or Open Render) to import the full-res EXRs.";
+
+ qcRow1 = qcPanel.add("group");
+ qcRow1.orientation = "row";
+ qcRow1.alignChildren = ["fill", "center"];
+ qcRow1.spacing = 6;
+ loadQCButton = qcRow1.add("button", undefined, "Load QC Queue");
+ openQCButton = qcRow1.add("button", undefined, "Open Render");
+ openQCButton.helpTip = "Import the selected export's EXR sequence into a QC comp at full resolution.";
+
+ var qcNotesGroup = qcPanel.add("group");
+ qcNotesGroup.orientation = "row";
+ qcNotesGroup.alignChildren = ["fill", "top"];
+ qcNotesGroup.spacing = 6;
+ var qcNotesLabel = qcNotesGroup.add("statictext", undefined, "Notes:");
+ qcNotesLabel.preferredSize = [70, 20];
+ uiState.qcNotesField = qcNotesGroup.add("edittext", undefined, "", { multiline: false });
+ uiState.qcNotesField.preferredSize = [240, 22];
+
+ qcRow2 = qcPanel.add("group");
+ qcRow2.orientation = "row";
+ qcRow2.alignChildren = ["fill", "center"];
+ qcRow2.spacing = 6;
+ qcPassButton = qcRow2.add("button", undefined, "QC Pass");
+ qcPassButton.helpTip = "Marks the version ready for delivery. Nothing is shared to the client.";
+ qcFailButton = qcRow2.add("button", undefined, "QC Fail");
+ qcFailButton.helpTip = "Sends the comp task back to CHANGES. Write what is wrong in Notes.";
+
buttonsPanel = scrollContent.add("panel", undefined, "Actions");
buttonsPanel.orientation = "column";
buttonsPanel.alignChildren = ["fill", "top"];
@@ -3313,6 +4265,17 @@ var SETTINGS_KEY = "cfg";
uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected");
uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready");
+ statusRow = statusPanel.add("group");
+ statusRow.orientation = "row";
+ statusRow.alignChildren = ["fill", "center"];
+ statusRow.spacing = 6;
+ diagnosticsButton = statusRow.add("button", undefined, "Diagnostics");
+ diagnosticsButton.helpTip =
+ "Check server, token, footage/export paths and template items on this\n" +
+ "machine without building anything, and show a report that can be copied.";
+ showLogButton = statusRow.add("button", undefined, "Show Last Report");
+ showLogButton.helpTip = "Re-open the trace from the last action, and a link to the log folder.";
+
uiState.projectDropdown.onChange = function () {
if (uiState.projectDropdown.selection) {
CURRENT_PROJECT = uiState.projectDropdown.selection.projectData;
@@ -3324,36 +4287,45 @@ var SETTINGS_KEY = "cfg";
}
};
uiState.episodeDropdown.onChange = populateShotDropdown;
- // ScriptUI silently swallows exceptions thrown inside onClick handlers.
- settingsBtn.onClick = function () {
- try {
- openSettingsDialog();
- } catch (settingsError) {
- alert("VFXReview Connector \u2014 Settings error:\n" + settingsError.toString() +
- (settingsError.line ? "\nLine " + settingsError.line : ""));
+ // Every handler goes through guarded(): it traces the click to the log
+ // and surfaces anything thrown, which ScriptUI would otherwise swallow.
+ settingsBtn.onClick = guarded("Settings", openSettingsDialog);
+ shotBuilderButton.onClick = guarded("Build Shot", buildShotFromDropdown);
+ queueExportButton.onClick = guarded("Queue Export", queueExport);
+ refreshExportButton.onClick = guarded("Refresh Status", refreshExportStatus);
+ retryExportButton.onClick = guarded("Retry Export", retryLatestExport);
+ regeneratePreviewButton.onClick = guarded("Regenerate Previews", regenerateLatestPreview);
+ refreshButton.onClick = guarded("Refresh", refreshSelection);
+ fixNamesButton.onClick = guarded("Fix Comp Names", fixCompNames);
+ overlayButton.onClick = guarded("Add Overlay", addOverlay);
+ previewButton.onClick = guarded("Build Preview", buildPreviews);
+ exrButton.onClick = guarded("Queue EXR", queueEXR);
+ exrPreviewButton.onClick = guarded("Queue EXR (Review)", queueEXRPreview);
+ importExrButton.onClick = guarded("Import EXR", importExportedEXR);
+ prepDeliveryButton.onClick = guarded("Prep Delivery", prepDelivery);
+ mp4Button.onClick = guarded("Queue MP4", queueMP4);
+ movButton.onClick = guarded("Queue MOV", queueMOV);
+ ocioButton.onClick = guarded("Add OCIO", applyOCIOColorSpace);
+ initWorkspaceButton.onClick = guarded("Initialise Workspace", initialiseWorkspace);
+ importRendersButton.onClick = guarded("Import Renders", importRenders);
+ pictureLockButton.onClick = guarded("Pull Picture Lock", pullPictureLock);
+ setStartTCButton.onClick = guarded("Set Start Timecode", setStartFrameFromTimecode);
+ incrementVersionButton.onClick = guarded("Increment Shot Version", incrementShotVersions);
+ loadQCButton.onClick = guarded("Load QC Queue", loadQCQueue);
+ openQCButton.onClick = guarded("Open QC Render", openSelectedQCEntry);
+ uiState.qcList.onDoubleClick = guarded("Open QC Render", openSelectedQCEntry);
+ uiState.qcList.onChange = function () {
+ var entry = getSelectedQCEntry();
+ if (entry) {
+ setQCStatus(describeQCEntry(entry));
}
};
- shotBuilderButton.onClick = buildShotFromDropdown;
- queueExportButton.onClick = queueExport;
- refreshExportButton.onClick = refreshExportStatus;
- retryExportButton.onClick = retryLatestExport;
- regeneratePreviewButton.onClick = regenerateLatestPreview;
- refreshButton.onClick = refreshSelection;
- fixNamesButton.onClick = fixCompNames;
- overlayButton.onClick = addOverlay;
- previewButton.onClick = buildPreviews;
- exrButton.onClick = queueEXR;
- exrPreviewButton.onClick = queueEXRPreview;
- importExrButton.onClick = importExportedEXR;
- prepDeliveryButton.onClick = prepDelivery;
- mp4Button.onClick = queueMP4;
- movButton.onClick = queueMOV;
- ocioButton.onClick = applyOCIOColorSpace;
- initWorkspaceButton.onClick = initialiseWorkspace;
- importRendersButton.onClick = importRenders;
- pictureLockButton.onClick = pullPictureLock;
- setStartTCButton.onClick = setStartFrameFromTimecode;
- incrementVersionButton.onClick = incrementShotVersions;
+ qcPassButton.onClick = guarded("QC Pass", qcPass);
+ qcFailButton.onClick = guarded("QC Fail", qcFail);
+ diagnosticsButton.onClick = guarded("Diagnostics", runDiagnostics);
+ showLogButton.onClick = function () {
+ showDiagDialog("Trace from the last action.", diagReport());
+ };
win.layout.layout(true);
win.layout.resize();
diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md
index a42e92f..85e6b6f 100644
--- a/VFXReviewConnector.md
+++ b/VFXReviewConnector.md
@@ -123,13 +123,15 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
## Panel Layout
-The panel is divided into five sections:
+The panel is divided into seven sections:
1. **Shot Builder** — Episode/shot selection and comp creation
-2. **Actions** — Per-shot and batch operations
-3. **Workspace** — One-time project initialisation
-4. **Color Space** — OCIO effect management
-5. **Status** — Live feedback on selected comps and last action
+2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
+3. **QC** — Technical QC queue for rendered exports
+4. **Actions** — Per-shot and batch operations
+5. **Workspace** — One-time project initialisation
+6. **Color Space** — OCIO effect management
+7. **Status** — Live feedback, plus Diagnostics and the last report
---
@@ -154,6 +156,28 @@ Constructs a full shot composite in the open AE project from the episode and sho
7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
+If any step fails, the status line names the stage and a copyable report opens —
+see [Diagnostics and logging](#diagnostics-and-logging).
+
+---
+
+## QC
+
+Technical QC on the **rendered EXRs**, not the H.264 preview — the point is
+catching mask slips, export glitches and metadata faults at full resolution.
+QC is per-export: a new export always needs new QC.
+
+| Control | Behaviour |
+|---|---|
+| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
+| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
+| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
+| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
+| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
+
+Passing an export is what makes it eligible for a delivery package on the web
+app's **Pipeline → Deliveries** page.
+
---
## Actions
@@ -402,3 +426,65 @@ The **Status** panel at the bottom of the UI shows two lines:
- **Line 2** — Result or progress message from the last operation.
Both lines are updated throughout batch operations so progress is visible in real time.
+
+Below them are two buttons:
+
+- **Diagnostics** — runs the environment self-test described below.
+- **Show Last Report** — re-opens the trace from the last button pressed.
+
+---
+
+## Diagnostics and logging
+
+Every button press is traced: what it tried, in what order, with the paths and
+HTTP codes it was working with. The trace goes to an in-memory buffer and to a
+log file, and it is shown automatically when something fails.
+
+**Log file:** `%APPDATA%/VFXReviewConnector/connector.log` (next to `config.json`).
+It is truncated once it passes 1 MB, so it can always be pasted into a message.
+The API token is never written to it — only whether one is set.
+
+### When Build Shot fails
+
+The status line now names the stage it failed at, and a scrollable report opens
+with the whole trace. The stages, and what each one means:
+
+| Stage | Meaning | Usual fix |
+|---|---|---|
+| `project` | No project selected in the panel | The project dropdown is empty because the server URL or token is wrong |
+| `shot lookup` | The server returned no shot record | Report shows the HTTP code: `0` = server unreachable, `401/403` = bad token, `404` = shot not in that project |
+| `configuration` | Footage Root is blank in Settings | Settings are **per-machine**; a fresh workstation starts blank |
+| `footage folder` | The show/episode folder does not exist here | Report names the deepest folder that *does* exist — usually an unmapped drive or a different drive letter/UNC on that machine |
+| `plate folders` | Folder exists, no plates for that shot code | Report lists the folders that *are* there, so a naming mismatch is visible |
+| `plate import` | Plate folders matched but held no importable EXRs | Report lists each folder's contents (`n .exr, n other files`) |
+| `comp creation` | After Effects threw while building the comps | Not a path or server problem — the AE error text is in the report |
+| `unexpected error` | Anything thrown that was not anticipated | Report carries the error text, line number and stack |
+
+Non-fatal conditions are traced too rather than passing silently: a missing
+`_SHOW LUT`, an overlay that could not be applied, a missing `__SHOTS/{group}`
+folder.
+
+### Diagnostics button
+
+Checks everything Build Shot depends on **without building anything**, so a
+workstation can be cleared before an artist is blocked. It verifies:
+
+1. `curl.exe` is present and runnable (every server call goes through it).
+2. Server URL and API token are set, and `GET /api/ext/projects` succeeds.
+3. Footage Root resolves to a folder that exists, and lists the plate folders
+ found for the currently selected shot.
+4. Export Root resolves to a folder that exists.
+5. The shot lookup succeeds for the currently selected shot.
+6. The open AE project has `UNG_VFX_OVERLAY`, `UNG_EXPORT_TEMPLATE`,
+ `_SHOW LUT`, and the `__SHOTS` / `_FOOTAGE_4K` / `_PRECOMPS` / `_PREVIEWS`
+ folders.
+
+Each failure is reported as a `PROBLEM` line with the offending path or HTTP
+response, and the report ends with a problem count.
+
+### Errors that used to be invisible
+
+ScriptUI silently swallows exceptions thrown inside a button handler, so a hard
+failure looked like a button that did nothing at all. Every handler is now
+wrapped: anything thrown is caught, logged with its line number and stack, shown
+in the status line, and surfaced in the report dialog.
diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
index 9732105..3b4fab6 100644
--- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx
+++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
-import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server } from "lucide-react";
+import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
@@ -112,6 +112,12 @@ export function PipelineQueueClient({ projects }: Props) {
+
+
+
+ Deliveries
+
+
diff --git a/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx
new file mode 100644
index 0000000..f3bd066
--- /dev/null
+++ b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/components/ui/use-toast";
+import { cn } from "@/lib/utils";
+import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
+
+interface ReadyExport {
+ exportId: string;
+ shotCode: string;
+ episode: string;
+ versionString: string;
+ fileCount: number | null;
+ totalBytes: number | null;
+ deliveryMovPath: string | null;
+ hasChecksum: boolean;
+ qc: { result: string; reviewer: string | null; at: string } | null;
+}
+
+interface ReadyResponse {
+ episodes: { episode: string; count: number; exports: ReadyExport[] }[];
+}
+
+interface PackageRow {
+ id: string;
+ packageNumber: number;
+ name: string;
+ episode: string;
+ status: string;
+ rootPath: string | null;
+ errorMessage: string | null;
+ projectName: string;
+ createdAt: string;
+ builtAt: string | null;
+ deliveredAt: string | null;
+ itemCount: number;
+ verifiedCount: number;
+ items: {
+ id: string;
+ shotCode: string;
+ versionString: string;
+ status: string;
+ fileCount: number | null;
+ linkMode: string | null;
+ errorMessage: string | null;
+ }[];
+}
+
+interface ProjectOption {
+ id: string;
+ name: string;
+ code: string;
+}
+
+function formatBytes(bytes: number | null): string {
+ if (bytes == null) return "—";
+ const gb = bytes / 1_073_741_824;
+ return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
+}
+
+export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [projectId, setProjectId] = useState("");
+ const [episode, setEpisode] = useState("");
+ const [building, setBuilding] = useState(false);
+
+ const { data: projects } = useQuery({
+ queryKey: ["pipeline-projects"],
+ queryFn: async () => {
+ const res = await fetch("/api/projects");
+ if (!res.ok) throw new Error("Failed to load projects");
+ const body = await res.json();
+ return (body.projects ?? body) as ProjectOption[];
+ },
+ });
+
+ const { data: ready, isLoading: readyLoading } = useQuery({
+ queryKey: ["delivery-ready", projectId],
+ queryFn: async () => {
+ const res = await fetch(`/api/deliveries/ready?projectId=${encodeURIComponent(projectId)}`);
+ if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load ready exports");
+ return res.json() as Promise;
+ },
+ enabled: Boolean(projectId),
+ });
+
+ const { data: packages } = useQuery({
+ queryKey: ["delivery-packages", projectId],
+ queryFn: async () => {
+ const res = await fetch(`/api/deliveries${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
+ if (!res.ok) throw new Error("Failed to load packages");
+ return res.json() as Promise<{ packages: PackageRow[] }>;
+ },
+ // A build is a queued job like any other — poll while one is in flight.
+ refetchInterval: 5000,
+ });
+
+ async function build(ep: string) {
+ if (!projectId) return;
+ setBuilding(true);
+ try {
+ const res = await fetch("/api/deliveries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId, episode: ep || null }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
+ } else {
+ toast({
+ title: `Package #${body.package.packageNumber} queued`,
+ description: `${body.package.itemCount} shots → ${body.package.rootPath}`,
+ });
+ queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
+ queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
+ }
+ } finally {
+ setBuilding(false);
+ }
+ }
+
+ async function packageAction(id: string, verb: "delivered" | "cancel") {
+ const res = await fetch(`/api/deliveries/${id}/${verb}`, { method: "POST" });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
+ }
+ queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
+ queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
+ }
+
+ const episodes = ready?.episodes ?? [];
+ const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
+
+ return (
+
+
+
+
+
+
+
+
Delivery packages
+
+
+ {/* Picker */}
+
+ {
+ setProjectId(e.target.value);
+ setEpisode("");
+ }}
+ className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
+ >
+ Select a project…
+ {(projects ?? []).map((p) => (
+
+ {p.name} ({p.code})
+
+ ))}
+
+ {episodes.length > 0 && (
+ setEpisode(e.target.value)}
+ className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
+ >
+ All episodes
+ {episodes.map((e) => (
+
+ {e.episode || "(no episode)"} — {e.count} ready
+
+ ))}
+
+ )}
+
+
+ {/* Ready to build */}
+ {projectId && (
+
+
+ Ready for delivery
+
+ {readyLoading &&
Loading…
}
+ {!readyLoading && shown.length === 0 && (
+
+ Nothing has passed QC yet. Exports appear here once QC passes them.
+
+ )}
+ {shown.map((ep) => (
+
+
+
+
+ {ep.episode ? `Episode ${ep.episode}` : "No episode"}
+
+ Ready: {ep.count} shots
+ {ep.exports.some((x) => !x.hasChecksum) && (
+
+ {ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
+
+ )}
+
+ {canBuild && (
+
build(ep.episode)} disabled={building}>
+
+ Build delivery
+
+ )}
+
+
+ {ep.exports.map((x) => (
+
+
+ {x.shotCode}
+
+ {x.versionString}
+ {x.fileCount ?? "?"} frames
+ {formatBytes(x.totalBytes)}
+ {!x.deliveryMovPath && no MOV }
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ {/* History — packages are permanent (§11.5) */}
+
+
Packages
+
+ {(packages?.packages ?? []).map((p) => (
+
+
+ #{p.packageNumber}
+ {p.name}
+
+ {statusLabel(p.status)}
+
+
+ {p.episode ? `episode ${p.episode} · ` : ""}
+ {p.verifiedCount}/{p.itemCount} shots
+
+ {new Date(p.createdAt).toLocaleDateString()}
+ {p.status === "BUILDING" && }
+
+ {canBuild && p.status === "READY" && (
+ {
+ e.preventDefault();
+ packageAction(p.id, "delivered");
+ }}
+ >
+
+ Mark delivered
+
+ )}
+ {canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
+ {
+ e.preventDefault();
+ packageAction(p.id, "cancel");
+ }}
+ >
+
+ Cancel
+
+ )}
+
+
+
+ {p.rootPath &&
{p.rootPath}
}
+ {p.errorMessage &&
{p.errorMessage}
}
+ {p.deliveredAt && (
+
+ Delivered {new Date(p.deliveredAt).toLocaleString()}
+
+ )}
+
+ {p.items.map((item) => (
+
+
+ {item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"}
+
+ {item.shotCode}
+ {item.versionString}
+ {item.fileCount != null && {item.fileCount}f }
+ {item.linkMode && {item.linkMode} }
+ {item.errorMessage && {item.errorMessage} }
+
+ ))}
+
+
+
+ ))}
+ {(packages?.packages ?? []).length === 0 && (
+
No packages built yet
+ )}
+
+
+
+ );
+}
diff --git a/app/(dashboard)/pipeline/deliveries/page.tsx b/app/(dashboard)/pipeline/deliveries/page.tsx
new file mode 100644
index 0000000..163cf92
--- /dev/null
+++ b/app/(dashboard)/pipeline/deliveries/page.tsx
@@ -0,0 +1,12 @@
+import { auth } from "@/auth";
+import { redirect } from "next/navigation";
+import { DeliveriesClient } from "./DeliveriesClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function DeliveriesPage() {
+ const session = await auth();
+ if (!session?.user) redirect("/login");
+ if (session.user.role === "CLIENT") redirect("/dashboard");
+ return ;
+}
diff --git a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx
index c0f6635..8b67811 100644
--- a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx
+++ b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx
@@ -66,9 +66,42 @@ interface ExportDetail {
note: string | null;
createdAt: string;
}[];
+ validations: {
+ id: string;
+ renderJobId: string;
+ checkName: string;
+ status: string;
+ expected: string | null;
+ actual: string | null;
+ message: string | null;
+ details: unknown;
+ createdAt: string;
+ }[];
+ qcReviews: {
+ id: string;
+ result: string;
+ notes: string | null;
+ reviewerName: string | null;
+ source: string;
+ createdAt: string;
+ }[];
+ deliveryItems: {
+ id: string;
+ status: string;
+ exrPath: string | null;
+ linkMode: string | null;
+ package: { id: string; name: string; episode: string; status: string; packageNumber: number };
+ }[];
};
}
+const CHECK_STYLES: Record = {
+ PASS: "text-emerald-400",
+ FAIL: "text-red-400",
+ WARN: "text-amber-400",
+ SKIPPED: "text-zinc-500",
+};
+
export function ExportDetailClient({ exportId }: { exportId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -189,6 +222,93 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
)}
+ {/* Validation results — newest run first */}
+ {e.validations.length > 0 && (
+
+
+ Validation
+ {(() => {
+ const latestRun = e.validations[e.validations.length - 1].renderJobId;
+ const rows = e.validations.filter((v) => v.renderJobId === latestRun);
+ const failed = rows.filter((v) => v.status === "FAIL").length;
+ const warned = rows.filter((v) => v.status === "WARN").length;
+ return (
+
+ {failed ? `${failed} failed` : warned ? `${warned} warning${warned > 1 ? "s" : ""}` : "all checks passed"}
+
+ );
+ })()}
+
+
+ {(() => {
+ const latestRun = e.validations[e.validations.length - 1].renderJobId;
+ // Failures first: the reason the export stopped should not need scrolling for.
+ const order = { FAIL: 0, WARN: 1, PASS: 2, SKIPPED: 3 } as Record
;
+ return e.validations
+ .filter((v) => v.renderJobId === latestRun)
+ .slice()
+ .sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
+ .map((v) => (
+
+
+ {v.status}
+
+ {v.checkName}
+
+ {v.message ??
+ (v.expected === v.actual ? v.actual : `expected ${v.expected ?? "—"}, got ${v.actual ?? "—"}`)}
+
+
+ ));
+ })()}
+
+
+ )}
+
+ {/* QC history — latest decision is the authoritative one */}
+ {e.qcReviews.length > 0 && (
+
+
QC
+
+ {e.qcReviews.map((q) => (
+
+
+
+ QC {q.result}
+
+ {q.reviewerName ?? "unknown reviewer"}
+ {q.source === "AE_PANEL" ? "AE panel" : "web"}
+ {new Date(q.createdAt).toLocaleString()}
+
+ {q.notes &&
{q.notes}
}
+
+ ))}
+
+
+ )}
+
+ {/* Delivery membership */}
+ {e.deliveryItems.length > 0 && (
+
+
Deliveries
+
+ {e.deliveryItems.map((d) => (
+
+
+ #{d.package.packageNumber} · {d.package.name}
+
+ episode {d.package.episode || "—"}
+ {d.package.status}
+
+ item {d.status.toLowerCase()}
+ {d.linkMode ? ` · ${d.linkMode}` : ""}
+
+
+ ))}
+
+
+ )}
+
{/* Render attempts */}
diff --git a/app/(dashboard)/pipeline/status-colors.ts b/app/(dashboard)/pipeline/status-colors.ts
index f1e7bec..c0e3b3a 100644
--- a/app/(dashboard)/pipeline/status-colors.ts
+++ b/app/(dashboard)/pipeline/status-colors.ts
@@ -18,6 +18,16 @@ export const EXPORT_STATUS_STYLES: Record = {
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
};
+export const DELIVERY_STATUS_STYLES: Record = {
+ DRAFT: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
+ QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
+ BUILDING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
+ READY: "bg-green-500/15 text-green-400 border-green-500/30",
+ DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
+ FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
+ CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
+};
+
export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
diff --git a/app/api/deliveries/[packageId]/cancel/route.ts b/app/api/deliveries/[packageId]/cancel/route.ts
new file mode 100644
index 0000000..3cfa5a4
--- /dev/null
+++ b/app/api/deliveries/[packageId]/cancel/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
+import { cancelDeliveryPackage } from "@/lib/render-pipeline/deliveries";
+
+// ── POST /api/deliveries/{id}/cancel ─────────────────────────────────────────
+//
+// Packages are never deleted (§11.5); a mistaken one is cancelled.
+
+export async function POST(
+ _req: NextRequest,
+ { params }: { params: Promise<{ packageId: string }> }
+) {
+ try {
+ const user = await requirePipelineAdmin();
+ const { packageId } = await params;
+ const result = await cancelDeliveryPackage(packageId, user.id);
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/deliveries/[packageId]/delivered/route.ts b/app/api/deliveries/[packageId]/delivered/route.ts
new file mode 100644
index 0000000..d91da3f
--- /dev/null
+++ b/app/api/deliveries/[packageId]/delivered/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
+import { markDelivered } from "@/lib/render-pipeline/deliveries";
+
+// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
+//
+// The folder has been uploaded to the client. Moves every packaged export to
+// DELIVERED — the end of the pipeline.
+
+export async function POST(
+ _req: NextRequest,
+ { params }: { params: Promise<{ packageId: string }> }
+) {
+ try {
+ const user = await requirePipelineAdmin();
+ const { packageId } = await params;
+ const result = await markDelivered(packageId, user.id);
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/deliveries/ready/route.ts b/app/api/deliveries/ready/route.ts
new file mode 100644
index 0000000..3fd039c
--- /dev/null
+++ b/app/api/deliveries/ready/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
+import { getReadyForDelivery } from "@/lib/render-pipeline/deliveries";
+
+// ── GET /api/deliveries/ready?projectId=&episode= (E18) ──────────────────────
+//
+// READY_FOR_DELIVERY exports grouped by episode — "Episode 101 — Ready: 18 shots".
+
+export async function GET(req: NextRequest) {
+ try {
+ await requirePipelineUser();
+ const { searchParams } = new URL(req.url);
+ const projectId = searchParams.get("projectId");
+ if (!projectId) {
+ return NextResponse.json({ error: "projectId is required" }, { status: 400 });
+ }
+ const result = await getReadyForDelivery(projectId, searchParams.get("episode"));
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/deliveries/route.ts b/app/api/deliveries/route.ts
new file mode 100644
index 0000000..ee2b987
--- /dev/null
+++ b/app/api/deliveries/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import { DeliveryStatus } from "@prisma/client";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
+import {
+ createDeliveryPackage,
+ createDeliverySchema,
+ listDeliveryPackages,
+} from "@/lib/render-pipeline/deliveries";
+
+// ── GET /api/deliveries (E17) · POST /api/deliveries (E16) ───────────────────
+
+export async function GET(req: NextRequest) {
+ try {
+ await requirePipelineUser();
+ const { searchParams } = new URL(req.url);
+ const status = searchParams.get("status");
+ const result = await listDeliveryPackages({
+ projectId: searchParams.get("projectId") ?? undefined,
+ episode: searchParams.get("episode") ?? undefined,
+ status: status ? (status as DeliveryStatus) : undefined,
+ });
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ // Building a package writes to the delivery volume and locks versions —
+ // producer/supervisor territory, not any signed-in user.
+ const user = await requirePipelineAdmin();
+
+ let raw: unknown;
+ try {
+ raw = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = createDeliverySchema.safeParse(raw);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid delivery request", details: parsed.error.issues }, { status: 422 });
+ }
+
+ const result = await createDeliveryPackage(parsed.data, user.id);
+ return NextResponse.json(result, { status: 201 });
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/ext/exports/[exportId]/qc/route.ts b/app/api/ext/exports/[exportId]/qc/route.ts
new file mode 100644
index 0000000..b2babb8
--- /dev/null
+++ b/app/api/ext/exports/[exportId]/qc/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc";
+
+// ── POST /api/ext/exports/{exportId}/qc (E4) ─────────────────────────────────
+//
+// QC Pass / QC Fail from the AE panel. PASS → READY_FOR_DELIVERY and nothing
+// else; FAIL → QC_FAILED and the comp task back to CHANGES (§10.2).
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ exportId: string }> }
+) {
+ if (!isExtAuthorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { exportId } = await params;
+
+ let raw: unknown;
+ try {
+ raw = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = qcSubmitSchema.safeParse(raw);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 });
+ }
+
+ try {
+ const result = await submitQCReview(exportId, parsed.data, { source: "AE_PANEL" });
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/ext/qc/queue/route.ts b/app/api/ext/qc/queue/route.ts
new file mode 100644
index 0000000..0f48a16
--- /dev/null
+++ b/app/api/ext/qc/queue/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { getQCQueue } from "@/lib/render-pipeline/qc";
+
+// ── GET /api/ext/qc/queue?projectCode=&episode= (E5) ─────────────────────────
+//
+// Exports sitting in READY_FOR_QC, oldest first, with everything the panel
+// needs to import the real EXR sequence for full-res QC.
+
+export async function GET(req: NextRequest) {
+ if (!isExtAuthorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ try {
+ const result = await getQCQueue({
+ projectCode: searchParams.get("projectCode"),
+ episode: searchParams.get("episode"),
+ limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined,
+ });
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/ext/render/jobs/[jobId]/complete-render/route.ts b/app/api/ext/render/jobs/[jobId]/complete-render/route.ts
index 91a0263..1e9ab77 100644
--- a/app/api/ext/render/jobs/[jobId]/complete-render/route.ts
+++ b/app/api/ext/render/jobs/[jobId]/complete-render/route.ts
@@ -5,8 +5,10 @@ import { completeRender } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
//
-// aerender finished with exit 0. Phase 2 interim: Export goes to a provisional
-// READY_FOR_QC; Phase 3 inserts VALIDATING between (§17.3).
+// aerender finished with exit 0 → Export goes to VALIDATING and the worker
+// keeps the job to run the checks (E12). With validation disabled the preview
+// stage is queued directly. `validationRequired` in the response tells the
+// worker which of the two happened.
export async function POST(
req: NextRequest,
diff --git a/app/api/ext/render/jobs/[jobId]/delivery/route.ts b/app/api/ext/render/jobs/[jobId]/delivery/route.ts
new file mode 100644
index 0000000..1fbe52d
--- /dev/null
+++ b/app/api/ext/render/jobs/[jobId]/delivery/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { deliveryReportSchema, reportDeliveryBuild } from "@/lib/render-pipeline/deliveries";
+
+// ── POST /api/ext/render/jobs/{jobId}/delivery ───────────────────────────────
+//
+// The worker finished (or gave up on) a DELIVERY_BUILD job: per-item results,
+// the manifest it wrote, and whether the package as a whole is good.
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ jobId: string }> }
+) {
+ if (!isExtAuthorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { jobId } = await params;
+
+ let raw: unknown;
+ try {
+ raw = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = deliveryReportSchema.safeParse(raw);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Delivery report is malformed", details: parsed.error.issues }, { status: 422 });
+ }
+
+ try {
+ const result = await reportDeliveryBuild(jobId, parsed.data);
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/ext/render/jobs/[jobId]/validation/route.ts b/app/api/ext/render/jobs/[jobId]/validation/route.ts
new file mode 100644
index 0000000..ae85563
--- /dev/null
+++ b/app/api/ext/render/jobs/[jobId]/validation/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { reportValidation, validationReportSchema } from "@/lib/render-pipeline/validation";
+
+// ── POST /api/ext/render/jobs/{jobId}/validation (E12) ───────────────────────
+//
+// The worker reports every check from one validation run in a single call.
+// Any FAIL stops the pipeline (Export → VALIDATION_FAILED); otherwise the
+// preview stage is queued.
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ jobId: string }> }
+) {
+ if (!isExtAuthorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const { jobId } = await params;
+
+ let raw: unknown;
+ try {
+ raw = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = validationReportSchema.safeParse(raw);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Validation report is malformed", details: parsed.error.issues },
+ { status: 422 }
+ );
+ }
+
+ try {
+ const result = await reportValidation(jobId, parsed.data);
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/render/exports/[exportId]/qc/route.ts b/app/api/render/exports/[exportId]/qc/route.ts
new file mode 100644
index 0000000..8325a52
--- /dev/null
+++ b/app/api/render/exports/[exportId]/qc/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
+import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc";
+
+// ── POST /api/render/exports/{exportId}/qc ───────────────────────────────────
+//
+// Web twin of E4 — same service, session auth. QC is not locked to AE (§10.2).
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ exportId: string }> }
+) {
+ try {
+ const user = await requirePipelineUser();
+ const { exportId } = await params;
+
+ let raw: unknown;
+ try {
+ raw = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = qcSubmitSchema.safeParse(raw);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 });
+ }
+
+ const result = await submitQCReview(exportId, parsed.data, {
+ source: "WEB",
+ userId: user.id,
+ name: user.name ?? user.email ?? null,
+ });
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/app/api/render/qc/queue/route.ts b/app/api/render/qc/queue/route.ts
new file mode 100644
index 0000000..83e20db
--- /dev/null
+++ b/app/api/render/qc/queue/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+import { toErrorResponse } from "@/lib/render-pipeline/errors";
+import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
+import { getQCQueue } from "@/lib/render-pipeline/qc";
+
+// ── GET /api/render/qc/queue?projectId=&episode= ─────────────────────────────
+//
+// Web twin of E5 for the QC page.
+
+export async function GET(req: NextRequest) {
+ try {
+ await requirePipelineUser();
+ const { searchParams } = new URL(req.url);
+ const result = await getQCQueue({
+ projectId: searchParams.get("projectId"),
+ episode: searchParams.get("episode"),
+ limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined,
+ });
+ return NextResponse.json(result);
+ } catch (err) {
+ const { body, status } = toErrorResponse(err);
+ return NextResponse.json(body, { status });
+ }
+}
diff --git a/lib/render-pipeline/config.ts b/lib/render-pipeline/config.ts
index 0229b34..63ce3df 100644
--- a/lib/render-pipeline/config.ts
+++ b/lib/render-pipeline/config.ts
@@ -12,7 +12,20 @@ export const PIPELINE_CONFIG_DEFAULTS = {
"render.stallTimeoutSeconds": 600,
"render.urgentPriorityThreshold": 20,
"render.outputRoot": "//SAN",
+ // Validation stage (§8). Disabling it restores the pre-Phase-3 flow
+ // (render → preview) without a redeploy, for a bad engine release.
+ "validation.enabled": "true",
"validation.sampleEvery": 25,
+ /** Per-check severity overrides, e.g. {"exr_type":"warn"} (§17.3). */
+ "validation.overrides": "{}",
+ /** Frames of head handle the plates carry; only used for the API timecode fallback (§8.1a). */
+ "validation.handleFrames": 8,
+ /**
+ * Studio-wide delivery defaults (§11.2). A project's own `deliveryConfig`
+ * column overrides these key by key; `{}` falls through to the built-ins in
+ * lib/render-pipeline/deliveries.ts.
+ */
+ "delivery.defaultConfig": "{}",
"preview.maxWidth": 1920,
// Preview stage (§9): headless AE rebuild of the render with slate/burn-ins
"preview.enabled": "true",
@@ -71,13 +84,23 @@ export async function getPreviewConfig() {
/** Worker-facing config bundle returned by E6 registration. */
export async function getWorkerConfig() {
- const [pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds] =
- await Promise.all([
- getConfigNumber("render.pollSeconds"),
- getConfigNumber("render.heartbeatSeconds"),
- getConfigNumber("render.leaseSeconds"),
- getConfigNumber("render.maxAttempts"),
- getConfigNumber("render.stallTimeoutSeconds"),
- ]);
- return { pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds };
+ const [
+ pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds,
+ validationEnabled, validationSampleEvery, validationHandleFrames,
+ ] = await Promise.all([
+ getConfigNumber("render.pollSeconds"),
+ getConfigNumber("render.heartbeatSeconds"),
+ getConfigNumber("render.leaseSeconds"),
+ getConfigNumber("render.maxAttempts"),
+ getConfigNumber("render.stallTimeoutSeconds"),
+ getConfigBoolean("validation.enabled"),
+ getConfigNumber("validation.sampleEvery"),
+ getConfigNumber("validation.handleFrames"),
+ ]);
+ return {
+ pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds,
+ // Whether the validation stage runs is the server's call, so switching it
+ // off is a config change here rather than a redeploy of every worker (§8).
+ validationEnabled, validationSampleEvery, validationHandleFrames,
+ };
}
diff --git a/lib/render-pipeline/deliveries.ts b/lib/render-pipeline/deliveries.ts
new file mode 100644
index 0000000..c7ac2dd
--- /dev/null
+++ b/lib/render-pipeline/deliveries.ts
@@ -0,0 +1,611 @@
+import { DeliveryStatus, Prisma } from "@prisma/client";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { PipelineError } from "./errors";
+import { applyTransition } from "./transitions";
+import { getConfigNumber, getConfigValue } from "./config";
+
+/**
+ * Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
+ * package and records it; a worker does the file work through the same queue
+ * and claim path as a render.
+ *
+ * Version locking: a package references Export rows and denormalises
+ * shotCode/versionString at creation, so a later re-render can never mutate
+ * delivery history.
+ */
+
+// ── Config (§11.2) ───────────────────────────────────────────────────────────
+
+export interface DeliveryConfig {
+ packageNameTemplate: string;
+ layout: { packageDir: string; shotDir: string; reportFile: string };
+ deliveryRoot: string;
+ includeMovs: boolean;
+ checksumAlgo: string;
+ reportFormats: string[];
+ copyMode: "auto" | "always-copy";
+}
+
+export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = {
+ 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"],
+ copyMode: "auto",
+};
+
+/**
+ * Project config → SystemConfig default → built-in. Merged shallowly per top
+ * level key so a project can override just `deliveryRoot` without restating the
+ * whole layout.
+ */
+export async function resolveDeliveryConfig(projectDeliveryConfig: unknown): Promise {
+ let systemDefault: Partial = {};
+ const raw = await getConfigValue("delivery.defaultConfig");
+ if (raw?.trim() && raw.trim() !== "{}") {
+ try {
+ systemDefault = JSON.parse(raw) as Partial;
+ } catch {
+ console.error("[render-pipeline] delivery.defaultConfig is not valid JSON — ignoring");
+ }
+ }
+
+ const project = (projectDeliveryConfig ?? {}) as Partial;
+ return {
+ ...DELIVERY_CONFIG_DEFAULT,
+ ...systemDefault,
+ ...project,
+ layout: {
+ ...DELIVERY_CONFIG_DEFAULT.layout,
+ ...(systemDefault.layout ?? {}),
+ ...(project.layout ?? {}),
+ },
+ };
+}
+
+export interface TokenValues {
+ showId: string;
+ episode: string;
+ yymmdd: string;
+ yyyymmdd: string;
+ packageName: string;
+ packageDir: string;
+ deliveryRoot: string;
+ shotCode: string;
+ versionString: string;
+ packageNumber: string;
+}
+
+export function applyTokens(template: string, tokens: Partial): string {
+ return template.replace(/\{(\w+)\}/g, (whole, key: string) => {
+ const value = (tokens as Record)[key];
+ return value === undefined ? whole : value;
+ });
+}
+
+export function dateTokens(date: Date) {
+ const yyyy = String(date.getFullYear());
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
+ const dd = String(date.getDate()).padStart(2, "0");
+ return { yymmdd: `${yyyy.slice(2)}${mm}${dd}`, yyyymmdd: `${yyyy}${mm}${dd}` };
+}
+
+// ── E18: what is ready ───────────────────────────────────────────────────────
+
+/**
+ * READY_FOR_DELIVERY exports grouped by episode. Only the newest ready export
+ * per shot is offered: an older ready version of the same shot is history, not
+ * a second thing to ship.
+ */
+export async function getReadyForDelivery(projectId: string, episode?: string | null) {
+ const rows = await db.export.findMany({
+ where: {
+ projectId,
+ status: "READY_FOR_DELIVERY",
+ ...(episode ? { shot: { episode } } : {}),
+ },
+ orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }],
+ include: {
+ shot: { select: { id: true, shotCode: true, episode: true } },
+ qcReviews: { orderBy: { createdAt: "desc" }, take: 1, select: { result: true, reviewerName: true, createdAt: true } },
+ },
+ });
+
+ const newestPerShot = new Map();
+ for (const row of rows) {
+ if (!newestPerShot.has(row.shotId)) newestPerShot.set(row.shotId, row);
+ }
+
+ const byEpisode = new Map[]>();
+ for (const row of newestPerShot.values()) {
+ const key = row.shot.episode ?? "";
+ if (!byEpisode.has(key)) byEpisode.set(key, []);
+ byEpisode.get(key)!.push(serialiseReady(row));
+ }
+
+ return {
+ episodes: [...byEpisode.entries()]
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([ep, exports]) => ({
+ episode: ep,
+ count: exports.length,
+ exports: exports.sort((a, b) => a.shotCode.localeCompare(b.shotCode)),
+ })),
+ };
+}
+
+function serialiseReady(row: {
+ id: string;
+ versionString: string;
+ outputDir: string;
+ exrFileCount: number | null;
+ exrTotalBytes: bigint | null;
+ deliveryMovPath: string | null;
+ checksum: string | null;
+ frameStart: number;
+ frameEnd: number;
+ shot: { id: string; shotCode: string; episode: string | null };
+ qcReviews: { result: string; reviewerName: string | null; createdAt: Date }[];
+}) {
+ return {
+ exportId: row.id,
+ shotId: row.shot.id,
+ shotCode: row.shot.shotCode,
+ episode: row.shot.episode ?? "",
+ versionString: row.versionString,
+ outputDir: row.outputDir,
+ frameStart: row.frameStart,
+ frameEnd: row.frameEnd,
+ fileCount: row.exrFileCount,
+ totalBytes: row.exrTotalBytes == null ? null : Number(row.exrTotalBytes),
+ deliveryMovPath: row.deliveryMovPath,
+ hasChecksum: row.checksum != null,
+ qc: row.qcReviews[0]
+ ? { result: row.qcReviews[0].result, reviewer: row.qcReviews[0].reviewerName, at: row.qcReviews[0].createdAt }
+ : null,
+ };
+}
+
+// ── E16: create a package ────────────────────────────────────────────────────
+
+export const createDeliverySchema = z.object({
+ projectId: z.string().min(1),
+ episode: z.string().max(50).optional().nullable(),
+ /** Omitted → every ready export in the episode. */
+ exportIds: z.array(z.string().min(1)).max(500).optional(),
+ options: z
+ .object({
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ name: z.string().max(120).optional(),
+ includeMovs: z.boolean().optional(),
+ copyMode: z.enum(["auto", "always-copy"]).optional(),
+ })
+ .optional(),
+});
+
+export type CreateDeliveryInput = z.infer;
+
+export async function createDeliveryPackage(input: CreateDeliveryInput, userId: string) {
+ const project = await db.project.findUnique({
+ where: { id: input.projectId },
+ select: { id: true, code: true, showId: true, deliveryConfig: true },
+ });
+ if (!project) throw new PipelineError(404, "Project not found");
+
+ const config = await resolveDeliveryConfig(project.deliveryConfig);
+ if (input.options?.includeMovs !== undefined) config.includeMovs = input.options.includeMovs;
+ if (input.options?.copyMode) config.copyMode = input.options.copyMode;
+
+ const buildDate = input.options?.date ? new Date(`${input.options.date}T00:00:00`) : new Date();
+ if (Number.isNaN(buildDate.getTime())) throw new PipelineError(422, "options.date is not a valid date");
+
+ const maxAttempts = await getConfigNumber("render.maxAttempts");
+
+ return db.$transaction(async (tx) => {
+ const exports = await tx.export.findMany({
+ where: {
+ projectId: project.id,
+ status: "READY_FOR_DELIVERY",
+ ...(input.exportIds?.length ? { id: { in: input.exportIds } } : {}),
+ ...(input.episode ? { shot: { episode: input.episode } } : {}),
+ },
+ include: { shot: { select: { id: true, shotCode: true, episode: true } } },
+ orderBy: { shotId: "asc" },
+ });
+
+ if (exports.length === 0) {
+ throw new PipelineError(422, "No READY_FOR_DELIVERY exports match — nothing to package");
+ }
+ if (input.exportIds?.length && exports.length !== input.exportIds.length) {
+ const found = new Set(exports.map((e) => e.id));
+ throw new PipelineError(
+ 409,
+ "Some exports are no longer ready for delivery (re-rendered or already packaged?)",
+ { missing: input.exportIds.filter((id) => !found.has(id)) }
+ );
+ }
+ // One export per shot: two versions of the same shot in one package is
+ // always a mistake, and it is cheaper to refuse than to ship it.
+ const shotIds = new Set();
+ for (const e of exports) {
+ if (shotIds.has(e.shotId)) {
+ throw new PipelineError(422, `Two versions of ${e.shot.shotCode} selected — pick one`);
+ }
+ shotIds.add(e.shotId);
+ }
+
+ const episode = input.episode ?? exports[0].shot.episode ?? "";
+ const dates = dateTokens(buildDate);
+ const showId = project.showId || project.code;
+
+ const lastNumber = await tx.deliveryPackage.aggregate({
+ where: { projectId: project.id },
+ _max: { packageNumber: true },
+ });
+ const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1;
+
+ const tokens: TokenValues = {
+ showId,
+ episode,
+ ...dates,
+ packageName: "",
+ packageDir: "",
+ deliveryRoot: config.deliveryRoot,
+ shotCode: "",
+ versionString: "",
+ packageNumber: String(packageNumber),
+ };
+ tokens.packageName = input.options?.name ?? applyTokens(config.packageNameTemplate, tokens);
+ tokens.packageDir = applyTokens(config.layout.packageDir, tokens);
+
+ const pkg = await tx.deliveryPackage.create({
+ data: {
+ projectId: project.id,
+ episode,
+ name: tokens.packageName,
+ packageNumber,
+ status: "QUEUED",
+ rootPath: tokens.packageDir,
+ // Snapshot: historic packages always show the rules they were built with.
+ configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue,
+ createdById: userId,
+ },
+ });
+
+ await tx.deliveryItem.createMany({
+ data: exports.map((e) => ({
+ packageId: pkg.id,
+ exportId: e.id,
+ shotId: e.shotId,
+ shotCode: e.shot.shotCode,
+ versionString: e.versionString,
+ })),
+ });
+
+ const manifest = await buildDeliveryJobManifest(tx, pkg.id);
+ const job = await tx.renderJob.create({
+ data: {
+ type: "DELIVERY_BUILD",
+ deliveryId: pkg.id,
+ attempt: 1,
+ maxAttempts,
+ // Deliveries are interactive: someone is waiting on the folder, and the
+ // work is file copying rather than hours of CPU, so it jumps renders.
+ priority: 10,
+ manifest: manifest as unknown as Prisma.InputJsonValue,
+ },
+ });
+
+ return {
+ package: {
+ id: pkg.id,
+ name: pkg.name,
+ packageNumber,
+ episode,
+ status: pkg.status,
+ rootPath: pkg.rootPath,
+ itemCount: exports.length,
+ },
+ renderJob: { id: job.id },
+ };
+ });
+}
+
+// ── The DELIVERY_BUILD manifest ──────────────────────────────────────────────
+
+export interface DeliveryManifestItem {
+ itemId: string;
+ exportId: string;
+ shotCode: string;
+ versionString: string;
+ sourceDir: string;
+ outputPattern: string;
+ frameStart: number;
+ frameEnd: number;
+ fileCount: number | null;
+ checksum: string | null;
+ deliveryMovPath: string | null;
+ shotDir: string;
+}
+
+export interface DeliveryManifest {
+ stage: "DELIVERY";
+ packageId: string;
+ packageName: string;
+ packageNumber: number;
+ episode: string;
+ packageDir: string;
+ reportFile: string;
+ includeMovs: boolean;
+ copyMode: string;
+ checksumAlgo: string;
+ reportFormats: string[];
+ items: DeliveryManifestItem[];
+}
+
+export async function buildDeliveryJobManifest(
+ tx: Prisma.TransactionClient,
+ packageId: string
+): Promise {
+ const pkg = await tx.deliveryPackage.findUniqueOrThrow({
+ where: { id: packageId },
+ include: {
+ items: { include: { export: true } },
+ project: { select: { code: true, showId: true } },
+ },
+ });
+
+ const snapshot = pkg.configSnapshot as unknown as DeliveryConfig & { tokens: TokenValues };
+ const baseTokens: TokenValues = {
+ ...snapshot.tokens,
+ packageName: pkg.name,
+ packageDir: pkg.rootPath ?? snapshot.tokens.packageDir,
+ episode: pkg.episode,
+ };
+
+ return {
+ stage: "DELIVERY",
+ packageId: pkg.id,
+ packageName: pkg.name,
+ packageNumber: pkg.packageNumber,
+ episode: pkg.episode,
+ packageDir: baseTokens.packageDir,
+ reportFile: applyTokens(snapshot.layout.reportFile, baseTokens),
+ includeMovs: snapshot.includeMovs,
+ copyMode: snapshot.copyMode ?? "auto",
+ checksumAlgo: snapshot.checksumAlgo,
+ reportFormats: snapshot.reportFormats,
+ items: pkg.items.map((item) => ({
+ itemId: item.id,
+ exportId: item.exportId,
+ shotCode: item.shotCode,
+ versionString: item.versionString,
+ sourceDir: item.export.outputDir,
+ outputPattern: item.export.outputPattern,
+ frameStart: item.export.frameStart,
+ frameEnd: item.export.frameEnd,
+ fileCount: item.export.exrFileCount,
+ checksum: item.export.checksum,
+ deliveryMovPath: snapshot.includeMovs ? item.export.deliveryMovPath : null,
+ shotDir: applyTokens(snapshot.layout.shotDir, {
+ ...baseTokens,
+ shotCode: item.shotCode,
+ versionString: item.versionString,
+ }),
+ })),
+ };
+}
+
+// ── Worker reporting ─────────────────────────────────────────────────────────
+
+export const deliveryReportSchema = z.object({
+ machineId: z.string().min(1),
+ items: z
+ .array(
+ z.object({
+ itemId: z.string().min(1),
+ status: z.enum(["COPIED", "VERIFIED", "FAILED"]),
+ exrPath: z.string().max(1000).optional().nullable(),
+ movPath: z.string().max(1000).optional().nullable(),
+ fileCount: z.number().int().nonnegative().optional(),
+ totalBytes: z.number().nonnegative().optional(),
+ checksum: z.string().max(200).optional().nullable(),
+ linkMode: z.enum(["hardlink", "copy"]).optional().nullable(),
+ errorMessage: z.string().max(2000).optional().nullable(),
+ })
+ )
+ .max(500),
+ manifest: z.unknown().optional(),
+ reportKey: z.string().max(500).optional().nullable(),
+ rootPath: z.string().max(1000).optional().nullable(),
+ errorMessage: z.string().max(2000).optional().nullable(),
+ /** false → the package failed as a whole. */
+ ok: z.boolean(),
+});
+
+export type DeliveryReport = z.infer;
+
+/**
+ * The worker finished (or gave up on) a DELIVERY_BUILD job. Exports move to
+ * PACKAGED only for items that actually verified — a package with a failed item
+ * is FAILED, and nothing about it is silently treated as delivered.
+ */
+export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) {
+ return db.$transaction(async (tx) => {
+ const job = await tx.renderJob.findUnique({ where: { id: jobId } });
+ if (!job) throw new PipelineError(404, "Render job not found");
+ if (job.machineId !== body.machineId) {
+ throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
+ }
+ if (!job.deliveryId) throw new PipelineError(422, "Job has no delivery package");
+
+ const pkg = await tx.deliveryPackage.findUnique({
+ where: { id: job.deliveryId },
+ include: { items: true },
+ });
+ if (!pkg) throw new PipelineError(404, "Delivery package not found");
+ if (pkg.status === "READY" || pkg.status === "DELIVERED") {
+ return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
+ }
+
+ await tx.renderJob.update({
+ where: { id: job.id },
+ data: {
+ status: body.ok ? "COMPLETED" : "FAILED",
+ progress: 1,
+ finishedAt: new Date(),
+ errorMessage: body.errorMessage ?? null,
+ },
+ });
+
+ for (const reported of body.items) {
+ await tx.deliveryItem.updateMany({
+ where: { id: reported.itemId, packageId: pkg.id },
+ data: {
+ status: reported.status,
+ exrPath: reported.exrPath ?? null,
+ movPath: reported.movPath ?? null,
+ fileCount: reported.fileCount ?? null,
+ totalBytes: reported.totalBytes == null ? null : BigInt(Math.round(reported.totalBytes)),
+ checksum: reported.checksum ?? null,
+ linkMode: reported.linkMode ?? null,
+ errorMessage: reported.errorMessage ?? null,
+ },
+ });
+ }
+
+ const verified = body.items.filter((i) => i.status === "VERIFIED");
+ const failed = body.items.filter((i) => i.status === "FAILED");
+ const success = body.ok && failed.length === 0 && verified.length === pkg.items.length;
+
+ if (success) {
+ for (const item of pkg.items) {
+ const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
+ if (exportRow?.status === "READY_FOR_DELIVERY") {
+ await applyTransition(tx, exportRow, "PACKAGED", {
+ type: "WORKER",
+ id: body.machineId,
+ note: `Packaged into #${pkg.packageNumber} ${pkg.name}`,
+ });
+ }
+ }
+ }
+
+ const updated = await tx.deliveryPackage.update({
+ where: { id: pkg.id },
+ data: {
+ status: success ? "READY" : "FAILED",
+ builtAt: new Date(),
+ rootPath: body.rootPath ?? pkg.rootPath,
+ manifest: (body.manifest ?? undefined) as Prisma.InputJsonValue | undefined,
+ reportKey: body.reportKey ?? pkg.reportKey,
+ errorMessage: success
+ ? null
+ : (body.errorMessage ??
+ (failed.length ? `${failed.length} item(s) failed: ${failed.map((f) => f.itemId).join(", ")}` : "Build did not complete")),
+ },
+ });
+
+ return {
+ package: { id: updated.id, status: updated.status },
+ verified: verified.length,
+ failed: failed.length,
+ alreadyApplied: false,
+ };
+ });
+}
+
+// ── E17 / E19 / cancel ───────────────────────────────────────────────────────
+
+export async function listDeliveryPackages(filter: { projectId?: string; episode?: string; status?: DeliveryStatus }) {
+ const packages = await db.deliveryPackage.findMany({
+ where: {
+ ...(filter.projectId ? { projectId: filter.projectId } : {}),
+ ...(filter.episode ? { episode: filter.episode } : {}),
+ ...(filter.status ? { status: filter.status } : {}),
+ },
+ orderBy: { createdAt: "desc" },
+ include: {
+ items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } },
+ project: { select: { id: true, name: true, code: true } },
+ },
+ });
+
+ return {
+ packages: packages.map((p) => ({
+ id: p.id,
+ packageNumber: p.packageNumber,
+ name: p.name,
+ episode: p.episode,
+ status: p.status,
+ rootPath: p.rootPath,
+ errorMessage: p.errorMessage,
+ projectName: p.project.name,
+ projectCode: p.project.code,
+ createdAt: p.createdAt,
+ builtAt: p.builtAt,
+ deliveredAt: p.deliveredAt,
+ itemCount: p.items.length,
+ verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length,
+ items: p.items,
+ })),
+ };
+}
+
+/** E19 — the human uploaded the folder to the client and is confirming it. */
+export async function markDelivered(packageId: string, userId: string) {
+ return db.$transaction(async (tx) => {
+ const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
+ if (!pkg) throw new PipelineError(404, "Delivery package not found");
+ if (pkg.status === "DELIVERED") {
+ return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
+ }
+ if (pkg.status !== "READY") {
+ throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
+ }
+
+ for (const item of pkg.items) {
+ const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
+ if (exportRow?.status === "PACKAGED") {
+ await applyTransition(tx, exportRow, "DELIVERED", {
+ type: "USER",
+ id: userId,
+ note: `Delivered in #${pkg.packageNumber} ${pkg.name}`,
+ });
+ }
+ }
+
+ const updated = await tx.deliveryPackage.update({
+ where: { id: pkg.id },
+ data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId },
+ });
+ return { package: { id: updated.id, status: updated.status }, alreadyApplied: false };
+ });
+}
+
+/** A mistaken package is cancelled, never deleted (§11.5). */
+export async function cancelDeliveryPackage(packageId: string, userId: string) {
+ return db.$transaction(async (tx) => {
+ const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } });
+ if (!pkg) throw new PipelineError(404, "Delivery package not found");
+ if (!["DRAFT", "QUEUED", "FAILED"].includes(pkg.status)) {
+ throw new PipelineError(409, `Package is ${pkg.status} — only DRAFT, QUEUED or FAILED packages can be cancelled`);
+ }
+ await tx.renderJob.updateMany({
+ where: { deliveryId: pkg.id, status: { in: ["QUEUED", "CLAIMED", "RUNNING"] } },
+ data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Delivery package cancelled" },
+ });
+ const updated = await tx.deliveryPackage.update({
+ where: { id: pkg.id },
+ data: { status: "CANCELLED", errorMessage: `Cancelled by user ${userId}` },
+ });
+ return { package: { id: updated.id, status: updated.status } };
+ });
+}
diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts
index c6f1a0f..7d541b3 100644
--- a/lib/render-pipeline/exports.ts
+++ b/lib/render-pipeline/exports.ts
@@ -1,7 +1,7 @@
import { ExportStatus, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
-import { renderManifestSchema, type RenderManifest } from "./manifest";
+import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest";
import { applyTransition, type TransitionActor } from "./transitions";
import { getConfigNumber, getConfigValue } from "./config";
@@ -227,6 +227,9 @@ export async function createExport(input: CreateExportInput) {
},
});
+ // Plate provenance is recorded at queue time so a suspect export is
+ // visible in the queue before it renders (§8.1a).
+ const provenance = describePlateProvenance(resolvedManifest);
await tx.exportEvent.create({
data: {
exportId: created.id,
@@ -234,7 +237,9 @@ export async function createExport(input: CreateExportInput) {
toStatus: "QUEUED",
actorType: "USER",
actorId: submittedBy?.id ?? null,
- note: `Queue Export ${versionString}${input.force ? " (force)" : ""}`,
+ note:
+ `Queue Export ${versionString}${input.force ? " (force)" : ""} — ` +
+ `${provenance.ok ? "" : "WARNING: "}${provenance.note}`,
},
});
@@ -333,10 +338,22 @@ export async function getExportDetail(exportId: string) {
include: { machine: { select: { id: true, name: true } } },
},
events: { orderBy: { createdAt: "asc" } },
+ validations: { orderBy: { createdAt: "asc" } },
+ qcReviews: { orderBy: { createdAt: "desc" } },
+ deliveryItems: {
+ include: {
+ package: { select: { id: true, name: true, episode: true, status: true, packageNumber: true } },
+ },
+ },
},
});
if (!exp) throw new PipelineError(404, "Export not found");
- return { export: serializeExport(exp) };
+ return {
+ export: {
+ ...serializeExport(exp),
+ deliveryItems: exp.deliveryItems.map((i) => ({ ...i, totalBytes: i.totalBytes == null ? null : Number(i.totalBytes) })),
+ },
+ };
}
/**
diff --git a/lib/render-pipeline/jobs.ts b/lib/render-pipeline/jobs.ts
index d8a5dba..1c6ff33 100644
--- a/lib/render-pipeline/jobs.ts
+++ b/lib/render-pipeline/jobs.ts
@@ -74,6 +74,14 @@ export async function claimNextJob(machineId: string, types?: string[]) {
});
}
+ // Delivery packages have their own small lifecycle on the same queue.
+ if (job.deliveryId) {
+ await db.deliveryPackage.updateMany({
+ where: { id: job.deliveryId, status: "QUEUED" },
+ data: { status: "BUILDING" },
+ });
+ }
+
return {
job: {
id: job.id,
@@ -232,8 +240,11 @@ export async function reportFail(
/**
* E11 — aerender finished OK.
- * Phase 2 interim (§17.3): transitions straight to a provisional READY_FOR_QC;
- * Phase 3 will insert VALIDATING between (see transitions.ts note).
+ *
+ * Hands off to validation (§8) when it is enabled: the worker then runs the
+ * checks and reports them via E12, which queues the preview stage. With
+ * validation disabled the old direct handoff to the preview stage is used, so
+ * the kill switch restores the pre-Phase-3 flow exactly.
*/
export async function completeRender(
jobId: string,
@@ -255,12 +266,17 @@ export async function completeRender(
throw new PipelineError(409, `Job is ${job.status} — contradictory complete report rejected`);
}
+ // With validation to follow, the job deliberately stays open and keeps its
+ // lease: a worker that dies mid-validation is then reaped and retried like
+ // any other stalled job, instead of leaving the Export stuck in VALIDATING.
+ const validationEnabled = job.export ? await getConfigBoolean("validation.enabled") : false;
+
await tx.renderJob.update({
where: { id: job.id },
data: {
- status: "COMPLETED",
+ status: validationEnabled ? "RUNNING" : "COMPLETED",
progress: 1,
- finishedAt: new Date(),
+ ...(validationEnabled ? {} : { finishedAt: new Date() }),
exitCode: 0,
logFileKey: body.logFileKey ?? job.logFileKey,
logTail: body.logTail?.slice(-20000) ?? job.logTail,
@@ -277,8 +293,25 @@ export async function completeRender(
...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
};
- // The EXR render is only the first stage: hand off to the preview stage
- // (headless AE rebuild → delivery MOV + review MP4) when it is enabled.
+ // The EXR render is only the first stage. Validation comes next when
+ // enabled; the worker reports its results via E12 on the same job.
+ if (validationEnabled) {
+ const exp = await applyTransition(tx, job.export, "VALIDATING", {
+ type: "WORKER",
+ id: machineId,
+ note: `Render complete in ${body.renderSeconds ?? "?"}s — validating`,
+ }, stats);
+ return {
+ job: { id: job.id, status: "RUNNING" },
+ export: { id: exp.id, status: exp.status },
+ previewJob: null,
+ validationRequired: true,
+ alreadyApplied: false,
+ };
+ }
+
+ // Validation disabled: hand off to the preview stage (headless AE rebuild
+ // → delivery MOV + review MP4) exactly as before Phase 3.
const previewEnabled = await getConfigBoolean("preview.enabled");
if (previewEnabled) {
const manifest = await buildPreviewManifest(exportRow);
diff --git a/lib/render-pipeline/manifest.ts b/lib/render-pipeline/manifest.ts
index cb47106..dcfe141 100644
--- a/lib/render-pipeline/manifest.ts
+++ b/lib/render-pipeline/manifest.ts
@@ -9,6 +9,20 @@ import { z } from "zod";
* generates them from the shot's exrOutput convention and the NEW version
* number it decides (§6.4 E1).
*/
+/**
+ * A plate element imported into the comp, resolved by the panel from the comp's
+ * own footage layers — not re-derived from a path convention (§6.2).
+ */
+export const plateRefSchema = z.object({
+ element: z.string().optional(),
+ /** First frame of the sequence on disk; the timecode reference file. */
+ path: z.string().min(1),
+ frameStart: z.number().int().optional(),
+ frameEnd: z.number().int().optional(),
+});
+
+export type PlateRef = z.infer;
+
export const renderManifestSchema = z
.object({
manifestVersion: z.number().int().min(1).default(1),
@@ -37,6 +51,30 @@ export const renderManifestSchema = z
timecodeStart: z.string().optional(),
})
.optional(),
+ /**
+ * Plate provenance for the timecode check (§8.1a). Optional so
+ * manifestVersion 1 panels keep working — validation then degrades to a
+ * worker-derived plate, and finally to the API value as a WARN.
+ */
+ source: z
+ .object({
+ /**
+ * The element the comp's start time was anchored to (the panel's
+ * primaryFootage). A shot's elements carry unrelated camera timecodes,
+ * so the timing reference must be named rather than guessed.
+ */
+ timingPlate: plateRefSchema.optional(),
+ plates: z.array(plateRefSchema).optional(),
+ /**
+ * Verbatim from the panel's start-frame resolution: "footage metadata"
+ * (trustworthy) or "API timecode …" (fallback, suspect). Known at queue
+ * time, so a suspect export is flagged before it burns render hours.
+ */
+ startSource: z.string().optional(),
+ startFrames: z.number().int().optional(),
+ handleFrames: z.number().int().optional(),
+ })
+ .optional(),
preview: z
.object({
template: z.string().optional(),
@@ -50,3 +88,29 @@ export const renderManifestSchema = z
});
export type RenderManifest = z.infer;
+
+/**
+ * Queue-time read of the manifest's plate provenance (§8.1a). A comp whose
+ * start time came from the API rather than the plate's own metadata is very
+ * likely to fail the timecode check — worth recording before the render burns
+ * machine hours, not after.
+ */
+export function describePlateProvenance(manifest: RenderManifest): { ok: boolean; note: string } {
+ const src = manifest.source;
+ if (!src?.timingPlate?.path) {
+ return {
+ ok: false,
+ note: "no timing plate in manifest — timecode will be checked against a worker-derived plate",
+ };
+ }
+ // Only warn on a start source we positively know to be the API fallback. An
+ // absent startSource means the comp predates this session (an AEP opened from
+ // disk), which is normal — the worker still does the authoritative check.
+ if (src.startSource && /\bAPI\b/i.test(src.startSource)) {
+ return {
+ ok: false,
+ note: `comp start came from "${src.startSource}", not the plate's own metadata — timecode check likely to fail`,
+ };
+ }
+ return { ok: true, note: `timing plate ${src.timingPlate.element ?? src.timingPlate.path}` };
+}
diff --git a/lib/render-pipeline/qc.ts b/lib/render-pipeline/qc.ts
new file mode 100644
index 0000000..d2cc348
--- /dev/null
+++ b/lib/render-pipeline/qc.ts
@@ -0,0 +1,173 @@
+import { Prisma, QCResult } from "@prisma/client";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { PipelineError } from "./errors";
+import { applyTransition } from "./transitions";
+
+/**
+ * QC workflow (RenderPipeline2 §10) — technical/final-delivery QC on shots the
+ * client has usually already approved. One service, two front doors: the AE
+ * panel (API key) and the web QC page (session).
+ *
+ * PASS means "ready to upload" and nothing else: it never shares to the client
+ * portal, never changes approval state, never creates client-visible media
+ * (§10.0). FAIL does move the comp task to CHANGES — the shot genuinely needs
+ * rework, and the existing REVISIONS derivation should surface that.
+ */
+
+export const qcSubmitSchema = z.object({
+ result: z.enum(["PASS", "FAIL"]),
+ notes: z.string().max(4000).optional().nullable(),
+ /** Panel sends the artist's email; web callers pass the session user instead. */
+ reviewerEmail: z.string().email().optional().nullable(),
+ reviewerName: z.string().max(200).optional().nullable(),
+});
+
+export type QCSubmitInput = z.infer;
+
+export interface QCActor {
+ source: "AE_PANEL" | "WEB";
+ userId?: string | null;
+ name?: string | null;
+}
+
+/** Statuses a QC decision can be recorded from. */
+const QC_ELIGIBLE = ["READY_FOR_QC", "QC_FAILED", "READY_FOR_DELIVERY"] as const;
+
+export async function submitQCReview(exportId: string, input: QCSubmitInput, actor: QCActor) {
+ const reviewer = input.reviewerEmail
+ ? await db.user.findUnique({ where: { email: input.reviewerEmail }, select: { id: true, name: true } })
+ : null;
+ const reviewedById = actor.userId ?? reviewer?.id ?? null;
+ const reviewerName = actor.name ?? reviewer?.name ?? input.reviewerName ?? input.reviewerEmail ?? null;
+
+ return db.$transaction(async (tx) => {
+ const exportRow = await tx.export.findUnique({
+ where: { id: exportId },
+ include: { shot: { select: { shotCode: true } } },
+ });
+ if (!exportRow) throw new PipelineError(404, "Export not found");
+
+ if (!QC_ELIGIBLE.includes(exportRow.status as (typeof QC_ELIGIBLE)[number])) {
+ throw new PipelineError(
+ 409,
+ `Export is ${exportRow.status} — QC can only be recorded on ${QC_ELIGIBLE.join(", ")}`
+ );
+ }
+
+ const result = input.result as QCResult;
+ const review = await tx.qCReview.create({
+ data: {
+ exportId,
+ result,
+ notes: input.notes?.trim() || null,
+ reviewedById,
+ reviewerName,
+ source: actor.source,
+ },
+ });
+
+ const who = reviewerName ?? "unknown reviewer";
+ let taskUpdated: { id: string; status: string } | null = null;
+
+ if (result === "PASS") {
+ // Re-passing an export that is already READY_FOR_DELIVERY is a no-op
+ // transition-wise; the QCReview row is still the record of the decision.
+ const updated =
+ exportRow.status === "READY_FOR_DELIVERY"
+ ? exportRow
+ : await applyTransition(tx, exportRow, "READY_FOR_DELIVERY", {
+ type: "USER",
+ id: reviewedById,
+ note: `QC PASS by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`,
+ });
+
+ return {
+ export: { id: updated.id, status: updated.status },
+ review: { id: review.id, result, createdAt: review.createdAt },
+ task: null,
+ };
+ }
+
+ // FAIL — the shot needs rework, so the comp task goes back to CHANGES.
+ const updated =
+ exportRow.status === "QC_FAILED"
+ ? exportRow
+ : await applyTransition(tx, exportRow, "QC_FAILED", {
+ type: "USER",
+ id: reviewedById,
+ note: `QC FAIL by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`,
+ });
+
+ if (exportRow.taskId) {
+ const task = await tx.task.findUnique({ where: { id: exportRow.taskId }, select: { id: true, status: true } });
+ if (task && task.status !== "CHANGES") {
+ await tx.task.update({ where: { id: task.id }, data: { status: "CHANGES" } });
+ taskUpdated = { id: task.id, status: "CHANGES" };
+ }
+ }
+
+ return {
+ export: { id: updated.id, status: updated.status },
+ review: { id: review.id, result, createdAt: review.createdAt },
+ task: taskUpdated,
+ };
+ });
+}
+
+export interface QCQueueFilter {
+ projectCode?: string | null;
+ projectId?: string | null;
+ episode?: string | null;
+ limit?: number;
+}
+
+/**
+ * E5 — everything waiting for a human. QC_FAILED is deliberately excluded: a
+ * failed export is the artist's problem until they queue a new version.
+ */
+export async function getQCQueue(filter: QCQueueFilter) {
+ const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
+
+ const where: Prisma.ExportWhereInput = {
+ status: "READY_FOR_QC",
+ ...(filter.projectId ? { projectId: filter.projectId } : {}),
+ ...(filter.projectCode ? { shot: { project: { code: filter.projectCode } } } : {}),
+ ...(filter.episode
+ ? { shot: { ...(filter.projectCode ? { project: { code: filter.projectCode } } : {}), episode: filter.episode } }
+ : {}),
+ };
+
+ const rows = await db.export.findMany({
+ where,
+ orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC
+ take: limit,
+ include: {
+ shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } },
+ validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } },
+ },
+ });
+
+ return {
+ queue: rows.map((e) => ({
+ exportId: e.id,
+ shotCode: e.shot.shotCode,
+ episode: e.shot.episode,
+ scene: e.shot.scene,
+ projectCode: e.shot.project.code,
+ versionString: e.versionString,
+ readySince: e.statusChangedAt,
+ // Everything the panel needs to import the actual EXRs for full-res QC
+ outputDir: e.outputDir,
+ outputPattern: e.outputPattern,
+ frameStart: e.frameStart,
+ frameEnd: e.frameEnd,
+ fps: e.fps,
+ deliveryMovPath: e.deliveryMovPath,
+ thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null,
+ previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null,
+ // Warnings the automated pass raised — the human should look harder at these
+ warnings: e.validations.map((v) => ({ checkName: v.checkName, status: v.status, message: v.message })),
+ })),
+ };
+}
diff --git a/lib/render-pipeline/reaper.ts b/lib/render-pipeline/reaper.ts
index 68b1fb4..6678ef3 100644
--- a/lib/render-pipeline/reaper.ts
+++ b/lib/render-pipeline/reaper.ts
@@ -29,6 +29,21 @@ export async function reapExpiredLeases() {
where: { id: job.id },
data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" },
});
+
+ // A delivery build that died leaves a half-written folder, so it is
+ // failed rather than silently retried: someone should look at what
+ // landed on the delivery volume before it is rebuilt.
+ if (job.deliveryId) {
+ await tx.deliveryPackage.updateMany({
+ where: { id: job.deliveryId, status: "BUILDING" },
+ data: {
+ status: "FAILED",
+ errorMessage: "Worker became unreachable mid-build — check the package folder before rebuilding",
+ },
+ });
+ return;
+ }
+
if (!job.export) return;
if (job.attempt < job.maxAttempts) {
@@ -42,16 +57,19 @@ export async function reapExpiredLeases() {
manifest: job.manifest as Prisma.InputJsonValue,
},
});
- if (job.export.status === "RENDERING") {
+ // VALIDATING counts too: the worker holds the same job through the
+ // validation stage, so dying there means the render must be redone —
+ // nothing was established about the EXRs either way.
+ if (["RENDERING", "VALIDATING"].includes(job.export.status)) {
await applyTransition(tx, job.export, "QUEUED", {
type: "SYSTEM",
- note: `Lease expired on attempt ${job.attempt} — requeued as attempt ${next.attempt}`,
+ note: `Lease expired on attempt ${job.attempt} during ${job.export.status} — requeued as attempt ${next.attempt}`,
});
}
- } else if (job.export.status === "RENDERING") {
+ } else if (["RENDERING", "VALIDATING"].includes(job.export.status)) {
await applyTransition(tx, job.export, "RENDER_FAILED", {
type: "SYSTEM",
- note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} — no retries remain`,
+ note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} during ${job.export.status} — no retries remain`,
});
}
});
diff --git a/lib/render-pipeline/transitions.ts b/lib/render-pipeline/transitions.ts
index 0bd2957..259e49a 100644
--- a/lib/render-pipeline/transitions.ts
+++ b/lib/render-pipeline/transitions.ts
@@ -5,22 +5,24 @@ import { PipelineError } from "./errors";
* Export lifecycle state machine (RenderPipeline2 §4).
* The server is the sole authority on state; workers *request* transitions.
*
- * NOTE (Phase 2 interim): RENDERING → READY_FOR_QC is temporarily legal.
- * Phase 3 inserts VALIDATING between them (§17.3) — remove READY_FOR_QC from
- * RENDERING's list when the validation engine ships.
+ * RENDERING → GENERATING_PREVIEW / READY_FOR_QC are the `validation.enabled=false`
+ * paths (the Phase 3 kill switch, §8), not interim edges: with validation on,
+ * a completed render always goes RENDERING → VALIDATING first.
*/
export const EXPORT_TRANSITIONS: Record = {
QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only
RENDERING: [
"VALIDATING",
- "GENERATING_PREVIEW", // interim: preview stage runs before validation exists
+ "GENERATING_PREVIEW", // validation disabled
"RENDER_FAILED",
"QUEUED", // lease expiry / retryable fail with attempts remaining (T5)
"CANCELLED",
- "READY_FOR_QC", // preview disabled / manual mark-done
+ "READY_FOR_QC", // preview + validation disabled / manual mark-done
],
RENDER_FAILED: ["QUEUED", "SUPERSEDED"],
- VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW"],
+ // QUEUED/RENDER_FAILED: the worker died *during* validation, so the render is
+ // retried — nothing was learned about the EXRs either way (reaper).
+ VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW", "READY_FOR_QC", "QUEUED", "RENDER_FAILED", "CANCELLED"],
VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"],
GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"],
PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
diff --git a/lib/render-pipeline/validation.ts b/lib/render-pipeline/validation.ts
new file mode 100644
index 0000000..1afa674
--- /dev/null
+++ b/lib/render-pipeline/validation.ts
@@ -0,0 +1,284 @@
+import { Prisma, ValidationStatus } from "@prisma/client";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { PipelineError } from "./errors";
+import { applyTransition } from "./transitions";
+import { getConfigBoolean, getConfigValue, getPreviewConfig } from "./config";
+import { buildPreviewManifest, createPreviewJob } from "./preview";
+
+/**
+ * Validation engine results (RenderPipeline2 §8). The worker runs the checks —
+ * it has the files — and reports every result in one call. The server decides
+ * what they mean: it is the only thing that moves an Export's state.
+ */
+
+export const validationCheckSchema = z.object({
+ checkName: z.string().min(1).max(64),
+ status: z.enum(["PASS", "FAIL", "WARN", "SKIPPED"]),
+ expected: z.string().max(500).optional().nullable(),
+ actual: z.string().max(500).optional().nullable(),
+ message: z.string().max(2000).optional().nullable(),
+ details: z.unknown().optional(),
+});
+
+export const validationReportSchema = z.object({
+ machineId: z.string().min(1),
+ checks: z.array(validationCheckSchema).min(1).max(200),
+ /** Sequence-level digest reused by the delivery builder for copy verification. */
+ checksum: z.string().max(200).optional(),
+ checksumAlgo: z.string().max(32).optional(),
+ exrFileCount: z.number().int().nonnegative().optional(),
+ exrTotalBytes: z.number().nonnegative().optional(),
+ validationSeconds: z.number().nonnegative().optional(),
+});
+
+export type ValidationReport = z.infer;
+
+/**
+ * Per-check severity overrides, e.g. `{"exr_type":"warn"}` (§17.3 risk row).
+ * A false FAIL blocks an artist, so a check can be demoted without a redeploy.
+ * `timecode_plate_match` is deliberately overridable too — but demoting it
+ * should be a considered, temporary act: it is the check the engine exists for.
+ */
+async function getSeverityOverrides(): Promise> {
+ const raw = await getConfigValue("validation.overrides");
+ if (!raw?.trim()) return {};
+ try {
+ const parsed = JSON.parse(raw) as Record;
+ const out: Record = {};
+ for (const [check, level] of Object.entries(parsed)) {
+ const up = String(level).toUpperCase();
+ if (up === "PASS" || up === "FAIL" || up === "WARN" || up === "SKIPPED") {
+ out[check] = up as ValidationStatus;
+ }
+ }
+ return out;
+ } catch {
+ console.error("[render-pipeline] validation.overrides is not valid JSON — ignoring");
+ return {};
+ }
+}
+
+function summarise(checks: { checkName: string; status: ValidationStatus }[]): string {
+ const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIPPED: 0 };
+ for (const c of checks) counts[c.status] += 1;
+ return `${counts.PASS} passed, ${counts.FAIL} failed, ${counts.WARN} warned, ${counts.SKIPPED} skipped`;
+}
+
+/**
+ * E12 — the worker reports every check from one validation run.
+ *
+ * Any FAIL stops the pipeline (§8.2): Export → VALIDATION_FAILED with the exact
+ * reasons queryable as rows. Otherwise the preview stage is queued, exactly as
+ * a successful render used to do directly.
+ */
+export async function reportValidation(jobId: string, body: ValidationReport) {
+ const overrides = await getSeverityOverrides();
+ const previewEnabled = (await getPreviewConfig()).enabled;
+
+ const effective = body.checks.map((c) => ({
+ ...c,
+ status: (overrides[c.checkName] ?? c.status) as ValidationStatus,
+ overridden: overrides[c.checkName] != null && overrides[c.checkName] !== c.status,
+ }));
+
+ return db.$transaction(async (tx) => {
+ const job = await tx.renderJob.findUnique({
+ where: { id: jobId },
+ include: { export: true },
+ });
+ if (!job) throw new PipelineError(404, "Render job not found");
+ if (job.machineId !== body.machineId) {
+ throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
+ }
+ const exportRow = job.export;
+ if (!exportRow) throw new PipelineError(422, "Job has no export to validate");
+
+ // Replayed report (worker retried the call after a network cut): the rows
+ // for this attempt already exist, so don't duplicate them or re-transition.
+ const existing = await tx.validationResult.count({
+ where: { exportId: exportRow.id, renderJobId: job.id },
+ });
+ if (existing > 0) {
+ return {
+ export: { id: exportRow.id, status: exportRow.status },
+ results: existing,
+ failed: exportRow.status === "VALIDATION_FAILED",
+ alreadyApplied: true,
+ };
+ }
+
+ if (exportRow.status !== "VALIDATING") {
+ throw new PipelineError(
+ 409,
+ `Export is ${exportRow.status} — validation results are only accepted while VALIDATING`
+ );
+ }
+
+ // The render job was held open through validation (see completeRender) so a
+ // worker dying here is still reaped; the work is finished now.
+ await tx.renderJob.update({
+ where: { id: job.id },
+ data: { status: "COMPLETED", progress: 1, finishedAt: new Date() },
+ });
+
+ await tx.validationResult.createMany({
+ data: effective.map((c) => ({
+ exportId: exportRow.id,
+ renderJobId: job.id,
+ checkName: c.checkName,
+ status: c.status,
+ expected: c.expected ?? null,
+ actual: c.actual ?? null,
+ message: c.overridden
+ ? `${c.message ?? "check reported " + c.status} [severity overridden by config]`
+ : (c.message ?? null),
+ details: (c.details ?? undefined) as Prisma.InputJsonValue | undefined,
+ })),
+ });
+
+ const failures = effective.filter((c) => c.status === "FAIL");
+ const stats = {
+ ...(body.exrFileCount != null ? { exrFileCount: body.exrFileCount } : {}),
+ ...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
+ ...(body.checksum ? { checksum: body.checksum } : {}),
+ };
+ const summary = summarise(effective);
+
+ if (failures.length > 0) {
+ const reasons = failures
+ .slice(0, 5)
+ .map((f) => `${f.checkName}: ${f.message ?? `expected ${f.expected}, got ${f.actual}`}`)
+ .join("; ");
+ const updated = await applyTransition(
+ tx,
+ exportRow,
+ "VALIDATION_FAILED",
+ {
+ type: "WORKER",
+ id: body.machineId,
+ note: `Validation failed (${summary}) — ${reasons}${failures.length > 5 ? ` (+${failures.length - 5} more)` : ""}`,
+ },
+ stats
+ );
+ return {
+ export: { id: updated.id, status: updated.status },
+ results: effective.length,
+ failed: true,
+ failures: failures.map((f) => f.checkName),
+ alreadyApplied: false,
+ };
+ }
+
+ // Passed: hand off to the preview stage, the same handoff completeRender
+ // used to make directly before validation existed.
+ if (!previewEnabled) {
+ const updated = await applyTransition(
+ tx,
+ exportRow,
+ "READY_FOR_QC",
+ { type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); preview stage disabled` },
+ stats
+ );
+ return {
+ export: { id: updated.id, status: updated.status },
+ results: effective.length,
+ failed: false,
+ previewJob: null,
+ alreadyApplied: false,
+ };
+ }
+
+ const manifest = await buildPreviewManifest(exportRow);
+ const previewJob = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority);
+ const updated = await applyTransition(
+ tx,
+ exportRow,
+ "GENERATING_PREVIEW",
+ { type: "WORKER", id: body.machineId, note: `Validation passed (${summary}) — queued preview build` },
+ stats
+ );
+
+ return {
+ export: { id: updated.id, status: updated.status },
+ results: effective.length,
+ failed: false,
+ previewJob: { id: previewJob.id, type: previewJob.type },
+ alreadyApplied: false,
+ };
+ });
+}
+
+/** Whether the validation stage runs at all (kill switch for a bad engine release). */
+export async function isValidationEnabled(): Promise {
+ return getConfigBoolean("validation.enabled");
+}
+
+export interface ValidationBrowserFilter {
+ projectId?: string;
+ episode?: string;
+ checkName?: string;
+ status?: ValidationStatus;
+ page?: number;
+ limit?: number;
+}
+
+/**
+ * Cross-export browser (§13) — the point is spotting a systemic fault
+ * ("every shot this week failed timecode_plate_match"), not one bad render.
+ */
+export async function listValidationResults(filter: ValidationBrowserFilter) {
+ const page = Math.max(1, filter.page ?? 1);
+ const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
+ const where: Prisma.ValidationResultWhereInput = {
+ ...(filter.checkName ? { checkName: filter.checkName } : {}),
+ ...(filter.status ? { status: filter.status } : { status: { in: ["FAIL", "WARN"] } }),
+ ...(filter.projectId || filter.episode
+ ? {
+ export: {
+ ...(filter.projectId ? { projectId: filter.projectId } : {}),
+ ...(filter.episode ? { shot: { episode: filter.episode } } : {}),
+ },
+ }
+ : {}),
+ };
+
+ const [total, rows] = await Promise.all([
+ db.validationResult.count({ where }),
+ db.validationResult.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ skip: (page - 1) * limit,
+ take: limit,
+ include: {
+ export: {
+ select: {
+ id: true,
+ versionString: true,
+ status: true,
+ shot: { select: { shotCode: true, episode: true } },
+ },
+ },
+ },
+ }),
+ ]);
+
+ return {
+ pagination: { page, limit, total, pages: Math.ceil(total / limit) },
+ results: rows.map((r) => ({
+ id: r.id,
+ checkName: r.checkName,
+ status: r.status,
+ expected: r.expected,
+ actual: r.actual,
+ message: r.message,
+ details: r.details,
+ createdAt: r.createdAt,
+ exportId: r.exportId,
+ versionString: r.export.versionString,
+ exportStatus: r.export.status,
+ shotCode: r.export.shot.shotCode,
+ episode: r.export.shot.episode,
+ })),
+ };
+}
diff --git a/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql b/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql
new file mode 100644
index 0000000..01f6597
--- /dev/null
+++ b/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql
@@ -0,0 +1,110 @@
+-- RenderPipeline2 Phase 3 (validation), Phase 4 QC half, Phase 5 (delivery).
+-- Purely additive: no existing table is altered, no backfill needed.
+
+-- CreateTable
+CREATE TABLE "validation_results" (
+ "id" TEXT NOT NULL,
+ "exportId" TEXT NOT NULL,
+ "renderJobId" TEXT NOT NULL,
+ "checkName" TEXT NOT NULL,
+ "status" "ValidationStatus" NOT NULL,
+ "expected" TEXT,
+ "actual" TEXT,
+ "message" TEXT,
+ "details" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "validation_results_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "qc_reviews" (
+ "id" TEXT NOT NULL,
+ "exportId" TEXT NOT NULL,
+ "result" "QCResult" NOT NULL,
+ "notes" TEXT,
+ "reviewedById" TEXT,
+ "reviewerName" TEXT,
+ "source" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "qc_reviews_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "delivery_packages" (
+ "id" TEXT NOT NULL,
+ "projectId" TEXT NOT NULL,
+ "episode" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "packageNumber" INTEGER NOT NULL,
+ "status" "DeliveryStatus" NOT NULL DEFAULT 'DRAFT',
+ "rootPath" TEXT,
+ "configSnapshot" JSONB NOT NULL,
+ "manifest" JSONB,
+ "reportKey" TEXT,
+ "errorMessage" TEXT,
+ "createdById" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "builtAt" TIMESTAMP(3),
+ "deliveredAt" TIMESTAMP(3),
+ "deliveredById" TEXT,
+
+ CONSTRAINT "delivery_packages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "delivery_items" (
+ "id" TEXT NOT NULL,
+ "packageId" TEXT NOT NULL,
+ "exportId" TEXT NOT NULL,
+ "shotId" TEXT NOT NULL,
+ "shotCode" TEXT NOT NULL,
+ "versionString" TEXT NOT NULL,
+ "exrPath" TEXT,
+ "movPath" TEXT,
+ "fileCount" INTEGER,
+ "totalBytes" BIGINT,
+ "checksum" TEXT,
+ "linkMode" TEXT,
+ "status" TEXT NOT NULL DEFAULT 'PENDING',
+ "errorMessage" TEXT,
+
+ CONSTRAINT "delivery_items_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "validation_results_exportId_renderJobId_idx" ON "validation_results"("exportId", "renderJobId");
+
+-- CreateIndex
+CREATE INDEX "qc_reviews_exportId_idx" ON "qc_reviews"("exportId");
+
+-- CreateIndex
+CREATE INDEX "delivery_packages_projectId_episode_idx" ON "delivery_packages"("projectId", "episode");
+
+-- CreateIndex
+CREATE INDEX "delivery_packages_status_idx" ON "delivery_packages"("status");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "delivery_packages_projectId_packageNumber_key" ON "delivery_packages"("projectId", "packageNumber");
+
+-- CreateIndex
+CREATE INDEX "delivery_items_exportId_idx" ON "delivery_items"("exportId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "delivery_items_packageId_exportId_key" ON "delivery_items"("packageId", "exportId");
+
+-- AddForeignKey
+ALTER TABLE "validation_results" ADD CONSTRAINT "validation_results_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "qc_reviews" ADD CONSTRAINT "qc_reviews_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "delivery_packages" ADD CONSTRAINT "delivery_packages_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "delivery_items" ADD CONSTRAINT "delivery_items_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "delivery_packages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "delivery_items" ADD CONSTRAINT "delivery_items_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 2fe96c2..1e3f457 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -215,40 +215,40 @@ enum AttachmentCategory {
// ─────────────────────────────────────────────
model User {
- id String @id @default(cuid())
- name String?
- email String @unique
- emailVerified DateTime?
- image String?
+ id String @id @default(cuid())
+ name String?
+ email String @unique
+ emailVerified DateTime?
+ image String?
passwordHash String?
role Role @default(ARTIST)
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
// NextAuth relations
accounts Account[]
sessions Session[]
// App relations
- producedProjects Project[] @relation("ProducerProjects")
- supervisedProjects Project[] @relation("SupervisorProjects")
- assignedShots Shot[] @relation("ArtistShots")
- uploadedVersions Version[]
- comments Comment[]
- commentReplies CommentReply[]
- annotations Annotation[]
- approvals Approval[]
- notifications Notification[]
- clientAccess ClientAccess[]
- assignedTasks Task[] @relation("TaskArtist")
- createdTasks Task[] @relation("TaskCreator")
- sharedVersions Version[] @relation("VersionSharedBy")
- ledAssets Asset[] @relation("AssetLead")
- createdShootDays ShootDay[] @relation("ShootDayCreator")
- createdTakes Take[] @relation("TakeCreator")
- uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
+ producedProjects Project[] @relation("ProducerProjects")
+ supervisedProjects Project[] @relation("SupervisorProjects")
+ assignedShots Shot[] @relation("ArtistShots")
+ uploadedVersions Version[]
+ comments Comment[]
+ commentReplies CommentReply[]
+ annotations Annotation[]
+ approvals Approval[]
+ notifications Notification[]
+ clientAccess ClientAccess[]
+ assignedTasks Task[] @relation("TaskArtist")
+ createdTasks Task[] @relation("TaskCreator")
+ sharedVersions Version[] @relation("VersionSharedBy")
+ ledAssets Asset[] @relation("AssetLead")
+ createdShootDays ShootDay[] @relation("ShootDayCreator")
+ createdTakes Take[] @relation("TakeCreator")
+ uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
@@map("users")
}
@@ -360,7 +360,8 @@ model Project {
/// Per-production delivery naming/layout templates (RenderPipeline2 §11.2);
/// falls back to SystemConfig defaults when null
- deliveryConfig Json?
+ deliveryConfig Json?
+ deliveryPackages DeliveryPackage[]
@@map("projects")
}
@@ -380,60 +381,60 @@ model EpisodeDueDate {
}
model Shot {
- id String @id @default(cuid())
- shotCode String
- scene String @default("")
- episode String?
- shotNumber Int @default(0)
- sequence String?
- description String? @db.Text
- notes String? @db.Text
- status ShotStatus @default(WAITING)
- priority ShotPriority @default(NORMAL)
- artistId String?
- projectId String
- frameStart Int?
- frameEnd Int?
- fps Float @default(24)
- dueDate DateTime?
- thumbnailUrl String?
- originalFootageUrl String?
- originalFootageKey String?
- shotGroupId String?
- shotApprovalStatus ShotApprovalStatus @default(PENDING)
- sharedWithClient Boolean @default(false)
+ id String @id @default(cuid())
+ shotCode String
+ scene String @default("")
+ episode String?
+ shotNumber Int @default(0)
+ sequence String?
+ description String? @db.Text
+ notes String? @db.Text
+ status ShotStatus @default(WAITING)
+ priority ShotPriority @default(NORMAL)
+ artistId String?
+ projectId String
+ frameStart Int?
+ frameEnd Int?
+ fps Float @default(24)
+ dueDate DateTime?
+ thumbnailUrl String?
+ originalFootageUrl String?
+ originalFootageKey String?
+ shotGroupId String?
+ shotApprovalStatus ShotApprovalStatus @default(PENDING)
+ sharedWithClient Boolean @default(false)
// EDL / pull CSV fields
- sourceClip String?
- timecodeStart String?
- timecodeEnd String?
- clipDuration String?
- exrOutput String?
+ sourceClip String?
+ timecodeStart String?
+ timecodeEnd String?
+ clipDuration String?
+ exrOutput String?
// Sequence / picture lock timecodes
- seqTimecodeStart String?
- seqTimecodeEnd String?
+ seqTimecodeStart String?
+ seqTimecodeEnd String?
// High-res deliverable (stored in Hetzner Object Storage)
- highResKey String?
- highResFilename String?
+ highResKey String?
+ highResFilename String?
// Shot-level version tracking (e.g. v001, v002)
- shotVersion String @default("v001")
+ shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation
- isKeyShot Boolean @default(false)
+ isKeyShot Boolean @default(false)
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
- slateType String?
- slateDescription String? @db.Text
- slateVfxScope String? @db.Text
- slateSubmissionNote String? @db.Text
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ slateType String?
+ slateDescription String? @db.Text
+ slateVfxScope String? @db.Text
+ slateSubmissionNote String? @db.Text
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
- artist User? @relation("ArtistShots", fields: [artistId], references: [id])
- shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
- versions Version[]
- tasks Task[]
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ artist User? @relation("ArtistShots", fields: [artistId], references: [id])
+ shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
+ versions Version[]
+ tasks Task[]
footagePlates FootagePlate[]
references ShotReference[]
- loggedTakes Take[] @relation("TakeToShot")
+ loggedTakes Take[] @relation("TakeToShot")
exports Export[]
@@unique([projectId, shotCode])
@@ -565,7 +566,7 @@ model Annotation {
commentId String?
authorId String?
frameNumber Int
- drawingData Json // Array of AnnotationShape objects
+ drawingData Json // Array of AnnotationShape objects
color String @default("#ef4444")
isVisible Boolean @default(true)
createdAt DateTime @default(now())
@@ -597,7 +598,7 @@ model Notification {
type NotificationType
title String
message String @db.Text
- data Json? // Extra context (versionId, shotCode, etc.)
+ data Json? // Extra context (versionId, shotCode, etc.)
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@ -647,13 +648,13 @@ model Task {
scheduledStartDate DateTime?
scheduledEndDate DateTime?
- scheduleNotes String? @db.Text
+ scheduleNotes String? @db.Text
- shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
- asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
- assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id])
- createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull)
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
+ asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
+ assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id])
+ createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
versions Version[]
@@index([scheduledStartDate])
@@ -692,9 +693,9 @@ model SystemConfig {
/// Physical display event log — polled by ESP32 dot-matrix display
model DisplayEvent {
id Int @id @default(autoincrement())
- type String // APPROVED | CHANGES | COMMENT | REJECTED
+ type String // APPROVED | CHANGES | COMMENT | REJECTED
shotCode String
- by String // actor name (reviewer / commenter)
+ by String // actor name (reviewer / commenter)
createdAt DateTime @default(now())
@@index([createdAt])
@@ -716,8 +717,8 @@ model ShootDay {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
- createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull)
setups Setup[]
@@map("shoot_days")
@@ -747,9 +748,9 @@ model Take {
pipelineShotId String?
// General
- scene String?
- shotLabel String?
- unitLabel String?
+ scene String?
+ shotLabel String?
+ unitLabel String?
// Camera
cameraLetter String?
@@ -880,8 +881,11 @@ model Export {
submittedByName String? // free text from panel config as fallback
supersededById String? // newer Export that replaced this one
- renderJobs RenderJob[]
- events ExportEvent[]
+ renderJobs RenderJob[]
+ events ExportEvent[]
+ validations ValidationResult[]
+ qcReviews QCReview[]
+ deliveryItems DeliveryItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -984,6 +988,96 @@ model WorkerHeartbeat {
@@map("worker_heartbeats")
}
+/// One row per check per validation run, so the UI shows exactly what failed
+/// and history survives retries (§5.4).
+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 // "timecode_plate_match", "missing_frames", ...
+ status ValidationStatus
+ expected String?
+ actual String?
+ message String?
+ details Json? // e.g. list of missing frame numbers, or the timecode reference used
+ createdAt DateTime @default(now())
+
+ @@index([exportId, renderJobId])
+ @@map("validation_results")
+}
+
+/// Human technical QC on an Export (§5.5). Multiple rows allowed — the latest
+/// is authoritative and drives T13/T14.
+model QCReview {
+ id String @id @default(cuid())
+ exportId String
+ export Export @relation(fields: [exportId], references: [id])
+ result QCResult
+ notes String? @db.Text
+ 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])
+ @@map("qc_reviews")
+}
+
+/// A dated delivery folder built from READY_FOR_DELIVERY exports (§5.7).
+/// Never deleted — a mistaken package is CANCELLED or superseded.
+model DeliveryPackage {
+ id String @id @default(cuid())
+ projectId String
+ project Project @relation(fields: [projectId], references: [id])
+ episode String // "101" — empty string on standard (non-episodic) shows
+ name String // "260805_Delivery"
+ packageNumber Int // sequential per project → "Package #15"
+ status DeliveryStatus @default(DRAFT)
+ rootPath String? // final path on the delivery volume
+ configSnapshot Json // naming template + options at build time (immutable record)
+ manifest Json? // built manifest: files, sizes, checksums
+ reportKey String? // delivery report in 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([status])
+ @@map("delivery_packages")
+}
+
+/// Version lock: the exact Export delivered. shotCode/versionString are
+/// denormalised so a later shot rename or re-render never mutates history.
+model DeliveryItem {
+ id String @id @default(cuid())
+ packageId String
+ package DeliveryPackage @relation(fields: [packageId], references: [id], onDelete: Cascade)
+ exportId String
+ export Export @relation(fields: [exportId], references: [id])
+ shotId String
+ shotCode String
+ versionString String
+ exrPath String? // path inside the package
+ movPath String?
+ fileCount Int?
+ totalBytes BigInt?
+ checksum String?
+ linkMode String? // "hardlink" | "copy" — what the builder actually did
+ status String @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED
+ errorMessage String?
+
+ @@unique([packageId, exportId])
+ @@index([exportId])
+ @@map("delivery_items")
+}
+
model SketchTemplate {
id String @id @default(cuid())
name String