feat(render-pipeline): validation, QC and delivery builder

Completes RenderPipeline2 phases 3-5. Additive throughout: with
validation.enabled=false the pipeline behaves exactly as before.

Validation (§8) runs on the worker straight after a render, while it
still holds the job and the files are local. Everything is read from the
EXR header — one 64 KB read per sampled frame, no pixel decode — so the
engine needs no oiiotool. The render job deliberately stays open through
the stage, so a worker that dies there is reaped and retried instead of
stranding the export in VALIDATING.

Timecode is the primary check and is anchored on the plate, never the
API: measured on the live show, Shot.timecodeStart is the editorial
in-point and sits one handle-length (8f) ahead of the plate, so checking
against it would pass the defective exports and fail the correct ones.
Reference precedence is manifest timing plate -> plate found on disk ->
API value, and the API rank is WARN-only. The panel now resolves plate
provenance by walking the comp's own footage layers, so the worker never
guesses which element carries the comp's timing.

Verified against real studio renders via the new --validate mode: it
catches both defect classes seen in shipped work — the +8 handle offset
and frame-derived timecode (00:00:41:17 = frame 1001 at 24fps).

QC (§10) is one service behind two front doors, API-key for the panel
and session for the web. PASS means ready to upload and nothing else;
FAIL sets the comp task to CHANGES. The panel gains a QC section that
imports the real EXR sequence at full res rather than the H.264.

Delivery (§11) rides the existing queue as a DELIVERY_BUILD job: hard
links with a copy fallback, verification by re-hashing delivered bytes
against the validation-time digest, manifest.json and the CSV report.
fsutil hardlink list is unavailable over SMB, so link identity is never
queried — reading the bytes back covers links and copies alike.

Also fixes ApiClient.ProgressAsync throwing on transport failure, which
would have aborted work in flight during a brief server outage; progress
reporting is best-effort by contract.

Removes the stale RenderWorker/ rule from the root .gitignore. The
worker's source is tracked like the rest of the repo and its own
.gitignore already covers bin/, obj/ and publish/; the rule silently
excluded new files while the existing 20 stayed tracked.

Not yet applied: migration 20260813000000 was hand-written because the
dev database was unavailable — run prisma migrate deploy and verify
before relying on it. None of this has run against a live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-13 13:49:51 +02:00
parent 7f2f5e1005
commit feb1c7b3e9
48 changed files with 5786 additions and 238 deletions
+2 -2
View File
@@ -32,5 +32,5 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Local tooling # RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/,
RenderWorker/ # publish/) — the worker's source is tracked like the rest of the repo.
+30
View File
@@ -24,6 +24,30 @@ X-Api-Key: <API_SECRET_KEY>
| `POST` | `/api/ext/shots` | Create a new shot | | `POST` | `/api/ext/shots` | Create a new shot |
| `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) | | `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 ## 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}` | | 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}` | | 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" }` | | 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) |
+64 -3
View File
@@ -579,6 +579,19 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m
"alpha": false, "alpha": false,
"timecodeStart": "00:59:49:12" "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": { "preview": {
"template": "default", "template": "default",
"burnins": true "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. 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 ### 6.3 Endpoint summary
| # | Method | Path | Auth | Caller | Purpose | | # | 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) | | `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` | | `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 | | `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 | | `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`). 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 ### 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). - 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 ### 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 ### 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) | | 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 | | 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 | | 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) | | 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) | | Migration | Additive; existing in-flight exports unaffected (transition map keys off manifest version) |
+44 -2
View File
@@ -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. 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 — - Durable reporting: complete/fail reports spool to disk and replay in order with backoff —
rendering continues while the server is down. 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 ## 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", "aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
"aeVersion": "24.3", "aeVersion": "24.3",
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe", "ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
"plateSearchRoots": ["V:/_FOOTAGE/UNG"],
"pathMappings": [ "pathMappings": [
{ "from": "//SAN/", "to": "S:/" } { "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 `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. the server's SystemConfig and arrives at registration — no per-machine tuning files.
## Run interactively (first-time smoke test) ## Run interactively (first-time smoke test)
@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging.Abstractions;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// 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.
/// </summary>
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<DeliveryManifestItem>
{
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<ApiClient>.Instance),
NullLogger<DeliveryStage>.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);
}
}
@@ -0,0 +1,193 @@
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// 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.
/// </summary>
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<InvalidDataException>(() => 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);
}
}
/// <summary>Builds a minimal but structurally real EXR header on disk.</summary>
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);
}
}
@@ -0,0 +1,61 @@
using System.Text;
using VFXReviewWorker;
using Xunit;
namespace VFXReviewWorker.Tests;
/// <summary>
/// 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.
/// </summary>
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<byte>(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);
}
}
}
+24 -7
View File
@@ -71,17 +71,32 @@ public sealed class ApiClient
return body?.Job; 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<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress, public async Task<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress,
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct) 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), using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
}; {
var res = await _http.SendAsync(req, ct); Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json),
if (!res.IsSuccessStatusCode) return null; };
return await res.Content.ReadFromJsonAsync<ProgressResponse>(Json, ct); var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
return await res.Content.ReadFromJsonAsync<ProgressResponse>(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. // 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", "fail" => $"api/ext/render/jobs/{jobId}/fail",
"complete-render" => $"api/ext/render/jobs/{jobId}/complete-render", "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", "finalize" => $"api/ext/render/jobs/{jobId}/finalize",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
}; };
@@ -0,0 +1,327 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// 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.
/// </summary>
public sealed class DeliveryStage
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly ILogger<DeliveryStage> _log;
public DeliveryStage(WorkerOptions options, ApiClient api, ILogger<DeliveryStage> log)
{
_options = options;
_api = api;
_log = log;
}
public async Task<DeliveryResult> 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<DeliveryItemResult>();
var manifestFiles = new List<object>();
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<object> 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}");
}
}
/// <summary>
/// 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.
/// </summary>
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<string> 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");
}
/// <summary>
/// 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.
/// </summary>
private string WriteReport(DeliveryManifest manifest, List<DeliveryItemResult> 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<DeliveryItemResult> results, List<object> 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<DeliveryItemResult> Items, string PackageDir, string? ReportPath, List<object> 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,
};
}
+82 -1
View File
@@ -26,6 +26,15 @@ public sealed class ServerConfig
public int LeaseSeconds { get; set; } = 300; public int LeaseSeconds { get; set; } = 300;
public int MaxAttempts { get; set; } = 3; public int MaxAttempts { get; set; } = 3;
public int StallTimeoutSeconds { get; set; } = 600; public int StallTimeoutSeconds { get; set; } = 600;
/// <summary>
/// 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.
/// </summary>
public bool ValidationEnabled { get; set; } = true;
public int ValidationSampleEvery { get; set; } = 25;
/// <summary>Head handles the plates carry; only used for the API timecode fallback (§8.1a).</summary>
public int ValidationHandleFrames { get; set; } = 8;
} }
public sealed class ClaimResponse public sealed class ClaimResponse
@@ -52,6 +61,43 @@ public sealed class ClaimedJob
public PreviewManifest AsPreviewManifest() => public PreviewManifest AsPreviewManifest() =>
Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts) Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a preview manifest"); ?? throw new InvalidOperationException("Job manifest is not a preview manifest");
public DeliveryManifest AsDeliveryManifest() =>
Manifest.Deserialize<DeliveryManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a delivery manifest");
}
/// <summary>DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11).</summary>
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<string> ReportFormats { get; set; } = new();
public List<DeliveryManifestItem> 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; } = "";
} }
/// <summary>PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).</summary> /// <summary>PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).</summary>
@@ -107,9 +153,10 @@ public sealed class PreviewBuildResult
public int? Height { get; set; } public int? Height { get; set; }
} }
/// <summary>The Render Manifest (§6.2) — only the fields the Phase 2 render stage needs; the rest is preserved raw.</summary> /// <summary>The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.</summary>
public sealed class RenderManifest public sealed class RenderManifest
{ {
public string ShotCode { get; set; } = "";
public string AepPath { get; set; } = ""; public string AepPath { get; set; } = "";
public string CompName { get; set; } = ""; public string CompName { get; set; } = "";
public string OutputDir { get; set; } = ""; public string OutputDir { get; set; } = "";
@@ -118,6 +165,11 @@ public sealed class RenderManifest
public string? RenderSettingsTemplate { get; set; } public string? RenderSettingsTemplate { get; set; }
public int FrameStart { get; set; } public int FrameStart { get; set; }
public int FrameEnd { 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] [JsonExtensionData]
public Dictionary<string, JsonElement>? Extra { get; set; } public Dictionary<string, JsonElement>? Extra { get; set; }
@@ -125,6 +177,35 @@ public sealed class RenderManifest
public int TotalFrames => FrameEnd - FrameStart + 1; public int TotalFrames => FrameEnd - FrameStart + 1;
} }
/// <summary>Per-project expectations the validation engine checks against (§8.1).</summary>
public sealed class ManifestExpected
{
public string? Colorspace { get; set; }
public string? BitDepth { get; set; }
public string? ExrCompression { get; set; }
public bool? Alpha { get; set; }
/// <summary>The API's editorial in-point — the rank 3 timecode fallback only (§8.1a).</summary>
public string? TimecodeStart { get; set; }
}
/// <summary>Plate provenance resolved by the panel from the comp's footage layers (§6.2).</summary>
public sealed class ManifestSource
{
public PlateRef? TimingPlate { get; set; }
public List<PlateRef> 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 sealed class ProgressResponse
{ {
public bool Ok { get; set; } public bool Ok { get; set; }
+234
View File
@@ -0,0 +1,234 @@
using System.Text;
namespace VFXReviewWorker;
/// <summary>
/// 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.
/// </summary>
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<ExrChannel> Channels { get; private init; } = Array.Empty<ExrChannel>();
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<string> AttributeNames { get; private init; } = Array.Empty<string>();
public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal));
/// <summary>half / float / uint — the deepest channel type present.</summary>
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}",
};
/// <summary>Reads the header, or throws <see cref="InvalidDataException"/> when the file is not a readable EXR.</summary>
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<byte>(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<string>();
var channels = new List<ExrChannel>();
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,
};
}
/// <summary>Header read that returns null instead of throwing — for "is this file readable at all" checks.</summary>
public static ExrHeader? TryRead(string path)
{
try { return Read(path); }
catch { return null; }
}
private static IEnumerable<ExrChannel> ReadChannels(ReadOnlySpan<byte> data)
{
var result = new List<ExrChannel>();
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<byte> 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}" };
}
/// <summary>
/// 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).
/// </summary>
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(';'));
}
/// <summary>
/// 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.
/// </summary>
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}";
}
+155 -1
View File
@@ -14,15 +14,20 @@ public sealed class JobExecutor
private readonly ApiClient _api; private readonly ApiClient _api;
private readonly AerenderRunner _runner; private readonly AerenderRunner _runner;
private readonly PreviewStage _preview; private readonly PreviewStage _preview;
private readonly ValidationStage _validation;
private readonly DeliveryStage _delivery;
private readonly ReportSpooler _spooler; private readonly ReportSpooler _spooler;
private readonly ILogger<JobExecutor> _log; private readonly ILogger<JobExecutor> _log;
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, ReportSpooler spooler, ILogger<JobExecutor> log) public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
ValidationStage validation, DeliveryStage delivery, ReportSpooler spooler, ILogger<JobExecutor> log)
{ {
_options = options; _options = options;
_api = api; _api = api;
_runner = runner; _runner = runner;
_preview = preview; _preview = preview;
_validation = validation;
_delivery = delivery;
_spooler = spooler; _spooler = spooler;
_log = log; _log = log;
} }
@@ -35,6 +40,12 @@ public sealed class JobExecutor
return; return;
} }
if (job.Type == "DELIVERY_BUILD")
{
await RunDeliveryAsync(job, machineId, cancelJob, shutdown);
return;
}
var manifest = job.AsRenderManifest(); var manifest = job.AsRenderManifest();
var mapper = new PathMapper(_options.PathMappings); var mapper = new PathMapper(_options.PathMappings);
var outputDirLocal = mapper.Map(manifest.OutputDir); var outputDirLocal = mapper.Map(manifest.OutputDir);
@@ -101,6 +112,15 @@ public sealed class JobExecutor
["exrFileCount"] = fileCount, ["exrFileCount"] = fileCount,
["exrTotalBytes"] = totalBytes, ["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") else if (result.ErrorMessage == "Cancelled")
{ {
@@ -149,6 +169,140 @@ public sealed class JobExecutor
} }
} }
/// <summary>
/// DELIVERY_BUILD job: build the dated package folder from already-QC'd
/// exports. No AE involved — folders, links and verification only.
/// </summary>
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<string, object?>
{
["machineId"] = machineId,
["ok"] = result.Ok,
["rootPath"] = result.PackageDir,
["reportKey"] = reportKey,
["items"] = result.Items.Select(i => new Dictionary<string, object?>
{
["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<string, object?>
{
["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<string, object?>
{
["machineId"] = machineId,
["ok"] = false,
["items"] = new List<object>(),
["errorMessage"] = $"Worker exception: {ex.Message}",
});
}
finally
{
ClearState();
}
}
/// <summary>
/// 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.
/// </summary>
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<string, object?>
{
["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<string, object?>
{
["machineId"] = machineId,
["checks"] = new[]
{
ValidationCheck.Fail("engine_error", "validation completes", "worker exception",
$"The validation engine threw: {ex.Message}"),
},
});
}
}
/// <summary> /// <summary>
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate /// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
/// and burn-ins, render the delivery MOV + review MP4, upload the review /// and burn-ins, render the delivery MOV + review MP4, upload the review
+9
View File
@@ -10,6 +10,13 @@ using VFXReviewWorker;
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json] // Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
// Install as a service: see RenderWorker/README.md // 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('-')); var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
// Install verification on a new machine — resolves paths, checks tools and // Install verification on a new machine — resolves paths, checks tools and
@@ -32,6 +39,8 @@ builder.Services.AddSingleton<ApiClient>();
builder.Services.AddSingleton<ReportSpooler>(); builder.Services.AddSingleton<ReportSpooler>();
builder.Services.AddSingleton<AerenderRunner>(); builder.Services.AddSingleton<AerenderRunner>();
builder.Services.AddSingleton<PreviewStage>(); builder.Services.AddSingleton<PreviewStage>();
builder.Services.AddSingleton<ValidationStage>();
builder.Services.AddSingleton<DeliveryStage>();
builder.Services.AddSingleton<JobExecutor>(); builder.Services.AddSingleton<JobExecutor>();
builder.Services.AddHostedService<WorkerService>(); builder.Services.AddHostedService<WorkerService>();
@@ -9,6 +9,11 @@ namespace VFXReviewWorker;
/// order with exponential backoff (1 s → 60 s). Rendering continues during /// order with exponential backoff (1 s → 60 s). Rendering continues during
/// server downtime; a report is deleted only once the server accepts it (or /// server downtime; a report is deleted only once the server accepts it (or
/// permanently rejects it as contradictory). /// 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.
/// </summary> /// </summary>
public sealed class ReportSpooler public sealed class ReportSpooler
{ {
@@ -0,0 +1,98 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace VFXReviewWorker;
/// <summary>
/// `VFXReviewWorker.exe --validate &lt;renderDir&gt; [--plate &lt;plateFile&gt;] [--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.
/// </summary>
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 <renderDir> [--plate <plateFirstFrame.exr>] [--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<ValidationStage>.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;
}
}
@@ -0,0 +1,590 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// 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.
/// </summary>
public sealed class ValidationStage
{
private readonly WorkerOptions _options;
private readonly ILogger<ValidationStage> _log;
public ValidationStage(WorkerOptions options, ILogger<ValidationStage> log)
{
_options = options;
_log = log;
}
public ValidationOutcome Run(RenderManifest manifest, string outputDirLocal, ServerConfig config)
{
var checks = new List<ValidationCheck>();
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<string> EnumerateSequence(
RenderManifest manifest, string outputDirLocal, List<ValidationCheck> checks,
out List<int> frameNumbers, out long totalBytes)
{
frameNumbers = new List<int>();
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<string>();
}
var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
var matched = new List<(int Frame, string Path)>();
var nonConforming = new List<string>();
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<int>();
var duplicates = new List<int>();
foreach (var f in frameNumbers)
{
if (!seen.Add(f)) duplicates.Add(f);
}
var missing = new List<int>();
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<string>();
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<string> PickSamples(List<string> files, int sampleEvery)
{
var picks = new SortedSet<int> { 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<string> sampled, List<ValidationCheck> checks)
{
var headers = new List<(string, ExrHeader)>();
var unreadable = new List<string>();
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<ValidationCheck> 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<ValidationCheck> 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<ValidationCheck> 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<int> frameNumbers,
ServerConfig config, List<ValidationCheck> 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<string>();
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));
}
/// <summary>
/// Reference precedence (§8.1a): manifest timing plate → plate found under a
/// configured search root → API timecode minus handles (advisory only).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string> { 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 ────────────────────────────────────────────────────────────
/// <summary>
/// Sequence digest = xxHash64 over the per-file digests in frame order
/// (§8.2). Reused by the delivery builder to verify what it linked/copied.
/// </summary>
private string? ComputeSequenceChecksum(List<string> 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<int> 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<ValidationCheck> Checks, int FileCount, long TotalBytes, string? Checksum)
{
public bool HasFailure => Checks.Any(c => c.Status == "FAIL");
}
/// <summary>One row of the E12 report.</summary>
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 };
}
/// <summary>
/// Parses `{base}.[#####].exr` output patterns and the filenames they produce.
/// </summary>
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);
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
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}";
}
@@ -21,6 +21,12 @@ public sealed class WorkerOptions
public string? FfmpegPath { get; set; } public string? FfmpegPath { get; set; }
public string? OiiotoolPath { get; set; } public string? OiiotoolPath { get; set; }
public List<PathMapping> PathMappings { get; set; } = new(); public List<PathMapping> PathMappings { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public List<string> PlateSearchRoots { get; set; } = new();
public string WorkerVersion { get; set; } = "1.0.0"; public string WorkerVersion { get; set; } = "1.0.0";
public string? AeVersion { get; set; } public string? AeVersion { get; set; }
@@ -61,9 +61,11 @@ public sealed class WorkerService : BackgroundService
: "No interactive After Effects — preview jobs enabled"); : "No interactive After Effects — preview jobs enabled");
_lastAeOpen = aeOpen; _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 var types = aeOpen
? new[] { "AE_RENDER" } ? new[] { "AE_RENDER", "DELIVERY_BUILD" }
: new[] { "AE_RENDER", "PREVIEW_ONLY" }; : new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD" };
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken); var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
if (job is not null) if (job is not null)
+158
View File
@@ -0,0 +1,158 @@
namespace VFXReviewWorker;
/// <summary>
/// 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.
/// </summary>
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<byte> 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<byte>(buffer, 0, read));
}
return state.Digest().ToString("x16");
}
/// <summary>Streaming state so multi-GB sequences hash without being held in memory.</summary>
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<byte> 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<byte> 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<byte>(_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));
}
}
+1069 -97
View File
File diff suppressed because it is too large Load Diff
+91 -5
View File
@@ -123,13 +123,15 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
## Panel Layout ## 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 1. **Shot Builder** — Episode/shot selection and comp creation
2. **Actions** — Per-shot and batch operations 2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
3. **Workspace** — One-time project initialisation 3. **QC** — Technical QC queue for rendered exports
4. **Color Space** — OCIO effect management 4. **Actions** — Per-shot and batch operations
5. **Status** — Live feedback on selected comps and last action 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. 7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders. 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 ## 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. - **Line 2** — Result or progress message from the last operation.
Both lines are updated throughout batch operations so progress is visible in real time. 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.
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react"; 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 { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { import {
@@ -112,6 +112,12 @@ export function PipelineQueueClient({ projects }: Props) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/machines"> <Link href="/pipeline/machines">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Server className="h-4 w-4 mr-2" /> <Server className="h-4 w-4 mr-2" />
@@ -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<string>("");
const [episode, setEpisode] = useState<string>("");
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<ReadyResponse>;
},
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 (
<div className="p-6 space-y-6 max-w-6xl">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-semibold text-white">Delivery packages</h1>
</div>
{/* Picker */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={projectId}
onChange={(e) => {
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"
>
<option value="">Select a project…</option>
{(projects ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
{episodes.length > 0 && (
<select
value={episode}
onChange={(e) => setEpisode(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
<option value="">All episodes</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode || "(no episode)"} — {e.count} ready
</option>
))}
</select>
)}
</div>
{/* Ready to build */}
{projectId && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Ready for delivery
</div>
{readyLoading && <div className="p-4 text-sm text-zinc-500">Loading…</div>}
{!readyLoading && shown.length === 0 && (
<div className="p-4 text-sm text-zinc-500">
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
<div>
<span className="text-white text-sm font-medium">
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
</span>
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
{ep.exports.some((x) => !x.hasChecksum) && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
</span>
)}
</div>
{canBuild && (
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
<Package className="h-4 w-4 mr-2" />
Build delivery
</Button>
)}
</div>
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{ep.exports.map((x) => (
<div key={x.exportId} className="flex items-center gap-2 text-xs">
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
<span className="text-zinc-500">{x.versionString}</span>
<span className="text-zinc-600">{x.fileCount ?? "?"} frames</span>
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
</div>
</div>
))}
</div>
)}
{/* History — packages are permanent (§11.5) */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
<div className="divide-y divide-zinc-800/60">
{(packages?.packages ?? []).map((p) => (
<details key={p.id} className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
<span className="text-sm text-zinc-300">{p.name}</span>
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs",
DELIVERY_STATUS_STYLES[p.status] ?? ""
)}
>
{statusLabel(p.status)}
</span>
<span className="text-xs text-zinc-500">
{p.episode ? `episode ${p.episode} · ` : ""}
{p.verifiedCount}/{p.itemCount} shots
</span>
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
<span className="ml-auto flex items-center gap-2">
{canBuild && p.status === "READY" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "delivered");
}}
>
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
Mark delivered
</Button>
)}
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "cancel");
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
{p.deliveredAt && (
<div className="text-xs text-emerald-400">
Delivered {new Date(p.deliveredAt).toLocaleString()}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{p.items.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-xs">
<span
className={cn(
item.status === "VERIFIED"
? "text-emerald-400"
: item.status === "FAILED"
? "text-red-400"
: "text-zinc-500"
)}
>
{item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"}
</span>
<span className="text-zinc-300">{item.shotCode}</span>
<span className="text-zinc-500">{item.versionString}</span>
{item.fileCount != null && <span className="text-zinc-600">{item.fileCount}f</span>}
{item.linkMode && <span className="text-zinc-600">{item.linkMode}</span>}
{item.errorMessage && <span className="text-red-400">{item.errorMessage}</span>}
</div>
))}
</div>
</div>
</details>
))}
{(packages?.packages ?? []).length === 0 && (
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
)}
</div>
</div>
</div>
);
}
@@ -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 <DeliveriesClient canBuild={["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)} />;
}
@@ -66,9 +66,42 @@ interface ExportDetail {
note: string | null; note: string | null;
createdAt: string; 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<string, string> = {
PASS: "text-emerald-400",
FAIL: "text-red-400",
WARN: "text-amber-400",
SKIPPED: "text-zinc-500",
};
export function ExportDetailClient({ exportId }: { exportId: string }) { export function ExportDetailClient({ exportId }: { exportId: string }) {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -189,6 +222,93 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
</div> </div>
)} )}
{/* Validation results — newest run first */}
{e.validations.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
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 (
<span className={cn("text-xs", failed ? "text-red-400" : warned ? "text-amber-400" : "text-emerald-400")}>
{failed ? `${failed} failed` : warned ? `${warned} warning${warned > 1 ? "s" : ""}` : "all checks passed"}
</span>
);
})()}
</div>
<div className="divide-y divide-zinc-800/60">
{(() => {
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<string, number>;
return e.validations
.filter((v) => v.renderJobId === latestRun)
.slice()
.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
.map((v) => (
<div key={v.id} className="px-4 py-2 text-sm flex items-start gap-3">
<span className={cn("w-16 shrink-0 text-xs font-medium", CHECK_STYLES[v.status] ?? "")}>
{v.status}
</span>
<span className="w-48 shrink-0 text-zinc-300 font-mono text-xs pt-0.5">{v.checkName}</span>
<span className="text-zinc-400 text-xs pt-0.5">
{v.message ??
(v.expected === v.actual ? v.actual : `expected ${v.expected ?? "—"}, got ${v.actual ?? "—"}`)}
</span>
</div>
));
})()}
</div>
</div>
)}
{/* QC history — latest decision is the authoritative one */}
{e.qcReviews.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">QC</div>
<div className="divide-y divide-zinc-800/60">
{e.qcReviews.map((q) => (
<div key={q.id} className="px-4 py-2.5 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className={cn("text-xs font-medium", q.result === "PASS" ? "text-emerald-400" : "text-red-400")}>
QC {q.result}
</span>
<span className="text-xs text-zinc-400">{q.reviewerName ?? "unknown reviewer"}</span>
<span className="text-xs text-zinc-500">{q.source === "AE_PANEL" ? "AE panel" : "web"}</span>
<span className="text-xs text-zinc-500">{new Date(q.createdAt).toLocaleString()}</span>
</div>
{q.notes && <div className="mt-1 text-xs text-zinc-400 whitespace-pre-wrap">{q.notes}</div>}
</div>
))}
</div>
</div>
)}
{/* Delivery membership */}
{e.deliveryItems.length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Deliveries</div>
<div className="divide-y divide-zinc-800/60">
{e.deliveryItems.map((d) => (
<div key={d.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
<Link href="/pipeline/deliveries" className="text-zinc-200 hover:text-white">
#{d.package.packageNumber} · {d.package.name}
</Link>
<span className="text-xs text-zinc-500">episode {d.package.episode || "—"}</span>
<span className="text-xs text-zinc-400">{d.package.status}</span>
<span className="text-xs text-zinc-500">
item {d.status.toLowerCase()}
{d.linkMode ? ` · ${d.linkMode}` : ""}
</span>
</div>
))}
</div>
</div>
)}
{/* Render attempts */} {/* Render attempts */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40"> <div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white"> <div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
+10
View File
@@ -18,6 +18,16 @@ export const EXPORT_STATUS_STYLES: Record<string, string> = {
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
}; };
export const DELIVERY_STATUS_STYLES: Record<string, string> = {
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 FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"]; export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
@@ -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 });
}
}
@@ -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 });
}
}
+24
View File
@@ -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 });
}
}
+54
View File
@@ -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 });
}
}
@@ -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 });
}
}
+28
View File
@@ -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 });
}
}
@@ -5,8 +5,10 @@ import { completeRender } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ────────────────── // ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
// //
// aerender finished with exit 0. Phase 2 interim: Export goes to a provisional // aerender finished with exit 0 → Export goes to VALIDATING and the worker
// READY_FOR_QC; Phase 3 inserts VALIDATING between (§17.3). // 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( export async function POST(
req: NextRequest, req: NextRequest,
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
+24
View File
@@ -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 });
}
}
+32 -9
View File
@@ -12,7 +12,20 @@ export const PIPELINE_CONFIG_DEFAULTS = {
"render.stallTimeoutSeconds": 600, "render.stallTimeoutSeconds": 600,
"render.urgentPriorityThreshold": 20, "render.urgentPriorityThreshold": 20,
"render.outputRoot": "//SAN", "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, "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.maxWidth": 1920,
// Preview stage (§9): headless AE rebuild of the render with slate/burn-ins // Preview stage (§9): headless AE rebuild of the render with slate/burn-ins
"preview.enabled": "true", "preview.enabled": "true",
@@ -71,13 +84,23 @@ export async function getPreviewConfig() {
/** Worker-facing config bundle returned by E6 registration. */ /** Worker-facing config bundle returned by E6 registration. */
export async function getWorkerConfig() { export async function getWorkerConfig() {
const [pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds] = const [
await Promise.all([ pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds,
getConfigNumber("render.pollSeconds"), validationEnabled, validationSampleEvery, validationHandleFrames,
getConfigNumber("render.heartbeatSeconds"), ] = await Promise.all([
getConfigNumber("render.leaseSeconds"), getConfigNumber("render.pollSeconds"),
getConfigNumber("render.maxAttempts"), getConfigNumber("render.heartbeatSeconds"),
getConfigNumber("render.stallTimeoutSeconds"), getConfigNumber("render.leaseSeconds"),
]); getConfigNumber("render.maxAttempts"),
return { pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds }; 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,
};
} }
+611
View File
@@ -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<DeliveryConfig> {
let systemDefault: Partial<DeliveryConfig> = {};
const raw = await getConfigValue("delivery.defaultConfig");
if (raw?.trim() && raw.trim() !== "{}") {
try {
systemDefault = JSON.parse(raw) as Partial<DeliveryConfig>;
} catch {
console.error("[render-pipeline] delivery.defaultConfig is not valid JSON — ignoring");
}
}
const project = (projectDeliveryConfig ?? {}) as Partial<DeliveryConfig>;
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<TokenValues>): string {
return template.replace(/\{(\w+)\}/g, (whole, key: string) => {
const value = (tokens as Record<string, string | undefined>)[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<string, (typeof rows)[number]>();
for (const row of rows) {
if (!newestPerShot.has(row.shotId)) newestPerShot.set(row.shotId, row);
}
const byEpisode = new Map<string, ReturnType<typeof serialiseReady>[]>();
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<typeof createDeliverySchema>;
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<string>();
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<DeliveryManifest> {
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<typeof deliveryReportSchema>;
/**
* 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 } };
});
}
+20 -3
View File
@@ -1,7 +1,7 @@
import { ExportStatus, Prisma } from "@prisma/client"; import { ExportStatus, Prisma } from "@prisma/client";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PipelineError } from "./errors"; import { PipelineError } from "./errors";
import { renderManifestSchema, type RenderManifest } from "./manifest"; import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest";
import { applyTransition, type TransitionActor } from "./transitions"; import { applyTransition, type TransitionActor } from "./transitions";
import { getConfigNumber, getConfigValue } from "./config"; 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({ await tx.exportEvent.create({
data: { data: {
exportId: created.id, exportId: created.id,
@@ -234,7 +237,9 @@ export async function createExport(input: CreateExportInput) {
toStatus: "QUEUED", toStatus: "QUEUED",
actorType: "USER", actorType: "USER",
actorId: submittedBy?.id ?? null, 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 } } }, include: { machine: { select: { id: true, name: true } } },
}, },
events: { orderBy: { createdAt: "asc" } }, 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"); 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) })),
},
};
} }
/** /**
+39 -6
View File
@@ -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 { return {
job: { job: {
id: job.id, id: job.id,
@@ -232,8 +240,11 @@ export async function reportFail(
/** /**
* E11 — aerender finished OK. * 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( export async function completeRender(
jobId: string, jobId: string,
@@ -255,12 +266,17 @@ export async function completeRender(
throw new PipelineError(409, `Job is ${job.status} — contradictory complete report rejected`); 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({ await tx.renderJob.update({
where: { id: job.id }, where: { id: job.id },
data: { data: {
status: "COMPLETED", status: validationEnabled ? "RUNNING" : "COMPLETED",
progress: 1, progress: 1,
finishedAt: new Date(), ...(validationEnabled ? {} : { finishedAt: new Date() }),
exitCode: 0, exitCode: 0,
logFileKey: body.logFileKey ?? job.logFileKey, logFileKey: body.logFileKey ?? job.logFileKey,
logTail: body.logTail?.slice(-20000) ?? job.logTail, logTail: body.logTail?.slice(-20000) ?? job.logTail,
@@ -277,8 +293,25 @@ export async function completeRender(
...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}), ...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
}; };
// The EXR render is only the first stage: hand off to the preview stage // The EXR render is only the first stage. Validation comes next when
// (headless AE rebuild → delivery MOV + review MP4) when it is enabled. // 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"); const previewEnabled = await getConfigBoolean("preview.enabled");
if (previewEnabled) { if (previewEnabled) {
const manifest = await buildPreviewManifest(exportRow); const manifest = await buildPreviewManifest(exportRow);
+64
View File
@@ -9,6 +9,20 @@ import { z } from "zod";
* generates them from the shot's exrOutput convention and the NEW version * generates them from the shot's exrOutput convention and the NEW version
* number it decides (§6.4 E1). * 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<typeof plateRefSchema>;
export const renderManifestSchema = z export const renderManifestSchema = z
.object({ .object({
manifestVersion: z.number().int().min(1).default(1), manifestVersion: z.number().int().min(1).default(1),
@@ -37,6 +51,30 @@ export const renderManifestSchema = z
timecodeStart: z.string().optional(), timecodeStart: z.string().optional(),
}) })
.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 preview: z
.object({ .object({
template: z.string().optional(), template: z.string().optional(),
@@ -50,3 +88,29 @@ export const renderManifestSchema = z
}); });
export type RenderManifest = z.infer<typeof renderManifestSchema>; export type RenderManifest = z.infer<typeof renderManifestSchema>;
/**
* 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}` };
}
+173
View File
@@ -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<typeof qcSubmitSchema>;
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 })),
})),
};
}
+22 -4
View File
@@ -29,6 +29,21 @@ export async function reapExpiredLeases() {
where: { id: job.id }, where: { id: job.id },
data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" }, 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.export) return;
if (job.attempt < job.maxAttempts) { if (job.attempt < job.maxAttempts) {
@@ -42,16 +57,19 @@ export async function reapExpiredLeases() {
manifest: job.manifest as Prisma.InputJsonValue, 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", { await applyTransition(tx, job.export, "QUEUED", {
type: "SYSTEM", 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", { await applyTransition(tx, job.export, "RENDER_FAILED", {
type: "SYSTEM", 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`,
}); });
} }
}); });
+8 -6
View File
@@ -5,22 +5,24 @@ import { PipelineError } from "./errors";
* Export lifecycle state machine (RenderPipeline2 §4). * Export lifecycle state machine (RenderPipeline2 §4).
* The server is the sole authority on state; workers *request* transitions. * The server is the sole authority on state; workers *request* transitions.
* *
* NOTE (Phase 2 interim): RENDERING → READY_FOR_QC is temporarily legal. * RENDERING → GENERATING_PREVIEW / READY_FOR_QC are the `validation.enabled=false`
* Phase 3 inserts VALIDATING between them (§17.3) — remove READY_FOR_QC from * paths (the Phase 3 kill switch, §8), not interim edges: with validation on,
* RENDERING's list when the validation engine ships. * a completed render always goes RENDERING → VALIDATING first.
*/ */
export const EXPORT_TRANSITIONS: Record<ExportStatus, ExportStatus[]> = { export const EXPORT_TRANSITIONS: Record<ExportStatus, ExportStatus[]> = {
QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only
RENDERING: [ RENDERING: [
"VALIDATING", "VALIDATING",
"GENERATING_PREVIEW", // interim: preview stage runs before validation exists "GENERATING_PREVIEW", // validation disabled
"RENDER_FAILED", "RENDER_FAILED",
"QUEUED", // lease expiry / retryable fail with attempts remaining (T5) "QUEUED", // lease expiry / retryable fail with attempts remaining (T5)
"CANCELLED", "CANCELLED",
"READY_FOR_QC", // preview disabled / manual mark-done "READY_FOR_QC", // preview + validation disabled / manual mark-done
], ],
RENDER_FAILED: ["QUEUED", "SUPERSEDED"], 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"], VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"],
GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"], GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"],
PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"], PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
+284
View File
@@ -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<typeof validationReportSchema>;
/**
* 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<Record<string, ValidationStatus>> {
const raw = await getConfigValue("validation.overrides");
if (!raw?.trim()) return {};
try {
const parsed = JSON.parse(raw) as Record<string, string>;
const out: Record<string, ValidationStatus> = {};
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<boolean> {
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,
})),
};
}
@@ -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;
+181 -87
View File
@@ -215,40 +215,40 @@ enum AttachmentCategory {
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
passwordHash String? passwordHash String?
role Role @default(ARTIST) role Role @default(ARTIST)
isActive Boolean @default(true) isActive Boolean @default(true)
mustChangePassword Boolean @default(false) mustChangePassword Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// NextAuth relations // NextAuth relations
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
// App relations // App relations
producedProjects Project[] @relation("ProducerProjects") producedProjects Project[] @relation("ProducerProjects")
supervisedProjects Project[] @relation("SupervisorProjects") supervisedProjects Project[] @relation("SupervisorProjects")
assignedShots Shot[] @relation("ArtistShots") assignedShots Shot[] @relation("ArtistShots")
uploadedVersions Version[] uploadedVersions Version[]
comments Comment[] comments Comment[]
commentReplies CommentReply[] commentReplies CommentReply[]
annotations Annotation[] annotations Annotation[]
approvals Approval[] approvals Approval[]
notifications Notification[] notifications Notification[]
clientAccess ClientAccess[] clientAccess ClientAccess[]
assignedTasks Task[] @relation("TaskArtist") assignedTasks Task[] @relation("TaskArtist")
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
sharedVersions Version[] @relation("VersionSharedBy") sharedVersions Version[] @relation("VersionSharedBy")
ledAssets Asset[] @relation("AssetLead") ledAssets Asset[] @relation("AssetLead")
createdShootDays ShootDay[] @relation("ShootDayCreator") createdShootDays ShootDay[] @relation("ShootDayCreator")
createdTakes Take[] @relation("TakeCreator") createdTakes Take[] @relation("TakeCreator")
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader") uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
@@map("users") @@map("users")
} }
@@ -360,7 +360,8 @@ model Project {
/// Per-production delivery naming/layout templates (RenderPipeline2 §11.2); /// Per-production delivery naming/layout templates (RenderPipeline2 §11.2);
/// falls back to SystemConfig defaults when null /// falls back to SystemConfig defaults when null
deliveryConfig Json? deliveryConfig Json?
deliveryPackages DeliveryPackage[]
@@map("projects") @@map("projects")
} }
@@ -380,60 +381,60 @@ model EpisodeDueDate {
} }
model Shot { model Shot {
id String @id @default(cuid()) id String @id @default(cuid())
shotCode String shotCode String
scene String @default("") scene String @default("")
episode String? episode String?
shotNumber Int @default(0) shotNumber Int @default(0)
sequence String? sequence String?
description String? @db.Text description String? @db.Text
notes String? @db.Text notes String? @db.Text
status ShotStatus @default(WAITING) status ShotStatus @default(WAITING)
priority ShotPriority @default(NORMAL) priority ShotPriority @default(NORMAL)
artistId String? artistId String?
projectId String projectId String
frameStart Int? frameStart Int?
frameEnd Int? frameEnd Int?
fps Float @default(24) fps Float @default(24)
dueDate DateTime? dueDate DateTime?
thumbnailUrl String? thumbnailUrl String?
originalFootageUrl String? originalFootageUrl String?
originalFootageKey String? originalFootageKey String?
shotGroupId String? shotGroupId String?
shotApprovalStatus ShotApprovalStatus @default(PENDING) shotApprovalStatus ShotApprovalStatus @default(PENDING)
sharedWithClient Boolean @default(false) sharedWithClient Boolean @default(false)
// EDL / pull CSV fields // EDL / pull CSV fields
sourceClip String? sourceClip String?
timecodeStart String? timecodeStart String?
timecodeEnd String? timecodeEnd String?
clipDuration String? clipDuration String?
exrOutput String? exrOutput String?
// Sequence / picture lock timecodes // Sequence / picture lock timecodes
seqTimecodeStart String? seqTimecodeStart String?
seqTimecodeEnd String? seqTimecodeEnd String?
// High-res deliverable (stored in Hetzner Object Storage) // High-res deliverable (stored in Hetzner Object Storage)
highResKey String? highResKey String?
highResFilename String? highResFilename String?
// Shot-level version tracking (e.g. v001, v002) // Shot-level version tracking (e.g. v001, v002)
shotVersion String @default("v001") shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation // 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 // Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String? slateType String?
slateDescription String? @db.Text slateDescription String? @db.Text
slateVfxScope String? @db.Text slateVfxScope String? @db.Text
slateSubmissionNote String? @db.Text slateSubmissionNote String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
artist User? @relation("ArtistShots", fields: [artistId], references: [id]) artist User? @relation("ArtistShots", fields: [artistId], references: [id])
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id]) shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
versions Version[] versions Version[]
tasks Task[] tasks Task[]
footagePlates FootagePlate[] footagePlates FootagePlate[]
references ShotReference[] references ShotReference[]
loggedTakes Take[] @relation("TakeToShot") loggedTakes Take[] @relation("TakeToShot")
exports Export[] exports Export[]
@@unique([projectId, shotCode]) @@unique([projectId, shotCode])
@@ -565,7 +566,7 @@ model Annotation {
commentId String? commentId String?
authorId String? authorId String?
frameNumber Int frameNumber Int
drawingData Json // Array of AnnotationShape objects drawingData Json // Array of AnnotationShape objects
color String @default("#ef4444") color String @default("#ef4444")
isVisible Boolean @default(true) isVisible Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -597,7 +598,7 @@ model Notification {
type NotificationType type NotificationType
title String title String
message String @db.Text message String @db.Text
data Json? // Extra context (versionId, shotCode, etc.) data Json? // Extra context (versionId, shotCode, etc.)
isRead Boolean @default(false) isRead Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -647,13 +648,13 @@ model Task {
scheduledStartDate DateTime? scheduledStartDate DateTime?
scheduledEndDate DateTime? scheduledEndDate DateTime?
scheduleNotes String? @db.Text scheduleNotes String? @db.Text
shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade) shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id]) assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id])
createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
versions Version[] versions Version[]
@@index([scheduledStartDate]) @@index([scheduledStartDate])
@@ -692,9 +693,9 @@ model SystemConfig {
/// Physical display event log — polled by ESP32 dot-matrix display /// Physical display event log — polled by ESP32 dot-matrix display
model DisplayEvent { model DisplayEvent {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type String // APPROVED | CHANGES | COMMENT | REJECTED type String // APPROVED | CHANGES | COMMENT | REJECTED
shotCode String shotCode String
by String // actor name (reviewer / commenter) by String // actor name (reviewer / commenter)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([createdAt]) @@index([createdAt])
@@ -716,8 +717,8 @@ model ShootDay {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull) createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull)
setups Setup[] setups Setup[]
@@map("shoot_days") @@map("shoot_days")
@@ -747,9 +748,9 @@ model Take {
pipelineShotId String? pipelineShotId String?
// General // General
scene String? scene String?
shotLabel String? shotLabel String?
unitLabel String? unitLabel String?
// Camera // Camera
cameraLetter String? cameraLetter String?
@@ -880,8 +881,11 @@ model Export {
submittedByName String? // free text from panel config as fallback submittedByName String? // free text from panel config as fallback
supersededById String? // newer Export that replaced this one supersededById String? // newer Export that replaced this one
renderJobs RenderJob[] renderJobs RenderJob[]
events ExportEvent[] events ExportEvent[]
validations ValidationResult[]
qcReviews QCReview[]
deliveryItems DeliveryItem[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -984,6 +988,96 @@ model WorkerHeartbeat {
@@map("worker_heartbeats") @@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 { model SketchTemplate {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String