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