diff --git a/.gitignore b/.gitignore index 057b60a..a8c6cae 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# Local tooling -RenderWorker/ +# RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/, +# publish/) — the worker's source is tracked like the rest of the repo. diff --git a/EXT_API_REFERENCE.md b/EXT_API_REFERENCE.md index cb484e8..397b1ac 100644 --- a/EXT_API_REFERENCE.md +++ b/EXT_API_REFERENCE.md @@ -24,6 +24,30 @@ X-Api-Key: | `POST` | `/api/ext/shots` | Create a new shot | | `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) | +### Render pipeline (RenderPipeline2) + +| # | Method | Path | Caller | Description | +|---|--------|------|--------|-------------| +| E1 | `POST` | `/api/ext/exports` | panel | Queue Export — creates Export + RenderJob | +| E2 | `GET` | `/api/ext/exports/latest` | panel | Latest export + live status for the panel header | +| E3 | `GET` | `/api/ext/exports/{exportId}` | panel/worker | Export detail incl. validations and QC | +| E4 | `POST` | `/api/ext/exports/{exportId}/qc` | panel | QC Pass / QC Fail | +| E5 | `GET` | `/api/ext/qc/queue` | panel | Exports in READY_FOR_QC | +| E6 | `POST` | `/api/ext/workers/register` | worker | Register machine, receive tuning | +| E7 | `POST` | `/api/ext/workers/{machineId}/heartbeat` | worker | Heartbeat + cancel channel | +| E8 | `POST` | `/api/ext/render/jobs/claim` | worker | Atomically claim the next job | +| E9 | `PATCH` | `/api/ext/render/jobs/{jobId}/progress` | worker | Progress/ETA; renews the lease | +| E10 | `POST` | `/api/ext/render/jobs/{jobId}/fail` | worker | Report failure | +| E11 | `POST` | `/api/ext/render/jobs/{jobId}/complete-render` | worker | aerender finished → VALIDATING | +| E12 | `POST` | `/api/ext/render/jobs/{jobId}/validation` | worker | Report all validation results | +| E13 | `POST` | `/api/ext/render/jobs/{jobId}/finalize` | worker | Preview artifacts done → READY_FOR_QC | +| E14 | `POST` | `/api/ext/exports/{exportId}/retry` | panel | Retry a failed export | +| E20 | `POST` | `/api/ext/render/jobs/{jobId}/artifact-presign` | worker | Presigned upload URL | +| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | worker | Report a DELIVERY_BUILD result | + +Session-authenticated twins for the web UI live under `/api/render/*` and +`/api/deliveries/*` — same service layer, no API key. + --- ## Shot Status Values @@ -513,3 +537,9 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`: | Populate Shot dropdown | `GET /api/ext/projects/{PROJECT_CODE}/shots?episode={value}` | | All shot operations (overlay, queue, preview, delivery) | `GET /api/ext/shots/lookup?shotCode={code}&projectCode={PROJECT_CODE}` | | Increment Shot Version | `PATCH /api/ext/shots/{shot.id}` with `{ "shotVersion": "v00X" }` | +| Queue Export | `POST /api/ext/exports` (E1) | +| Refresh Status / poll | `GET /api/ext/exports/latest` (E2) | +| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) | +| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` | +| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) | +| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) | diff --git a/RenderPipeline2.md b/RenderPipeline2.md index 9c97af4..207c098 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -579,6 +579,19 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m "alpha": false, "timecodeStart": "00:59:49:12" }, + "source": { + "startSource": "footage metadata", + "handleFrames": 8, + "timingPlate": { + "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr", + "frameStart": 1001, + "frameEnd": 1120 + }, + "plates": [ + { "element": "BG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 }, + { "element": "FG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_FG01_TT_v001/UNG_106_010_020_FG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 } + ] + }, "preview": { "template": "default", "burnins": true @@ -588,6 +601,14 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m The server validates the manifest with Zod, cross-checks `shotId`/`shotCode`/frame range against the DB, and **the server (not the panel) decides the new version number** — race-free single source of truth. +**`source` (required from manifestVersion 2)** — the plate provenance the timecode check depends on (§8.1a). The panel resolves these from the *comp's own footage layers*, not by re-deriving paths, so the manifest records what was actually comped: + +- `timingPlate` — the element the comp's start time was anchored to (the panel's `primaryFootage`: longest duration, first on tie). A shot's elements routinely carry unrelated camera timecodes, so "the plate" must be named explicitly rather than guessed by the worker. +- `startSource` — verbatim from the panel's existing start-frame resolution: `"footage metadata"` (probed from the plate, trustworthy) or `"API timecode …"` (fallback, suspect). This is known at queue time, so an export built from the API fallback can be flagged **before** it burns render hours. +- `plates[]` — every element imported into the comp, for the record and for cross-element diagnostics. + +Manifests without `source` (manifestVersion 1) are still accepted; the timecode check degrades per §8.1a. + ### 6.3 Endpoint summary | # | Method | Path | Auth | Caller | Purpose | @@ -1009,11 +1030,47 @@ Runs on the worker immediately after a successful render (Export = VALIDATING). | `exr_type` | header: scanline vs tiled, compression | ≠ `expected.exrCompression` (WARN if compression differs but readable; FAIL if not valid EXR) | | `alpha_channel` | channel list contains A | presence ≠ `expected.alpha` | | `colourspace` | EXR metadata (`chromaticities` / ACES container flag / colorspace attr written by AE OCIO) | ≠ `expected.colorspace` (working space is linear ACES2065-1 per 18.1-Q4); `colorPipeline` is required per project (18.1-Q12), so SKIPPED only on legacy projects predating pipeline enablement | -| `timecode_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic, or first frame ≠ `expected.timecodeStart` when set | +| `timecode_plate_match` | **primary check — see §8.1a** | render `timeCode` ≠ the timing plate's `timeCode` at the corresponding frame | +| `timecode_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic (gap, repeat, or frozen value) | +| `timecode_fps` | EXR `framesPerSecond` rational + drop-frame flag on render vs timing plate vs manifest `fps` | any disagreement | | `zero_byte / corrupt` | every file size > 0; oiiotool header-read on sampled frames (first, last, every Nth) | any zero-byte or unreadable file | Full-sequence checks (count, gaps, names, sizes) run on every file; header checks sample first/middle/last + every 25th frame (fast, catches systemic issues; per-frame full decode is not worth minutes per shot — tunable via `SystemConfig: validation.sampleEvery`). +### 8.1a Timecode: the primary check (studio decision) + +Timecode is the check this engine exists for. It is validated **against the source plate**, never against the API, because the API and the plate do not agree by design. + +**Measured on the live show (2026-08-13, 85 rendered sequences + 106 delivered sequences on `V:/`):** + +- AE-written EXRs *do* carry `timeCode` (plus a `timeCodeString` mirror) and `framesPerSecond` — the §17.3 "metadata may be absent" risk does not apply to the studio's current EXR output module. A render whose TC is absent means the output module changed, and is a FAIL. +- `Shot.timecodeStart` from the API is the **editorial in-point**; the plate on disk starts `handleFrames` earlier. Measured on three shots, the API value was consistently the plate's TC **+8 frames** — the exact handle count. Validating a render against the API would therefore **pass the defective exports and fail the correct ones**: on `UNG_102_019_010` the correctly-built render (`18:58:57:12`, matching its BG01 plate) differs from the API value (`18:58:57:20`), while on `UNG_102_040_010` the *defective* render matches the API exactly and is 8 frames off its plate. +- 95 of 106 delivered sequences match their plate's timecode exactly. The 11 that do not fall into three classes this check catches: **+8 handle offset** (comp anchored to the editorial in-point instead of the plate head), **frame-derived TC** (`00:00:41:17` = frame 1001 at 24 fps, i.e. a comp with no source timecode at all — also seen as `00:00:00:00`), and **one timecode shared across four different shots** in a single delivery package. + +**Reference precedence** — the check records which source it used and never silently substitutes a weaker one: + +| Rank | Source | Verdict semantics | +|---|---|---| +| 1 | `source.timingPlate` from the manifest — the element the comp was actually anchored to | PASS / FAIL, authoritative | +| 2 | Plate re-derived on the worker from the footage root + shot code (manifestVersion 1, or a timing plate that no longer resolves). If elements disagree, the one matching the render's frame count wins; still ambiguous → WARN, never PASS | PASS / FAIL, `reference: "derived"` | +| 3 | API `Shot.timecodeStart` **minus `handleFrames`** | **WARN only, never PASS** — `reference: "api"`, message states the plate could not be read | + +Rank 3 is a diagnostic, not a verification: it depends on the show's handle count being correct for that specific plate, which is exactly the assumption the defects above violate. + +**Comparison, given the plate reference:** + +``` +expectedTc = plateTc + (render.frameStart - plate.frameStart) // frames, at manifest fps +``` + +The offset term matters — a shot may render a sub-range of a long plate (observed: 37 rendered frames from a 709-frame plate). Sampled frames must then increment by exactly one frame each; a constant value across the sequence is the frame-derived-TC defect, not a rounding artifact. `timeCodeString` is cross-checked against the packed `timeCode` as a writer sanity check. + +**Queue-time pre-flight.** The panel already knows whether it read the start time from the plate's metadata or fell back to the API (`source.startSource`). When the manifest says the API fallback was used, the Export is flagged at queue time — the render is very likely to fail this check, and there is no point spending machine hours to find out. + +**Delivery-package cross-check (Phase 5).** Within one package, two shots sharing a first-frame timecode is a FAIL on the package: it is cheap, and it is precisely the defect that reached a built delivery package above. + +**Tooling note.** `timeCode`, `timeCodeString` and `framesPerSecond` live in the EXR header, so this check needs one 64 KB read per sampled frame and no pixel decode — the header parse is ~40 lines (attribute list: `name\0 type\0 int32 size data`; `timeCode` is two `uint32`, the first packed SMPTE 12M BCD, which reads back as its own hex digits: `0x18585712` → `18:58:57:12`). It does **not** depend on oiiotool, so the timecode check can ship ahead of the OIIO-based resolution/bit-depth/channel checks. + ### 8.2 Semantics - Any FAIL → whole run FAIL → Export = VALIDATION_FAILED, pipeline stops, exact reasons in `ValidationResult` rows (E12 example above shows a missing-frame report). @@ -1126,7 +1183,11 @@ Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shot ### 11.3 Hard links vs copying -**Recommendation: hard link when `deliveryRoot` and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible).** Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory). Post-link verification: link target identity check; post-copy verification: size + xxHash against validation-time values. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation. +**Recommendation: hard link when `deliveryRoot` and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible).** Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory). + +**Verified on the studio volume (2026-08-13):** `V:` is `\\TRUENAS\vfx` over SMB, and hard links work on it — `mklink /H` succeeds and a write through one name is visible through the other. Renders (`V:/_EXPORTS/UNG/{ep}/{shot}/{ver}`) and the delivery root (`V:/_EXPORTS/UNG/DELIVERY`) are on the same share, so the fast path applies to real deliveries today. + +**Implementation trap:** `fsutil hardlink list` fails over SMB (`Error 50: The request is not supported`), so the builder **cannot enumerate links to verify them** on a network volume. Verify instead by hashing the delivery-side file and comparing against the validation-time xxHash — that reads the same bytes whether the entry is a link or a copy, so one verification path covers both modes. Post-copy verification is the same check plus size. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation. ### 11.4 Manifest and delivery report @@ -1278,7 +1339,7 @@ Five independently deployable phases. Each phase leaves the previous workflow fu | API | E12; transitions T3→T7/T8 activated (Phase 2 interim: complete-render goes straight to a provisional READY_FOR_QC; Phase 3 inserts VALIDATING between) | | UI | Validation table on Export detail; Validation Results browser; failure badge in queue | | Worker | Validation engine (oiiotool integration, all Section 8.1 checks, checksums); oiiotool bundled with installer | -| Risks | Colourspace/timecode metadata may be absent in AE-written EXRs → those checks SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`) | +| Risks | ~~Timecode metadata may be absent in AE-written EXRs~~ — disproved on the live show (§8.1a): AE writes `timeCode`, so absence is now itself a FAIL. Colourspace metadata may still be absent → that check SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`). **The timecode check is the phase's priority and ships first** — it needs no oiiotool, and 11 of 106 delivered sequences on the current show would have been caught by it | | Testing | Golden EXR fixture sets: complete, gapped, duplicate, wrong-res, wrong-depth, no-alpha, corrupt, zero-byte; checksum determinism; large-sequence perf (500 frames < 60 s) | | Migration | Additive; existing in-flight exports unaffected (transition map keys off manifest version) | diff --git a/RenderWorker/README.md b/RenderWorker/README.md index 27578a8..7877253 100644 --- a/RenderWorker/README.md +++ b/RenderWorker/README.md @@ -60,8 +60,47 @@ the artist's AEP (then it is pure `aerender`), or the ffmpeg engine. are never resumed — the next attempt clears its own output files and re-renders. - Durable reporting: complete/fail reports spool to disk and replay in order with backoff — rendering continues while the server is down. +- **Validation (Phase 3)**: after a successful render the worker checks the sequence it just + produced and reports every result in one call (E12). See below. -Preview generation (Phase 4) and validation (Phase 3) plug into this same service later. +## Validation stage + +Runs on the worker straight after the render, while it still holds the job and the files are +local. The Export sits in `VALIDATING` until the report lands; the server decides what the +results mean (any FAIL → `VALIDATION_FAILED`, otherwise the preview stage is queued). + +Checks: `file_count`, `missing_frames`, `duplicate_frames`, `first_frame`, `last_frame`, +`filename_consistency`, `zero_byte`, `corrupt`, `resolution`, `bit_depth`, `alpha_channel`, +`exr_type`, `timecode_plate_match`, `timecode_consistency`, `timecode_fps`. + +**No oiiotool needed.** Everything is read from the EXR header — one 64 KB read per sampled +frame, no pixel decode. Full-sequence checks (count, gaps, names, sizes) touch every file; +header checks sample first/middle/last plus every Nth frame (`validation.sampleEvery`). + +**Timecode is the primary check** (spec §8.1a) and is compared against *the plate*, in this +order: the timing plate named in the manifest → a plate found under `plateSearchRoots` → +the API timecode minus handles, which is **WARN-only** because the API stores the editorial +in-point and is a handle-length ahead of the plate by design. + +Checksums are xxHash64 per file, with a sequence digest stored on the Export and reused by +the delivery builder to verify what it packaged. + +Server-side switches (SystemConfig, no worker redeploy): `validation.enabled`, +`validation.sampleEvery`, `validation.handleFrames`, and `validation.overrides` for per-check +severity, e.g. `{"exr_type":"warn"}`. + +### Checking a render by hand + +```powershell +.\VFXReviewWorker.exe --validate "V:\_EXPORTS\UNG\102\UNG_102_019_010\v005" --plate "V:\_FOOTAGE\UNG\UNG_102\UNG_102_019_010_BG01_TT_v001\UNG_102_019_010_BG01_TT_v001.01001.exr" +``` + +Runs the whole engine over an existing folder with no server, job or queue, and prints the +report. The frame range, pattern and resolution are inferred from the files. Exits non-zero +if any check fails, so it also works in a script. Useful for auditing work that predates the +pipeline, and for proving the checks on real shots before turning the stage on. + +Preview generation (Phase 4) plugs into this same service. ## Packaging for another machine @@ -117,6 +156,7 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8): "aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe", "aeVersion": "24.3", "ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe", + "plateSearchRoots": ["V:/_FOOTAGE/UNG"], "pathMappings": [ { "from": "//SAN/", "to": "S:/" } ] @@ -124,7 +164,9 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8): ``` `pathMappings` translate the manifest's canonical UNC paths to this machine's drive -mappings. Everything tunable (poll interval, lease, stall timeout, max attempts) lives in +mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no +timing plate (older panel versions), to find the shot's plates for the timecode check. +Everything tunable (poll interval, lease, stall timeout, max attempts, validation) lives in the server's SystemConfig and arrives at registration — no per-machine tuning files. ## Run interactively (first-time smoke test) diff --git a/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs new file mode 100644 index 0000000..13ad93b --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/DeliveryStageTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging.Abstractions; +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// Packaging is the last step before files leave the studio, so the parts that +/// could silently ship the wrong thing — checksum verification, frame-count +/// drift, hard-link vs copy — are pinned here. +/// +public class DeliveryStageTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_delivery_{Guid.NewGuid():N}"); + private readonly string _sourceDir; + private readonly string _packageDir; + + public DeliveryStageTests() + { + _sourceDir = Path.Combine(_root, "renders", "SHOT_001", "v001"); + _packageDir = Path.Combine(_root, "DELIVERY", "101", "260813_Delivery"); + Directory.CreateDirectory(_sourceDir); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void WriteFrames(int count, int startFrame = 1001) + { + for (var i = 0; i < count; i++) + { + var name = $"SHOT_001_cmp_TT_v001.{startFrame + i:D5}.exr"; + File.WriteAllText(Path.Combine(_sourceDir, name), $"frame {startFrame + i} pixels"); + } + } + + private DeliveryManifest Manifest(string? checksum = null, int fileCount = 3, string copyMode = "auto") => new() + { + PackageId = "pkg1", + PackageName = "260813_Delivery", + PackageNumber = 1, + Episode = "101", + PackageDir = _packageDir, + ReportFile = Path.Combine(_packageDir, "260813_Delivery_report.csv"), + CopyMode = copyMode, + Items = new List + { + new() + { + ItemId = "item1", + ExportId = "exp1", + ShotCode = "SHOT_001", + VersionString = "v001", + SourceDir = _sourceDir, + OutputPattern = "SHOT_001_cmp_TT_v001.[#####].exr", + FrameStart = 1001, + FrameEnd = 1000 + fileCount, + FileCount = fileCount, + Checksum = checksum, + ShotDir = Path.Combine(_packageDir, "SHOT_001"), + }, + }, + }; + + private DeliveryStage NewStage() => new( + new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" }, + new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger.Instance), + NullLogger.Instance); + + [Fact] + public async Task PackagesFramesAndWritesReport() + { + WriteFrames(3); + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.True(result.Ok); + var item = Assert.Single(result.Items); + Assert.Equal("VERIFIED", item.Status); + Assert.Equal(3, item.FileCount); + + var delivered = Directory.GetFiles(Path.Combine(_packageDir, "SHOT_001"), "*.exr"); + Assert.Equal(3, delivered.Length); + Assert.True(File.Exists(Path.Combine(_packageDir, "manifest.json"))); + + var report = File.ReadAllText(Path.Combine(_packageDir, "260813_Delivery_report.csv")); + Assert.Contains("Shot,Version,FrameStart", report); + Assert.Contains("SHOT_001,v001,1001,1003,3,3", report); + } + + [Fact] + public async Task HardLinksOnTheSameVolumeSoNoExtraBytesAreWritten() + { + WriteFrames(2); + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2), "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.Equal("hardlink", result.Items[0].LinkMode); + + // A hard link is the same file: writing through one name is visible via + // the other. That is what makes packaging near-instant and free. + var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr"); + var linked = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr"); + File.WriteAllText(source, "rewritten"); + Assert.Equal("rewritten", File.ReadAllText(linked)); + } + + [Fact] + public async Task AlwaysCopyModeProducesIndependentFiles() + { + WriteFrames(2); + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2, copyMode: "always-copy"), + "machine1", CancellationToken.None, CancellationToken.None); + + Assert.Equal("copy", result.Items[0].LinkMode); + + var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr"); + var copied = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr"); + File.WriteAllText(source, "rewritten"); + Assert.NotEqual("rewritten", File.ReadAllText(copied)); + } + + [Fact] + public async Task ChecksumMismatchFailsTheItem() + { + WriteFrames(3); + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, + Manifest(checksum: "deadbeefdeadbeef"), "machine1", CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal("FAILED", result.Items[0].Status); + Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!); + } + + [Fact] + public async Task FrameCountDriftSinceQcFailsTheItem() + { + // The export recorded 3 frames at validation; the folder now has 2. + WriteFrames(2); + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 3), "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Contains("the render changed since QC", result.Items[0].ErrorMessage!); + } + + [Fact] + public async Task MissingSourceFolderFailsWithThePath() + { + var manifest = Manifest(); + manifest.Items[0].SourceDir = Path.Combine(_root, "does-not-exist"); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Contains("Source folder not found", result.Items[0].ErrorMessage!); + } + + [Fact] + public async Task ChecksumMatchesTheValidationTimeDigestForIdenticalContent() + { + WriteFrames(3); + var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1", + CancellationToken.None, CancellationToken.None); + + // Feeding the recorded digest back in must verify, which is exactly what + // the delivery builder does against Export.checksum. + Directory.Delete(_packageDir, recursive: true); + var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, + Manifest(checksum: first.Items[0].Checksum), "machine1", CancellationToken.None, CancellationToken.None); + + Assert.True(second.Ok); + } +} diff --git a/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs b/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs new file mode 100644 index 0000000..abefa23 --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/ExrHeaderTests.cs @@ -0,0 +1,193 @@ +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// The timecode check is the reason the validation engine exists (§8.1a), so +/// the header parse and the timecode arithmetic are pinned here against a +/// synthetic EXR built byte by byte. +/// +public class ExrHeaderTests +{ + [Fact] + public void ReadsTimecodeResolutionChannelsAndFps() + { + var path = ExrFixture.Write(timecode: "18:58:57:12", width: 1920, height: 1080, + channels: new[] { ("A", 1), ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 4); + try + { + var header = ExrHeader.Read(path); + + Assert.Equal(new Timecode(18, 58, 57, 12, false), header.Timecode); + Assert.Equal(1920, header.DataWidth); + Assert.Equal(1080, header.DataHeight); + Assert.Equal(24, header.FramesPerSecond); + Assert.Equal("half", header.BitDepth); + Assert.True(header.HasAlpha); + Assert.Equal("PIZ", header.CompressionName); + } + finally { File.Delete(path); } + } + + [Fact] + public void MissingTimecodeIsReportedAsAbsentNotAsZero() + { + var path = ExrFixture.Write(timecode: null, width: 1920, height: 1080, + channels: new[] { ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 3); + try + { + var header = ExrHeader.Read(path); + Assert.Null(header.Timecode); + Assert.False(header.HasAlpha); + } + finally { File.Delete(path); } + } + + [Fact] + public void NonExrFileThrows() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "this is not an exr at all, not even close"); + Assert.Throws(() => ExrHeader.Read(path)); + Assert.Null(ExrHeader.TryRead(path)); + } + finally { File.Delete(path); } + } + + [Theory] + // The BCD packing means the raw value reads back as its own hex digits. + [InlineData(0x18585712u, 18, 58, 57, 12)] + [InlineData(0x00004117u, 0, 0, 41, 17)] + [InlineData(0x00000000u, 0, 0, 0, 0)] + public void DecodesPackedSmpteTimecode(uint packed, int hh, int mm, int ss, int ff) + { + Assert.Equal(new Timecode(hh, mm, ss, ff, false), Timecode.FromPacked(packed)); + } + + [Fact] + public void FrameArithmeticRoundTrips() + { + var tc = new Timecode(18, 58, 57, 12, false); + Assert.Equal(tc, Timecode.FromFrames(tc.ToFrames(24), 24)); + + // 133 frames on from 18:58:57:12 at 24 fps is 18:59:03:01 — the real + // first/last frame pair measured on UNG_102_019_010 v005. + Assert.Equal(new Timecode(18, 59, 3, 1, false), Timecode.FromFrames(tc.ToFrames(24) + 133, 24)); + } + + [Fact] + public void ApiTimecodeIsOneHandleAheadOfThePlate() + { + // The defect class from §8.1a: the API stores the editorial in-point and + // the plate starts 8 frames earlier, so a comp anchored to the API value + // renders 8 frames late. + var plate = Timecode.Parse("13:44:06:02")!.Value; + var api = Timecode.Parse("13:44:06:10")!.Value; + Assert.Equal(8, api.ToFrames(24) - plate.ToFrames(24)); + } + + [Fact] + public void FrameDerivedTimecodeIsFrameNumberOverFps() + { + // 00:00:41:17 is frame 1001 at 24 fps — the signature of a comp built + // with no source timecode at all. + Assert.Equal(1001, Timecode.Parse("00:00:41:17")!.Value.ToFrames(24)); + } + + [Theory] + [InlineData("UNG_102_019_010_cmp_TT_v005.01001.exr", 1001)] + [InlineData("UNG_108_030_010_cmp_TT_v003.01204.exr", 1204)] + [InlineData("no_frame_number.exr", null)] + public void ParsesFrameNumbersFromFilenames(string name, int? expected) + { + Assert.Equal(expected, SequencePattern.FrameFromName(name)); + } + + [Fact] + public void ParsesOutputPattern() + { + var p = SequencePattern.FromOutputPattern("UNG_102_019_010_cmp_TT_v005.[#####].exr"); + Assert.Equal("UNG_102_019_010_cmp_TT_v005", p.Prefix); + Assert.Equal(".exr", p.Extension); + Assert.Equal(5, p.Padding); + } +} + +/// Builds a minimal but structurally real EXR header on disk. +internal static class ExrFixture +{ + public static string Write(string? timecode, int width, int height, + (string Name, int PixelType)[] channels, int fpsNum, byte compression) + { + var path = Path.Combine(Path.GetTempPath(), $"vfxr_test_{Guid.NewGuid():N}.exr"); + using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); + using var w = new BinaryWriter(fs); + + w.Write(ExrHeader.Magic); + w.Write(2); // version 2, no flags + + // channels (chlist) + using (var chData = new MemoryStream()) + using (var cw = new BinaryWriter(chData)) + { + foreach (var (name, pixelType) in channels) + { + WriteCString(cw, name); + cw.Write(pixelType); + cw.Write((byte)0); // pLinear + cw.Write(new byte[3]); // reserved + cw.Write(1); // xSampling + cw.Write(1); // ySampling + } + cw.Write((byte)0); // list terminator + WriteAttr(w, "channels", "chlist", chData.ToArray()); + } + + WriteAttr(w, "compression", "compression", new[] { compression }); + + var window = new byte[16]; + BitConverter.GetBytes(0).CopyTo(window, 0); + BitConverter.GetBytes(0).CopyTo(window, 4); + BitConverter.GetBytes(width - 1).CopyTo(window, 8); + BitConverter.GetBytes(height - 1).CopyTo(window, 12); + WriteAttr(w, "dataWindow", "box2i", window); + WriteAttr(w, "displayWindow", "box2i", window); + + var rational = new byte[8]; + BitConverter.GetBytes(fpsNum).CopyTo(rational, 0); + BitConverter.GetBytes(1u).CopyTo(rational, 4); + WriteAttr(w, "framesPerSecond", "rational", rational); + + if (timecode is not null) + { + var parts = timecode.Split(':'); + uint Bcd(int v) => (uint)((v / 10) * 16 + v % 10); + var packed = (Bcd(int.Parse(parts[0])) << 24) | (Bcd(int.Parse(parts[1])) << 16) + | (Bcd(int.Parse(parts[2])) << 8) | Bcd(int.Parse(parts[3])); + var tcData = new byte[8]; + BitConverter.GetBytes(packed).CopyTo(tcData, 0); + WriteAttr(w, "timeCode", "timecode", tcData); + WriteAttr(w, "timeCodeString", "string", System.Text.Encoding.ASCII.GetBytes(timecode)); + } + + w.Write((byte)0); // end of header + return path; + } + + private static void WriteAttr(BinaryWriter w, string name, string type, byte[] data) + { + WriteCString(w, name); + WriteCString(w, type); + w.Write(data.Length); + w.Write(data); + } + + private static void WriteCString(BinaryWriter w, string s) + { + w.Write(System.Text.Encoding.ASCII.GetBytes(s)); + w.Write((byte)0); + } +} diff --git a/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs b/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs new file mode 100644 index 0000000..5dbc487 --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/XxHash64Tests.cs @@ -0,0 +1,61 @@ +using System.Text; +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// Reference vectors from the xxHash specification. The delivery builder +/// verifies packaged files against digests recorded here at validation time, so +/// a wrong implementation would not be caught by round-tripping our own code — +/// only by pinning the published values. +/// +public class XxHash64Tests +{ + [Theory] + [InlineData("", 0xEF46DB3751D8E999UL)] + [InlineData("a", 0xD24EC4F1A98C6E5BUL)] + [InlineData("abc", 0x44BC2CF5AD770999UL)] + public void MatchesReferenceVectors(string input, ulong expected) + { + Assert.Equal(expected, XxHash64.Hash(Encoding.ASCII.GetBytes(input))); + } + + [Fact] + public void StreamingInChunksMatchesSingleShot() + { + var data = new byte[1000]; + new Random(42).NextBytes(data); + var oneShot = XxHash64.Hash(data); + + // Chunk sizes deliberately straddle the 32-byte stripe boundary. + foreach (var chunk in new[] { 1, 7, 31, 32, 33, 64, 999 }) + { + var state = new XxHash64.State(0); + for (var i = 0; i < data.Length; i += chunk) + { + state.Update(new ReadOnlySpan(data, i, Math.Min(chunk, data.Length - i))); + } + Assert.Equal(oneShot, state.Digest()); + } + } + + [Fact] + public void HashFileIsStableAndContentSensitive() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "frame data"); + var first = XxHash64.HashFile(path); + Assert.Equal(first, XxHash64.HashFile(path)); + + File.WriteAllText(path, "frame datb"); + Assert.NotEqual(first, XxHash64.HashFile(path)); + } + finally + { + File.Delete(path); + } + } +} diff --git a/RenderWorker/VFXReviewWorker/ApiClient.cs b/RenderWorker/VFXReviewWorker/ApiClient.cs index 1f31acf..6d570b8 100644 --- a/RenderWorker/VFXReviewWorker/ApiClient.cs +++ b/RenderWorker/VFXReviewWorker/ApiClient.cs @@ -71,17 +71,32 @@ public sealed class ApiClient return body?.Job; } - // E9 — best-effort; failures are swallowed by the caller (lease/reaper covers us) + // E9 — best-effort by contract: a progress report is a nicety, and losing + // one must never abort work in flight. Transport failures return null here + // rather than throwing at the call site; the lease and the reaper are what + // actually protect the job. public async Task ProgressAsync(string jobId, string machineId, double progress, int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct) { - using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress") + try { - Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json), - }; - var res = await _http.SendAsync(req, ct); - if (!res.IsSuccessStatusCode) return null; - return await res.Content.ReadFromJsonAsync(Json, ct); + using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress") + { + Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json), + }; + var res = await _http.SendAsync(req, ct); + if (!res.IsSuccessStatusCode) return null; + return await res.Content.ReadFromJsonAsync(Json, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // a real shutdown still stops us + } + catch (Exception ex) + { + _log.LogDebug(ex, "Progress report failed for job {JobId} (non-fatal)", jobId); + return null; + } } // E10 / E11 — lifecycle reports, sent via the spooler for durability. @@ -93,6 +108,8 @@ public sealed class ApiClient { "fail" => $"api/ext/render/jobs/{jobId}/fail", "complete-render" => $"api/ext/render/jobs/{jobId}/complete-render", + "validation" => $"api/ext/render/jobs/{jobId}/validation", + "delivery" => $"api/ext/render/jobs/{jobId}/delivery", "finalize" => $"api/ext/render/jobs/{jobId}/finalize", _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"), }; diff --git a/RenderWorker/VFXReviewWorker/DeliveryStage.cs b/RenderWorker/VFXReviewWorker/DeliveryStage.cs new file mode 100644 index 0000000..f8e3ff8 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/DeliveryStage.cs @@ -0,0 +1,327 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Delivery builder (RenderPipeline2 §11). Creates the dated package folder, +/// hard-links (or copies) each shot's EXRs and delivery MOV into it, verifies +/// what it wrote, and leaves a manifest + CSV report beside the shots. +/// +/// Hard links make an 18-shot EXR package near-instant and cost no extra disk. +/// They are safe here because rendered versions are immutable by pipeline +/// design — a new version is a new directory, nothing ever rewrites an EXR in +/// place. Cross-volume targets fall back to copying automatically. +/// +public sealed class DeliveryStage +{ + private readonly WorkerOptions _options; + private readonly ApiClient _api; + private readonly ILogger _log; + + public DeliveryStage(WorkerOptions options, ApiClient api, ILogger log) + { + _options = options; + _api = api; + _log = log; + } + + public async Task RunAsync( + ClaimedJob job, DeliveryManifest manifest, string machineId, CancellationToken cancelJob, CancellationToken shutdown) + { + var mapper = new PathMapper(_options.PathMappings); + var packageDir = mapper.Map(manifest.PackageDir); + var results = new List(); + var manifestFiles = new List(); + var alwaysCopy = string.Equals(manifest.CopyMode, "always-copy", StringComparison.OrdinalIgnoreCase); + + _log.LogInformation("Building delivery package {Name} ({Count} shots) at {Dir}", + manifest.PackageName, manifest.Items.Count, packageDir); + Directory.CreateDirectory(packageDir); + + for (var i = 0; i < manifest.Items.Count; i++) + { + if (cancelJob.IsCancellationRequested) break; + var item = manifest.Items[i]; + + await _api.ProgressAsync(job.Id, machineId, (double)i / manifest.Items.Count, i, manifest.Items.Count, null, + $"Packaging {item.ShotCode} {item.VersionString} ({i + 1}/{manifest.Items.Count})", shutdown); + + var result = BuildItem(item, mapper, alwaysCopy, manifestFiles); + results.Add(result); + if (result.Status == "FAILED") + { + _log.LogError("Delivery item {Shot} failed: {Error}", item.ShotCode, result.ErrorMessage); + } + } + + var ok = results.Count == manifest.Items.Count && results.All(r => r.Status == "VERIFIED"); + + // The report is the client-facing document, so it is written even for a + // partial build — it is the fastest way to see what did and did not land. + string? reportPath = null; + try + { + reportPath = WriteReport(manifest, results, mapper); + WriteManifestJson(manifest, results, manifestFiles, packageDir); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not write delivery report/manifest for {Name}", manifest.PackageName); + } + + return new DeliveryResult(ok, results, packageDir, reportPath, manifestFiles); + } + + private DeliveryItemResult BuildItem( + DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, List manifestFiles) + { + var sourceDir = mapper.Map(item.SourceDir); + var shotDir = mapper.Map(item.ShotDir); + + try + { + if (!Directory.Exists(sourceDir)) + { + return DeliveryItemResult.Failed(item, $"Source folder not found: {sourceDir}"); + } + + var pattern = SequencePattern.FromOutputPattern(item.OutputPattern); + var sources = Directory.EnumerateFiles(sourceDir) + .Where(f => Path.GetFileName(f).StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase) + && Path.GetFileName(f).EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + + if (sources.Count == 0) + { + return DeliveryItemResult.Failed(item, $"No {pattern.Extension} files matching {pattern.Prefix}* in {sourceDir}"); + } + if (item.FileCount is int expected && sources.Count != expected) + { + return DeliveryItemResult.Failed(item, + $"Source has {sources.Count} frames but the export recorded {expected} — the render changed since QC"); + } + + Directory.CreateDirectory(shotDir); + + long totalBytes = 0; + var usedCopy = false; + foreach (var src in sources) + { + var dest = Path.Combine(shotDir, Path.GetFileName(src)); + if (Place(src, dest, alwaysCopy, out var copied) is string placeError) + { + return DeliveryItemResult.Failed(item, placeError); + } + usedCopy |= copied; + totalBytes += new FileInfo(dest).Length; + } + + // Verification reads the delivered bytes back, which works the same + // whether the entry is a link or a copy — `fsutil hardlink list` is + // not available over SMB, so link identity cannot be queried. + var digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, Path.GetFileName(s)))); + if (item.Checksum is { Length: > 0 } && !string.Equals(digest, item.Checksum, StringComparison.OrdinalIgnoreCase)) + { + return DeliveryItemResult.Failed(item, + $"Checksum mismatch after packaging: expected {item.Checksum}, got {digest}"); + } + + string? movDest = null; + if (!string.IsNullOrWhiteSpace(item.DeliveryMovPath)) + { + var movSrc = mapper.Map(item.DeliveryMovPath!); + if (File.Exists(movSrc)) + { + movDest = Path.Combine(shotDir, Path.GetFileName(movSrc)); + if (Place(movSrc, movDest, alwaysCopy, out var movCopied) is string movError) + { + return DeliveryItemResult.Failed(item, movError); + } + usedCopy |= movCopied; + totalBytes += new FileInfo(movDest).Length; + } + else + { + // Not fatal: the EXRs are the deliverable, the MOV is a + // viewing copy. It is recorded so it is not silently absent. + _log.LogWarning("Delivery MOV missing for {Shot}: {Path}", item.ShotCode, movSrc); + } + } + + foreach (var src in sources) + { + var name = Path.GetFileName(src); + manifestFiles.Add(new + { + shotCode = item.ShotCode, + relativePath = Path.Combine(Path.GetFileName(shotDir), name).Replace('\\', '/'), + bytes = new FileInfo(Path.Combine(shotDir, name)).Length, + exportId = item.ExportId, + }); + } + + return new DeliveryItemResult + { + ItemId = item.ItemId, + ShotCode = item.ShotCode, + VersionString = item.VersionString, + Status = "VERIFIED", + ExrPath = shotDir, + MovPath = movDest, + FileCount = sources.Count, + TotalBytes = totalBytes, + Checksum = digest, + LinkMode = usedCopy ? "copy" : "hardlink", + FrameStart = item.FrameStart, + FrameEnd = item.FrameEnd, + }; + } + catch (Exception ex) + { + return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Hard-link when possible, copy when not. Returns null on success or the + /// error message. `copied` reports which happened, so the package records + /// what it actually did rather than what it intended. + /// + private string? Place(string source, string dest, bool alwaysCopy, out bool copied) + { + copied = false; + try + { + if (File.Exists(dest)) File.Delete(dest); + + if (!alwaysCopy && CreateHardLink(dest, source, IntPtr.Zero)) + { + return null; + } + + // Cross-volume, a filesystem without link support, or always-copy. + File.Copy(source, dest, overwrite: true); + copied = true; + return null; + } + catch (Exception ex) + { + return $"Could not place {Path.GetFileName(source)}: {ex.Message}"; + } + } + + private static string SequenceChecksum(IEnumerable files) + { + var state = new XxHash64.State(0); + foreach (var f in files) + { + state.Update(Encoding.ASCII.GetBytes(XxHash64.HashFile(f))); + } + return state.Digest().ToString("x16"); + } + + /// + /// The client-facing delivery report (§11.4): one row per shot. Columns are + /// confirmed against the production's delivery spec before the first real + /// delivery (18.2-C2), which is why this is a plain, easily-edited writer. + /// + private string WriteReport(DeliveryManifest manifest, List results, PathMapper mapper) + { + var path = mapper.Map(manifest.ReportFile); + var dir = Path.GetDirectoryName(path); + if (dir is not null) Directory.CreateDirectory(dir); + + var sb = new StringBuilder(); + sb.AppendLine("Shot,Version,FrameStart,FrameEnd,FrameCount,Files,Bytes,Checksum,MOV,Status"); + foreach (var r in results) + { + sb.AppendLine(string.Join(",", new[] + { + Csv(r.ShotCode), + Csv(r.VersionString), + r.FrameStart.ToString(CultureInfo.InvariantCulture), + r.FrameEnd.ToString(CultureInfo.InvariantCulture), + (r.FrameEnd - r.FrameStart + 1).ToString(CultureInfo.InvariantCulture), + (r.FileCount ?? 0).ToString(CultureInfo.InvariantCulture), + (r.TotalBytes ?? 0).ToString(CultureInfo.InvariantCulture), + Csv(r.Checksum ?? ""), + Csv(r.MovPath is null ? "" : Path.GetFileName(r.MovPath)), + Csv(r.Status), + })); + } + File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); + _log.LogInformation("Wrote delivery report {Path}", path); + return path; + } + + private void WriteManifestJson( + DeliveryManifest manifest, List results, List files, string packageDir) + { + var payload = new + { + package = manifest.PackageName, + packageNumber = manifest.PackageNumber, + episode = manifest.Episode, + builtAt = DateTimeOffset.UtcNow, + builtBy = Environment.MachineName, + checksumAlgo = manifest.ChecksumAlgo, + shots = results.Select(r => new + { + r.ShotCode, + r.VersionString, + r.FileCount, + r.TotalBytes, + r.Checksum, + r.LinkMode, + r.Status, + r.ErrorMessage, + }), + files, + }; + var path = Path.Combine(packageDir, "manifest.json"); + File.WriteAllText(path, JsonSerializer.Serialize(payload, WorkerOptions.JsonOpts), new UTF8Encoding(false)); + } + + private static string Csv(string value) => + value.Contains(',') || value.Contains('"') ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + + [DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); +} + +public sealed record DeliveryResult( + bool Ok, List Items, string PackageDir, string? ReportPath, List Files); + +public sealed class DeliveryItemResult +{ + public string ItemId { get; set; } = ""; + public string ShotCode { get; set; } = ""; + public string VersionString { get; set; } = ""; + public string Status { get; set; } = "PENDING"; + public string? ExrPath { get; set; } + public string? MovPath { get; set; } + public int? FileCount { get; set; } + public long? TotalBytes { get; set; } + public string? Checksum { get; set; } + public string? LinkMode { get; set; } + public string? ErrorMessage { get; set; } + public int FrameStart { get; set; } + public int FrameEnd { get; set; } + + public static DeliveryItemResult Failed(DeliveryManifestItem item, string error) => new() + { + ItemId = item.ItemId, + ShotCode = item.ShotCode, + VersionString = item.VersionString, + Status = "FAILED", + ErrorMessage = error, + FrameStart = item.FrameStart, + FrameEnd = item.FrameEnd, + }; +} diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs index 4a347b8..100bb4c 100644 --- a/RenderWorker/VFXReviewWorker/Dtos.cs +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -26,6 +26,15 @@ public sealed class ServerConfig public int LeaseSeconds { get; set; } = 300; public int MaxAttempts { get; set; } = 3; public int StallTimeoutSeconds { get; set; } = 600; + + /// + /// Validation stage (§8). The server decides whether it runs, so turning it + /// off is a config change there rather than a redeploy of every worker. + /// + public bool ValidationEnabled { get; set; } = true; + public int ValidationSampleEvery { get; set; } = 25; + /// Head handles the plates carry; only used for the API timecode fallback (§8.1a). + public int ValidationHandleFrames { get; set; } = 8; } public sealed class ClaimResponse @@ -52,6 +61,43 @@ public sealed class ClaimedJob public PreviewManifest AsPreviewManifest() => Manifest.Deserialize(WorkerOptions.JsonOpts) ?? throw new InvalidOperationException("Job manifest is not a preview manifest"); + + public DeliveryManifest AsDeliveryManifest() => + Manifest.Deserialize(WorkerOptions.JsonOpts) + ?? throw new InvalidOperationException("Job manifest is not a delivery manifest"); +} + +/// DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11). +public sealed class DeliveryManifest +{ + public string Stage { get; set; } = "DELIVERY"; + public string PackageId { get; set; } = ""; + public string PackageName { get; set; } = ""; + public int PackageNumber { get; set; } + public string Episode { get; set; } = ""; + public string PackageDir { get; set; } = ""; + public string ReportFile { get; set; } = ""; + public bool IncludeMovs { get; set; } = true; + public string CopyMode { get; set; } = "auto"; + public string ChecksumAlgo { get; set; } = "xxh64"; + public List ReportFormats { get; set; } = new(); + public List Items { get; set; } = new(); +} + +public sealed class DeliveryManifestItem +{ + public string ItemId { get; set; } = ""; + public string ExportId { get; set; } = ""; + public string ShotCode { get; set; } = ""; + public string VersionString { get; set; } = ""; + public string SourceDir { get; set; } = ""; + public string OutputPattern { get; set; } = ""; + public int FrameStart { get; set; } + public int FrameEnd { get; set; } + public int? FileCount { get; set; } + public string? Checksum { get; set; } + public string? DeliveryMovPath { get; set; } + public string ShotDir { get; set; } = ""; } /// PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9). @@ -107,9 +153,10 @@ public sealed class PreviewBuildResult public int? Height { get; set; } } -/// The Render Manifest (§6.2) — only the fields the Phase 2 render stage needs; the rest is preserved raw. +/// The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw. public sealed class RenderManifest { + public string ShotCode { get; set; } = ""; public string AepPath { get; set; } = ""; public string CompName { get; set; } = ""; public string OutputDir { get; set; } = ""; @@ -118,6 +165,11 @@ public sealed class RenderManifest public string? RenderSettingsTemplate { get; set; } public int FrameStart { get; set; } public int FrameEnd { get; set; } + public double Fps { get; set; } = 24; + public int Width { get; set; } + public int Height { get; set; } + public ManifestExpected? Expected { get; set; } + public ManifestSource? Source { get; set; } [JsonExtensionData] public Dictionary? Extra { get; set; } @@ -125,6 +177,35 @@ public sealed class RenderManifest public int TotalFrames => FrameEnd - FrameStart + 1; } +/// Per-project expectations the validation engine checks against (§8.1). +public sealed class ManifestExpected +{ + public string? Colorspace { get; set; } + public string? BitDepth { get; set; } + public string? ExrCompression { get; set; } + public bool? Alpha { get; set; } + /// The API's editorial in-point — the rank 3 timecode fallback only (§8.1a). + public string? TimecodeStart { get; set; } +} + +/// Plate provenance resolved by the panel from the comp's footage layers (§6.2). +public sealed class ManifestSource +{ + public PlateRef? TimingPlate { get; set; } + public List Plates { get; set; } = new(); + public string? StartSource { get; set; } + public int? StartFrames { get; set; } + public int? HandleFrames { get; set; } +} + +public sealed class PlateRef +{ + public string? Element { get; set; } + public string Path { get; set; } = ""; + public int? FrameStart { get; set; } + public int? FrameEnd { get; set; } +} + public sealed class ProgressResponse { public bool Ok { get; set; } diff --git a/RenderWorker/VFXReviewWorker/ExrHeader.cs b/RenderWorker/VFXReviewWorker/ExrHeader.cs new file mode 100644 index 0000000..1375ac2 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ExrHeader.cs @@ -0,0 +1,234 @@ +using System.Text; + +namespace VFXReviewWorker; + +/// +/// Minimal OpenEXR header reader (RenderPipeline2 §8.1a). +/// +/// Everything validation needs — timecode, fps, resolution, channel types, +/// compression — lives in the header, so a check costs one 64 KB read and no +/// pixel decode. That is why the engine needs no oiiotool/OpenImageIO +/// dependency and can run on a bare workstation. +/// +/// Header layout: magic(4) version(4) then attributes as +/// `name\0 type\0 int32 size data`, terminated by an empty name. +/// +public sealed class ExrHeader +{ + public const uint Magic = 20000630; + private const int MaxHeaderBytes = 1 << 18; // 256 KB — far beyond any sane header + + public int DataWidth { get; private init; } + public int DataHeight { get; private init; } + public int DisplayWidth { get; private init; } + public int DisplayHeight { get; private init; } + public IReadOnlyList Channels { get; private init; } = Array.Empty(); + public byte? CompressionCode { get; private init; } + public Timecode? Timecode { get; private init; } + public string? TimecodeString { get; private init; } + public double? FramesPerSecond { get; private init; } + public bool HasChromaticities { get; private init; } + public bool IsTiled { get; private init; } + public IReadOnlyList AttributeNames { get; private init; } = Array.Empty(); + + public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal)); + + /// half / float / uint — the deepest channel type present. + public string? BitDepth + { + get + { + if (Channels.Count == 0) return null; + if (Channels.Any(c => c.PixelType == 2)) return "float"; + if (Channels.Any(c => c.PixelType == 1)) return "half"; + return "uint"; + } + } + + public string CompressionName => CompressionCode switch + { + 0 => "none", 1 => "RLE", 2 => "ZIPS", 3 => "ZIP", 4 => "PIZ", + 5 => "PXR24", 6 => "B44", 7 => "B44A", 8 => "DWAA", 9 => "DWAB", + null => "unknown", + _ => $"code {CompressionCode}", + }; + + /// Reads the header, or throws when the file is not a readable EXR. + public static ExrHeader Read(string path) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var buf = new byte[Math.Min(MaxHeaderBytes, Math.Max(1024, (int)Math.Min(fs.Length, MaxHeaderBytes)))]; + var read = fs.Read(buf, 0, buf.Length); + if (read < 16) throw new InvalidDataException("File is too small to be an EXR"); + + var span = new ReadOnlySpan(buf, 0, read); + if (BitConverter.ToUInt32(span[..4]) != Magic) throw new InvalidDataException("Not an EXR (bad magic number)"); + var version = BitConverter.ToInt32(span.Slice(4, 4)); + + int pos = 8; + var names = new List(); + var channels = new List(); + int dataW = 0, dataH = 0, dispW = 0, dispH = 0; + byte? compression = null; + Timecode? tc = null; + string? tcString = null; + double? fps = null; + bool chroma = false; + + while (pos < read) + { + var name = ReadCString(span, ref pos); + if (name.Length == 0) break; // end of header + var type = ReadCString(span, ref pos); + if (pos + 4 > read) throw new InvalidDataException("Truncated EXR header (attribute size)"); + var size = BitConverter.ToInt32(span.Slice(pos, 4)); + pos += 4; + if (size < 0 || pos + size > read) throw new InvalidDataException($"Truncated EXR header (attribute \"{name}\")"); + var data = span.Slice(pos, size); + names.Add(name); + + switch (name) + { + case "dataWindow" when size >= 16: + dataW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1; + dataH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1; + break; + case "displayWindow" when size >= 16: + dispW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1; + dispH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1; + break; + case "compression" when size >= 1: + compression = data[0]; + break; + case "timeCode" when size >= 8: + tc = VFXReviewWorker.Timecode.FromPacked( + BitConverter.ToUInt32(data[..4]), BitConverter.ToUInt32(data.Slice(4, 4))); + break; + case "timeCodeString": + tcString = Encoding.ASCII.GetString(data).TrimEnd('\0'); + break; + case "framesPerSecond" when size >= 8: + { + var num = BitConverter.ToInt32(data[..4]); + var den = BitConverter.ToUInt32(data.Slice(4, 4)); + if (den != 0) fps = num / (double)den; + break; + } + case "chromaticities": + chroma = true; + break; + case "channels": + channels.AddRange(ReadChannels(data)); + break; + } + + pos += size; + } + + return new ExrHeader + { + DataWidth = dataW, + DataHeight = dataH, + DisplayWidth = dispW, + DisplayHeight = dispH, + Channels = channels, + CompressionCode = compression, + Timecode = tc, + TimecodeString = tcString, + FramesPerSecond = fps, + HasChromaticities = chroma, + IsTiled = (version & 0x200) != 0, + AttributeNames = names, + }; + } + + /// Header read that returns null instead of throwing — for "is this file readable at all" checks. + public static ExrHeader? TryRead(string path) + { + try { return Read(path); } + catch { return null; } + } + + private static IEnumerable ReadChannels(ReadOnlySpan data) + { + var result = new List(); + int p = 0; + while (p < data.Length) + { + var name = ReadCString(data, ref p); + if (name.Length == 0) break; // null terminator ends the channel list + if (p + 16 > data.Length) break; + var pixelType = BitConverter.ToInt32(data.Slice(p, 4)); + p += 16; // pixelType(4) + pLinear(1) + reserved(3) + xSampling(4) + ySampling(4) + result.Add(new ExrChannel(name, pixelType)); + } + return result; + } + + private static string ReadCString(ReadOnlySpan span, ref int pos) + { + int start = pos; + while (pos < span.Length && span[pos] != 0) pos++; + var s = Encoding.ASCII.GetString(span[start..pos]); + pos++; // skip the terminator + return s; + } +} + +public readonly record struct ExrChannel(string Name, int PixelType) +{ + public string TypeName => PixelType switch { 0 => "uint", 1 => "half", 2 => "float", _ => $"type {PixelType}" }; +} + +/// +/// SMPTE 12M timecode as stored in the EXR `timeCode` attribute: BCD-packed, +/// which is why the raw value reads back as its own hex digits +/// (0x18585712 → 18:58:57:12). +/// +public readonly record struct Timecode(int Hours, int Minutes, int Seconds, int Frames, bool DropFrame) +{ + public static Timecode FromPacked(uint timeAndFlags, uint _userData = 0) + { + int Bits(int shift, int mask) => (int)((timeAndFlags >> shift) & (uint)mask); + return new Timecode( + Bits(24, 0xF) + Bits(28, 0x3) * 10, + Bits(16, 0xF) + Bits(20, 0x7) * 10, + Bits(8, 0xF) + Bits(12, 0x7) * 10, + Bits(0, 0xF) + Bits(4, 0x3) * 10, + Bits(6, 0x1) == 1); + } + + public static Timecode? Parse(string? text, bool dropFrame = false) + { + if (string.IsNullOrWhiteSpace(text)) return null; + var parts = text.Trim().Split(':', ';'); + if (parts.Length < 4) return null; + if (!int.TryParse(parts[0], out var hh) || !int.TryParse(parts[1], out var mm) || + !int.TryParse(parts[2], out var ss) || !int.TryParse(parts[3], out var ff)) return null; + return new Timecode(hh, mm, ss, ff, dropFrame || text.Contains(';')); + } + + /// + /// Non-drop frame count. The studio shoots 24 fps, so drop-frame never + /// applies; a drop-frame flag mismatch is reported as its own check rather + /// than silently changing this arithmetic. + /// + public long ToFrames(double fps) + { + var rate = (long)Math.Round(fps <= 0 ? 24 : fps); + return ((Hours * 60L + Minutes) * 60L + Seconds) * rate + Frames; + } + + public static Timecode FromFrames(long frames, double fps) + { + var rate = (long)Math.Round(fps <= 0 ? 24 : fps); + if (rate <= 0) rate = 24; + var f = Math.Max(0, frames); + var ff = (int)(f % rate); + var totalSeconds = f / rate; + return new Timecode((int)(totalSeconds / 3600), (int)(totalSeconds / 60 % 60), (int)(totalSeconds % 60), ff, false); + } + + public override string ToString() => + $"{Hours:D2}:{Minutes:D2}:{Seconds:D2}{(DropFrame ? ';' : ':')}{Frames:D2}"; +} diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs index 8c0067a..9556200 100644 --- a/RenderWorker/VFXReviewWorker/JobExecutor.cs +++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs @@ -14,15 +14,20 @@ public sealed class JobExecutor private readonly ApiClient _api; private readonly AerenderRunner _runner; private readonly PreviewStage _preview; + private readonly ValidationStage _validation; + private readonly DeliveryStage _delivery; private readonly ReportSpooler _spooler; private readonly ILogger _log; - public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, ReportSpooler spooler, ILogger log) + public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, + ValidationStage validation, DeliveryStage delivery, ReportSpooler spooler, ILogger log) { _options = options; _api = api; _runner = runner; _preview = preview; + _validation = validation; + _delivery = delivery; _spooler = spooler; _log = log; } @@ -35,6 +40,12 @@ public sealed class JobExecutor return; } + if (job.Type == "DELIVERY_BUILD") + { + await RunDeliveryAsync(job, machineId, cancelJob, shutdown); + return; + } + var manifest = job.AsRenderManifest(); var mapper = new PathMapper(_options.PathMappings); var outputDirLocal = mapper.Map(manifest.OutputDir); @@ -101,6 +112,15 @@ public sealed class JobExecutor ["exrFileCount"] = fileCount, ["exrTotalBytes"] = totalBytes, }); + + // Validation runs here, while we still hold the job and the + // files are local. It is queued behind complete-render, and the + // spooler's in-order replay is what guarantees the server has + // moved the Export to VALIDATING before this lands (§8). + if (config.ValidationEnabled) + { + await RunValidationAsync(job, manifest, machineId, outputDirLocal, config, shutdown); + } } else if (result.ErrorMessage == "Cancelled") { @@ -149,6 +169,140 @@ public sealed class JobExecutor } } + /// + /// DELIVERY_BUILD job: build the dated package folder from already-QC'd + /// exports. No AE involved — folders, links and verification only. + /// + private async Task RunDeliveryAsync(ClaimedJob job, string machineId, CancellationToken cancelJob, CancellationToken shutdown) + { + var manifest = job.AsDeliveryManifest(); + + WriteState(new CurrentJobState + { + JobId = job.Id, + ExportId = null, + MachineId = machineId, + OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.PackageDir), + ClaimedAt = DateTimeOffset.UtcNow, + }); + + try + { + var result = await _delivery.RunAsync(job, manifest, machineId, cancelJob, shutdown); + + _log.LogInformation("Delivery {Name}: {Ok} ({Verified}/{Total} shots verified)", + manifest.PackageName, result.Ok ? "READY" : "FAILED", + result.Items.Count(i => i.Status == "VERIFIED"), manifest.Items.Count); + + string? reportKey = null; + if (result.ReportPath is not null) + { + reportKey = await _api.UploadArtifactAsync(job.Id, machineId, "report", result.ReportPath, "text/csv", shutdown); + } + + _spooler.Enqueue("delivery", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["ok"] = result.Ok, + ["rootPath"] = result.PackageDir, + ["reportKey"] = reportKey, + ["items"] = result.Items.Select(i => new Dictionary + { + ["itemId"] = i.ItemId, + ["status"] = i.Status, + ["exrPath"] = i.ExrPath, + ["movPath"] = i.MovPath, + ["fileCount"] = i.FileCount, + ["totalBytes"] = i.TotalBytes, + ["checksum"] = i.Checksum, + ["linkMode"] = i.LinkMode, + ["errorMessage"] = i.ErrorMessage, + }).ToList(), + ["manifest"] = new Dictionary + { + ["packageDir"] = result.PackageDir, + ["reportPath"] = result.ReportPath, + ["files"] = result.Files, + }, + ["errorMessage"] = result.Ok + ? null + : string.Join("; ", result.Items.Where(i => i.Status == "FAILED").Select(i => $"{i.ShotCode}: {i.ErrorMessage}").Take(5)), + }); + } + catch (Exception ex) + { + _log.LogError(ex, "Unexpected delivery error for job {JobId}", job.Id); + _spooler.Enqueue("delivery", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["ok"] = false, + ["items"] = new List(), + ["errorMessage"] = $"Worker exception: {ex.Message}", + }); + } + finally + { + ClearState(); + } + } + + /// + /// Runs the validation engine and spools the E12 report. Deliberately + /// non-fatal on its own errors: a crash in the checks must not lose a + /// finished render, so it is reported as a failed validation run rather + /// than being allowed to escape. + /// + private async Task RunValidationAsync( + ClaimedJob job, RenderManifest manifest, string machineId, string outputDirLocal, + ServerConfig config, CancellationToken shutdown) + { + var started = DateTimeOffset.UtcNow; + try + { + // Keep the lease alive: the job stays open through validation. + await _api.ProgressAsync(job.Id, machineId, 1.0, manifest.FrameEnd, manifest.TotalFrames, 0, + "Validating rendered sequence…", shutdown); + + var outcome = await Task.Run(() => _validation.Run(manifest, outputDirLocal, config), shutdown); + var seconds = (DateTimeOffset.UtcNow - started).TotalSeconds; + + var failed = outcome.Checks.Where(c => c.Status == "FAIL").Select(c => c.CheckName).ToList(); + if (failed.Count > 0) + { + _log.LogWarning("Job {JobId} validation FAILED in {Sec:F1}s: {Checks}", job.Id, seconds, string.Join(", ", failed)); + } + else + { + _log.LogInformation("Job {JobId} validation passed in {Sec:F1}s ({Count} checks, {Files} files)", + job.Id, seconds, outcome.Checks.Count, outcome.FileCount); + } + + _spooler.Enqueue("validation", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["checks"] = outcome.Checks, + ["checksum"] = outcome.Checksum, + ["checksumAlgo"] = "xxh64", + ["exrFileCount"] = outcome.FileCount, + ["exrTotalBytes"] = outcome.TotalBytes, + ["validationSeconds"] = (int)seconds, + }); + } + catch (Exception ex) + { + _log.LogError(ex, "Validation engine error for job {JobId}", job.Id); + _spooler.Enqueue("validation", job.Id, new Dictionary + { + ["machineId"] = machineId, + ["checks"] = new[] + { + ValidationCheck.Fail("engine_error", "validation completes", "worker exception", + $"The validation engine threw: {ex.Message}"), + }, + }); + } + } + /// /// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate /// and burn-ins, render the delivery MOV + review MP4, upload the review diff --git a/RenderWorker/VFXReviewWorker/Program.cs b/RenderWorker/VFXReviewWorker/Program.cs index c5984d7..e09404b 100644 --- a/RenderWorker/VFXReviewWorker/Program.cs +++ b/RenderWorker/VFXReviewWorker/Program.cs @@ -10,6 +10,13 @@ using VFXReviewWorker; // Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json] // Install as a service: see RenderWorker/README.md +// Dry-run the validation engine over an existing render folder. No server, no +// config, no queue — useful for checking work that predates the pipeline. +if (args.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase))) +{ + return ValidateCommand.Run(args); +} + var configPath = args.FirstOrDefault(a => !a.StartsWith('-')); // Install verification on a new machine — resolves paths, checks tools and @@ -32,6 +39,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/RenderWorker/VFXReviewWorker/ReportSpooler.cs b/RenderWorker/VFXReviewWorker/ReportSpooler.cs index 2e4a889..352c72e 100644 --- a/RenderWorker/VFXReviewWorker/ReportSpooler.cs +++ b/RenderWorker/VFXReviewWorker/ReportSpooler.cs @@ -9,6 +9,11 @@ namespace VFXReviewWorker; /// order with exponential backoff (1 s → 60 s). Rendering continues during /// server downtime; a report is deleted only once the server accepts it (or /// permanently rejects it as contradictory). +/// +/// Strict ordering is load-bearing beyond durability: the validation report +/// (E12) is only legal once complete-render (E11) has moved the Export to +/// VALIDATING, and in-order replay is what guarantees that, with no need for +/// the worker to wait on an ACK. /// public sealed class ReportSpooler { diff --git a/RenderWorker/VFXReviewWorker/ValidateCommand.cs b/RenderWorker/VFXReviewWorker/ValidateCommand.cs new file mode 100644 index 0000000..08a132d --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ValidateCommand.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace VFXReviewWorker; + +/// +/// `VFXReviewWorker.exe --validate <renderDir> [--plate <plateFile>] [--fps 24]` +/// +/// Runs the validation engine (§8) over a folder of already-rendered EXRs and +/// prints the report, without a server, a job or a queue. Two uses: proving the +/// checks against real work before the stage goes live, and answering "is this +/// delivery good?" for a sequence that predates the pipeline. +/// +public static class ValidateCommand +{ + public static int Run(string[] args) + { + var dir = args.SkipWhile(a => !a.Equals("--validate", StringComparison.OrdinalIgnoreCase)).Skip(1).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(dir) || dir.StartsWith('-')) + { + Console.Error.WriteLine("Usage: VFXReviewWorker.exe --validate [--plate ] [--fps 24] [--sample 25]"); + return 2; + } + if (!Directory.Exists(dir)) + { + Console.Error.WriteLine($"Not a directory: {dir}"); + return 2; + } + + var plate = ArgValue(args, "--plate"); + var fps = double.TryParse(ArgValue(args, "--fps"), out var f) ? f : 24; + var sample = int.TryParse(ArgValue(args, "--sample"), out var s) ? s : 25; + + var files = Directory.EnumerateFiles(dir, "*.exr").OrderBy(x => x, StringComparer.Ordinal).ToList(); + if (files.Count == 0) + { + Console.Error.WriteLine($"No .exr files in {dir}"); + return 2; + } + + // Infer what the manifest would have said from the files themselves. + var firstName = Path.GetFileName(files[0]); + var frames = files.Select(x => SequencePattern.FrameFromName(Path.GetFileName(x))).Where(x => x is not null).Select(x => x!.Value).ToList(); + var prefix = firstName.Contains('.') ? firstName[..firstName.IndexOf('.')] : Path.GetFileNameWithoutExtension(firstName); + var header = ExrHeader.TryRead(files[0]); + + var manifest = new RenderManifest + { + ShotCode = prefix.Split("_cmp_")[0], + OutputDir = dir, + OutputPattern = $"{prefix}.[#####].exr", + FrameStart = frames.Count > 0 ? frames.Min() : 1, + FrameEnd = frames.Count > 0 ? frames.Max() : files.Count, + Fps = fps, + Width = header?.DataWidth ?? 0, + Height = header?.DataHeight ?? 0, + Source = plate is null ? null : new ManifestSource + { + TimingPlate = new PlateRef { Path = plate, FrameStart = SequencePattern.FrameFromName(Path.GetFileName(plate)) }, + StartSource = "supplied on the command line", + }, + }; + + var options = new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" }; + var stage = new ValidationStage(options, NullLogger.Instance); + var outcome = stage.Run(manifest, dir, new ServerConfig { ValidationSampleEvery = sample }); + + Console.WriteLine(); + Console.WriteLine($"{files.Count} files in {dir}"); + Console.WriteLine($"inferred range {manifest.FrameStart}-{manifest.FrameEnd} at {fps} fps, pattern {manifest.OutputPattern}"); + if (plate is not null) Console.WriteLine($"timing plate {plate}"); + Console.WriteLine(); + + foreach (var c in outcome.Checks) + { + var mark = c.Status switch { "PASS" => " ok ", "FAIL" => " FAIL ", "WARN" => " warn ", _ => " skip " }; + Console.WriteLine($"[{mark}] {c.CheckName,-24} {Describe(c)}"); + } + + Console.WriteLine(); + Console.WriteLine($"checksum (xxh64): {outcome.Checksum ?? "(not computed)"}"); + var failed = outcome.Checks.Count(c => c.Status == "FAIL"); + Console.WriteLine(failed == 0 ? "PASSED" : $"FAILED — {failed} check(s)"); + return failed == 0 ? 0 : 1; + } + + private static string Describe(ValidationCheck c) + { + if (!string.IsNullOrWhiteSpace(c.Message)) return c.Message!; + if (c.Expected is null && c.Actual is null) return ""; + return c.Expected == c.Actual ? c.Actual ?? "" : $"expected {c.Expected}, got {c.Actual}"; + } + + private static string? ArgValue(string[] args, string name) + { + var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } +} diff --git a/RenderWorker/VFXReviewWorker/ValidationStage.cs b/RenderWorker/VFXReviewWorker/ValidationStage.cs new file mode 100644 index 0000000..9ef16a9 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/ValidationStage.cs @@ -0,0 +1,590 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace VFXReviewWorker; + +/// +/// Validation engine (RenderPipeline2 §8). Runs on the worker straight after a +/// successful render, while it still holds the job and the files are local. +/// It only ever *reports* — the server decides what the results mean. +/// +/// Full-sequence checks (count, gaps, names, sizes) touch every file; header +/// checks sample first/middle/last plus every Nth frame. +/// +public sealed class ValidationStage +{ + private readonly WorkerOptions _options; + private readonly ILogger _log; + + public ValidationStage(WorkerOptions options, ILogger log) + { + _options = options; + _log = log; + } + + public ValidationOutcome Run(RenderManifest manifest, string outputDirLocal, ServerConfig config) + { + var checks = new List(); + var files = EnumerateSequence(manifest, outputDirLocal, checks, out var frameNumbers, out var totalBytes); + + if (files.Count == 0) + { + // Nothing to inspect: the sequence checks already recorded the FAIL. + return new ValidationOutcome(checks, 0, 0, null); + } + + var sampleEvery = Math.Max(1, config.ValidationSampleEvery); + var sampled = PickSamples(files, sampleEvery); + var headers = ReadHeaders(sampled, checks); + + if (headers.Count > 0) + { + CheckResolution(manifest, headers, checks); + CheckBitDepthAndChannels(manifest, headers, checks); + CheckCompression(manifest, headers, checks); + CheckTimecode(manifest, headers, frameNumbers, config, checks); + } + + var checksum = ComputeSequenceChecksum(files); + return new ValidationOutcome(checks, files.Count, totalBytes, checksum); + } + + // ── Sequence-level checks ──────────────────────────────────────────────── + + private List EnumerateSequence( + RenderManifest manifest, string outputDirLocal, List checks, + out List frameNumbers, out long totalBytes) + { + frameNumbers = new List(); + totalBytes = 0; + var expectedCount = manifest.TotalFrames; + + if (!Directory.Exists(outputDirLocal)) + { + checks.Add(ValidationCheck.Fail("file_count", expectedCount.ToString(), "0", + $"Output directory does not exist on this machine: {outputDirLocal}")); + return new List(); + } + + var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern); + var matched = new List<(int Frame, string Path)>(); + var nonConforming = new List(); + + foreach (var path in Directory.EnumerateFiles(outputDirLocal)) + { + var name = Path.GetFileName(path); + if (!name.EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase)) continue; + // Only files belonging to this render: a version folder can also hold + // the preview MOV/MP4, which are not part of the EXR sequence. + if (!name.StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)) continue; + + var frame = pattern.FrameNumberOf(name); + if (frame is null) { nonConforming.Add(name); continue; } + matched.Add((frame.Value, path)); + } + + matched.Sort((a, b) => a.Frame.CompareTo(b.Frame)); + var files = matched.Select(m => m.Path).ToList(); + frameNumbers = matched.Select(m => m.Frame).ToList(); + + // file_count + checks.Add(files.Count == expectedCount + ? ValidationCheck.Pass("file_count", expectedCount.ToString(), files.Count.ToString()) + : ValidationCheck.Fail("file_count", expectedCount.ToString(), files.Count.ToString(), + $"Expected {expectedCount} frames ({manifest.FrameStart}-{manifest.FrameEnd}), found {files.Count} in {outputDirLocal}")); + + // filename_consistency + checks.Add(nonConforming.Count == 0 + ? ValidationCheck.Pass("filename_consistency", pattern.Describe(), "all files match") + : ValidationCheck.Fail("filename_consistency", pattern.Describe(), $"{nonConforming.Count} nonconforming", + "Files not matching the output pattern: " + string.Join(", ", nonConforming.Take(10)), + new { files = nonConforming.Take(50) })); + + if (files.Count == 0) return files; + + // missing_frames / duplicate_frames / first_frame / last_frame + var seen = new HashSet(); + var duplicates = new List(); + foreach (var f in frameNumbers) + { + if (!seen.Add(f)) duplicates.Add(f); + } + var missing = new List(); + for (var f = manifest.FrameStart; f <= manifest.FrameEnd; f++) + { + if (!seen.Contains(f)) missing.Add(f); + } + + checks.Add(missing.Count == 0 + ? ValidationCheck.Pass("missing_frames", "none", "none") + : ValidationCheck.Fail("missing_frames", "none", $"{missing.Count} missing", + "Missing frames: " + Summarise(missing), new { missing = missing.Take(500) })); + + checks.Add(duplicates.Count == 0 + ? ValidationCheck.Pass("duplicate_frames", "none", "none") + : ValidationCheck.Fail("duplicate_frames", "none", $"{duplicates.Count} duplicated", + "Duplicate frame numbers (differing padding?): " + Summarise(duplicates), + new { duplicates = duplicates.Take(100) })); + + checks.Add(frameNumbers[0] == manifest.FrameStart + ? ValidationCheck.Pass("first_frame", manifest.FrameStart.ToString(), frameNumbers[0].ToString()) + : ValidationCheck.Fail("first_frame", manifest.FrameStart.ToString(), frameNumbers[0].ToString(), + $"Sequence starts at {frameNumbers[0]}, expected {manifest.FrameStart}")); + + var last = frameNumbers[^1]; + checks.Add(last == manifest.FrameEnd + ? ValidationCheck.Pass("last_frame", manifest.FrameEnd.ToString(), last.ToString()) + : ValidationCheck.Fail("last_frame", manifest.FrameEnd.ToString(), last.ToString(), + $"Sequence ends at {last}, expected {manifest.FrameEnd}")); + + // zero_byte — every file, it is only a directory stat + var zeroByte = new List(); + foreach (var path in files) + { + var len = new FileInfo(path).Length; + totalBytes += len; + if (len == 0) zeroByte.Add(Path.GetFileName(path)); + } + checks.Add(zeroByte.Count == 0 + ? ValidationCheck.Pass("zero_byte", "none", "none") + : ValidationCheck.Fail("zero_byte", "none", $"{zeroByte.Count} empty", + "Zero-byte files: " + string.Join(", ", zeroByte.Take(10)), new { files = zeroByte.Take(50) })); + + return files; + } + + // ── Header checks (sampled) ────────────────────────────────────────────── + + private static List PickSamples(List files, int sampleEvery) + { + var picks = new SortedSet { 0, files.Count / 2, files.Count - 1 }; + for (var i = 0; i < files.Count; i += sampleEvery) picks.Add(i); + return picks.Where(i => i >= 0 && i < files.Count).Select(i => files[i]).ToList(); + } + + private List<(string Path, ExrHeader Header)> ReadHeaders(List sampled, List checks) + { + var headers = new List<(string, ExrHeader)>(); + var unreadable = new List(); + + foreach (var path in sampled) + { + try + { + headers.Add((path, ExrHeader.Read(path))); + } + catch (Exception ex) + { + unreadable.Add($"{Path.GetFileName(path)}: {ex.Message}"); + _log.LogWarning("Unreadable EXR header: {Path} ({Error})", path, ex.Message); + } + } + + checks.Add(unreadable.Count == 0 + ? ValidationCheck.Pass("corrupt", "all sampled frames readable", $"{headers.Count} sampled") + : ValidationCheck.Fail("corrupt", "all sampled frames readable", $"{unreadable.Count} unreadable", + "Unreadable EXR headers: " + string.Join("; ", unreadable.Take(5)), new { files = unreadable.Take(50) })); + + return headers; + } + + private static void CheckResolution(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks) + { + if (m.Width <= 0 || m.Height <= 0) + { + checks.Add(ValidationCheck.Skipped("resolution", "manifest carries no expected resolution")); + return; + } + var expected = $"{m.Width}x{m.Height}"; + var bad = headers.Where(h => h.H.DataWidth != m.Width || h.H.DataHeight != m.Height).ToList(); + checks.Add(bad.Count == 0 + ? ValidationCheck.Pass("resolution", expected, expected) + : ValidationCheck.Fail("resolution", expected, $"{bad[0].H.DataWidth}x{bad[0].H.DataHeight}", + $"{bad.Count} of {headers.Count} sampled frames are not {expected} (first: {Path.GetFileName(bad[0].Path)})")); + } + + private static void CheckBitDepthAndChannels(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks) + { + var expectedDepth = m.Expected?.BitDepth; + var actualDepth = headers[0].H.BitDepth ?? "unknown"; + if (string.IsNullOrWhiteSpace(expectedDepth)) + { + checks.Add(ValidationCheck.Skipped("bit_depth", $"no expected bit depth configured (rendered {actualDepth})")); + } + else + { + var mismatched = headers.Where(h => !string.Equals(h.H.BitDepth, expectedDepth, StringComparison.OrdinalIgnoreCase)).ToList(); + checks.Add(mismatched.Count == 0 + ? ValidationCheck.Pass("bit_depth", expectedDepth, actualDepth) + : ValidationCheck.Fail("bit_depth", expectedDepth, mismatched[0].H.BitDepth ?? "unknown", + $"{mismatched.Count} of {headers.Count} sampled frames are not {expectedDepth}")); + } + + var hasAlpha = headers[0].H.HasAlpha; + var channelList = string.Join(",", headers[0].H.Channels.Select(c => c.Name)); + if (m.Expected?.Alpha is null) + { + checks.Add(ValidationCheck.Skipped("alpha_channel", $"no expectation configured (channels: {channelList})")); + } + else + { + checks.Add(hasAlpha == m.Expected.Alpha.Value + ? ValidationCheck.Pass("alpha_channel", m.Expected.Alpha.Value ? "alpha present" : "no alpha", channelList) + : ValidationCheck.Fail("alpha_channel", + m.Expected.Alpha.Value ? "alpha present" : "no alpha", + hasAlpha ? "alpha present" : "no alpha", + $"Channel list is {channelList}")); + } + } + + private static void CheckCompression(RenderManifest m, List<(string Path, ExrHeader H)> headers, List checks) + { + var actual = headers[0].H.CompressionName; + if (headers[0].H.CompressionCode is null) + { + checks.Add(ValidationCheck.Fail("exr_type", "readable EXR", "no compression attribute", + "Sampled frame has no compression attribute — not a valid EXR header")); + return; + } + var expected = m.Expected?.ExrCompression; + if (string.IsNullOrWhiteSpace(expected)) + { + checks.Add(ValidationCheck.Pass("exr_type", "valid EXR", $"{actual}{(headers[0].H.IsTiled ? ", tiled" : ", scanline")}")); + return; + } + // Compression differing but readable is a WARN, never a stop (§8.1). + checks.Add(string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) + ? ValidationCheck.Pass("exr_type", expected, actual) + : ValidationCheck.Warn("exr_type", expected, actual, + $"Compression is {actual}, expected {expected} — readable, so not blocking")); + } + + // ── Timecode: the primary check (§8.1a) ────────────────────────────────── + + private void CheckTimecode( + RenderManifest m, List<(string Path, ExrHeader H)> headers, List frameNumbers, + ServerConfig config, List checks) + { + var fps = m.Fps > 0 ? m.Fps : 24; + var first = headers[0]; + + if (first.H.Timecode is null) + { + // AE's EXR output module writes timeCode; its absence means the + // output module changed, so this is a stop rather than a skip. + checks.Add(ValidationCheck.Fail("timecode_plate_match", "timecode present", "absent", + $"No timeCode attribute in {Path.GetFileName(first.Path)} — the EXR output module no longer writes timecode", + new { attributes = first.H.AttributeNames.Take(40) })); + return; + } + + var renderTc = first.H.Timecode.Value; + var renderFirstFrame = frameNumbers.Count > 0 ? frameNumbers[0] : m.FrameStart; + + // timecode_consistency — sampled frames must advance exactly one frame + // each. A constant value is the frame-derived-timecode defect. + var drift = new List(); + for (var i = 0; i < headers.Count; i++) + { + var h = headers[i].H; + if (h.Timecode is null) { drift.Add($"{Path.GetFileName(headers[i].Path)}: no timecode"); continue; } + var frameNo = SequencePattern.FrameFromName(Path.GetFileName(headers[i].Path)) ?? (renderFirstFrame + i); + var expectedTc = Timecode.FromFrames(renderTc.ToFrames(fps) + (frameNo - renderFirstFrame), fps); + if (h.Timecode.Value.ToFrames(fps) != expectedTc.ToFrames(fps)) + { + drift.Add($"frame {frameNo}: {h.Timecode.Value} (expected {expectedTc})"); + } + } + checks.Add(drift.Count == 0 + ? ValidationCheck.Pass("timecode_consistency", "monotonic, 1 frame per frame", $"{headers.Count} frames sampled") + : ValidationCheck.Fail("timecode_consistency", "monotonic, 1 frame per frame", $"{drift.Count} sampled frames off", + "Timecode does not advance with the frames: " + string.Join("; ", drift.Take(5)), + new { drift = drift.Take(50) })); + + // timecode_fps + if (first.H.FramesPerSecond is double headerFps) + { + checks.Add(Math.Abs(headerFps - fps) < 0.01 + ? ValidationCheck.Pass("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###")) + : ValidationCheck.Fail("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"), + $"EXR framesPerSecond is {headerFps:0.###} but the comp rendered at {fps:0.###}")); + } + else + { + checks.Add(ValidationCheck.Skipped("timecode_fps", "no framesPerSecond attribute in the rendered EXRs")); + } + + // timecode_plate_match — reference precedence, rank 1 → 3. + var reference = ResolveTimecodeReference(m, config); + if (reference is null) + { + checks.Add(ValidationCheck.Warn("timecode_plate_match", "plate timecode", renderTc.ToString(), + "No plate reference available: the manifest carries no timing plate, none was found on disk, " + + "and no API timecode was supplied. Render timecode is unverified.", + new { renderTimecode = renderTc.ToString() })); + return; + } + + var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame); + var expected = Timecode.FromFrames(expectedFrames, fps); + var actualFrames = renderTc.ToFrames(fps); + var deltaFrames = actualFrames - expectedFrames; + var details = new + { + reference = reference.Kind, + platePath = reference.PlatePath, + plateTimecode = reference.PlateTimecode.ToString(), + plateFirstFrame = reference.PlateFirstFrame, + renderFirstFrame, + deltaFrames, + }; + + if (deltaFrames == 0) + { + // The API fallback proves nothing on its own (§8.1a): it is the + // editorial in-point minus a handle count that may not be this + // plate's, so a match is reported as a WARN, never a PASS. + checks.Add(reference.Kind == "api" + ? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(), + "Matches the API timecode less handles, but the plate itself could not be read — unverified.", details) + : ValidationCheck.Pass("timecode_plate_match", expected.ToString(), renderTc.ToString(), + $"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}", details)); + return; + } + + var message = + $"Render starts at {renderTc} but the {reference.Kind} plate says it should be {expected} " + + $"({(deltaFrames > 0 ? "+" : "")}{deltaFrames} frames). " + + (Math.Abs(deltaFrames) == reference.HandleFrames && reference.HandleFrames > 0 + ? $"That is exactly the {reference.HandleFrames}-frame handle: the comp was anchored to the editorial in-point instead of the plate's first frame." + : renderTc.Hours == 0 && renderTc.Minutes == 0 + ? "A near-zero timecode means the comp had no source timecode at all and AE derived it from the frame number." + : ""); + + checks.Add(reference.Kind == "api" + ? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(), + message + " (checked against the API value, not the plate — treat as advisory)", details) + : ValidationCheck.Fail("timecode_plate_match", expected.ToString(), renderTc.ToString(), message, details)); + } + + /// + /// Reference precedence (§8.1a): manifest timing plate → plate found under a + /// configured search root → API timecode minus handles (advisory only). + /// + private TimecodeReference? ResolveTimecodeReference(RenderManifest m, ServerConfig config) + { + var mapper = new PathMapper(_options.PathMappings); + var handleFrames = m.Source?.HandleFrames ?? config.ValidationHandleFrames; + + // Rank 1 — the plate the comp was actually built from. + var timing = m.Source?.TimingPlate; + if (timing is not null && !string.IsNullOrWhiteSpace(timing.Path)) + { + var local = mapper.Map(timing.Path); + var header = File.Exists(local) ? ExrHeader.TryRead(local) : null; + if (header?.Timecode is not null) + { + var frame = SequencePattern.FrameFromName(Path.GetFileName(local)) ?? timing.FrameStart ?? m.FrameStart; + return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames); + } + _log.LogWarning("Timing plate from manifest is unreadable: {Path}", local); + } + + // Rank 2 — derive the plate from a configured search root. + var derived = FindPlateOnDisk(m, mapper); + if (derived is not null) return derived with { HandleFrames = handleFrames }; + + // Rank 3 — the API's editorial in-point, less handles. Advisory only. + var apiTc = Timecode.Parse(m.Expected?.TimecodeStart); + if (apiTc is not null) + { + var fps = m.Fps > 0 ? m.Fps : 24; + var plateStart = Timecode.FromFrames(apiTc.Value.ToFrames(fps) - handleFrames, fps); + return new TimecodeReference("api", null, plateStart, m.FrameStart, handleFrames); + } + + return null; + } + + /// + /// Rank 2: look for the shot's plate folders under the configured roots, + /// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and + /// {shotCode}_{element}/). Ambiguity is resolved by frame count; still + /// ambiguous means no reference rather than a guess. + /// + private TimecodeReference? FindPlateOnDisk(RenderManifest m, PathMapper mapper) + { + if (_options.PlateSearchRoots.Count == 0 || string.IsNullOrWhiteSpace(m.ShotCode)) return null; + + var candidates = new List<(string Dir, int Count)>(); + foreach (var rootRaw in _options.PlateSearchRoots) + { + var root = mapper.Map(rootRaw); + if (!Directory.Exists(root)) continue; + // The show/episode level is unknown here, so scan the root and one + // level below it — bounded, and enough for both layouts. + var levels = new List { root }; + try { levels.AddRange(Directory.EnumerateDirectories(root)); } catch { } + + foreach (var level in levels) + { + try + { + var grouped = Path.Combine(level, m.ShotCode); + if (Directory.Exists(grouped)) + { + foreach (var element in Directory.EnumerateDirectories(grouped)) + { + AddCandidate(candidates, element); + } + } + foreach (var element in Directory.EnumerateDirectories(level, m.ShotCode + "_*")) + { + AddCandidate(candidates, element); + } + } + catch { /* unreadable branch — keep scanning the rest */ } + } + } + + if (candidates.Count == 0) return null; + + // Prefer the element whose frame count matches the render: that is the + // one the comp's timing came from. + var exact = candidates.Where(c => c.Count == m.TotalFrames).ToList(); + (string Dir, int Count)? chosen = + exact.Count == 1 ? exact[0] + : exact.Count == 0 && candidates.Count == 1 ? candidates[0] + : null; + + if (chosen is null) + { + _log.LogWarning("Plate reference ambiguous for {Shot}: {Candidates}", + m.ShotCode, string.Join(", ", candidates.Select(c => Path.GetFileName(c.Dir)))); + return null; + } + + var firstFile = Directory.EnumerateFiles(chosen.Value.Dir, "*.exr").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault(); + if (firstFile is null) return null; + var header = ExrHeader.TryRead(firstFile); + if (header?.Timecode is null) return null; + + var frame = SequencePattern.FrameFromName(Path.GetFileName(firstFile)) ?? m.FrameStart; + return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0); + } + + private static void AddCandidate(List<(string Dir, int Count)> candidates, string dir) + { + try + { + var count = Directory.EnumerateFiles(dir, "*.exr").Count(); + if (count > 0) candidates.Add((dir, count)); + } + catch { } + } + + // ── Checksums ──────────────────────────────────────────────────────────── + + /// + /// Sequence digest = xxHash64 over the per-file digests in frame order + /// (§8.2). Reused by the delivery builder to verify what it linked/copied. + /// + private string? ComputeSequenceChecksum(List files) + { + try + { + var state = new XxHash64.State(0); + foreach (var f in files) + { + var digest = XxHash64.HashFile(f); + state.Update(System.Text.Encoding.ASCII.GetBytes(digest)); + } + return state.Digest().ToString("x16"); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Sequence checksum failed"); + return null; + } + } + + private static string Summarise(IEnumerable numbers) + { + var list = numbers.Take(12).ToList(); + var text = string.Join(", ", list); + return numbers.Count() > 12 ? text + ", …" : text; + } +} + +public sealed record TimecodeReference( + string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames); + +public sealed record ValidationOutcome( + List Checks, int FileCount, long TotalBytes, string? Checksum) +{ + public bool HasFailure => Checks.Any(c => c.Status == "FAIL"); +} + +/// One row of the E12 report. +public sealed class ValidationCheck +{ + public string CheckName { get; set; } = ""; + public string Status { get; set; } = "PASS"; + public string? Expected { get; set; } + public string? Actual { get; set; } + public string? Message { get; set; } + public object? Details { get; set; } + + public static ValidationCheck Pass(string name, string? expected = null, string? actual = null, string? message = null, object? details = null) => + new() { CheckName = name, Status = "PASS", Expected = expected, Actual = actual, Message = message, Details = details }; + + public static ValidationCheck Fail(string name, string? expected, string? actual, string message, object? details = null) => + new() { CheckName = name, Status = "FAIL", Expected = expected, Actual = actual, Message = message, Details = details }; + + public static ValidationCheck Warn(string name, string? expected, string? actual, string message, object? details = null) => + new() { CheckName = name, Status = "WARN", Expected = expected, Actual = actual, Message = message, Details = details }; + + public static ValidationCheck Skipped(string name, string message) => + new() { CheckName = name, Status = "SKIPPED", Message = message }; +} + +/// +/// Parses `{base}.[#####].exr` output patterns and the filenames they produce. +/// +public sealed class SequencePattern +{ + public string Prefix { get; private init; } = ""; + public string Extension { get; private init; } = ".exr"; + public int Padding { get; private init; } + + private static readonly Regex FrameInName = new(@"\.(\d{2,9})\.[A-Za-z0-9]+$", RegexOptions.Compiled); + + public static SequencePattern FromOutputPattern(string outputPattern) + { + var pattern = outputPattern ?? ""; + var ext = Path.GetExtension(pattern); + if (string.IsNullOrEmpty(ext)) ext = ".exr"; + + var hashStart = pattern.IndexOf('['); + var prefix = hashStart > 0 ? pattern[..hashStart].TrimEnd('.') : Path.GetFileNameWithoutExtension(pattern); + var padding = 0; + foreach (var ch in pattern) + { + if (ch == '#') padding++; + } + + return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding }; + } + + public int? FrameNumberOf(string fileName) => FrameFromName(fileName); + + /// `UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001. + public static int? FrameFromName(string fileName) + { + var m = FrameInName.Match(fileName); + if (!m.Success) return null; + return int.TryParse(m.Groups[1].Value, out var n) ? n : null; + } + + public string Describe() => $"{Prefix}.[{new string('#', Math.Max(1, Padding))}]{Extension}"; +} diff --git a/RenderWorker/VFXReviewWorker/WorkerOptions.cs b/RenderWorker/VFXReviewWorker/WorkerOptions.cs index da43ca8..35aead4 100644 --- a/RenderWorker/VFXReviewWorker/WorkerOptions.cs +++ b/RenderWorker/VFXReviewWorker/WorkerOptions.cs @@ -21,6 +21,12 @@ public sealed class WorkerOptions public string? FfmpegPath { get; set; } public string? OiiotoolPath { get; set; } public List PathMappings { get; set; } = new(); + /// + /// Optional footage roots searched for a shot's plates when a manifest + /// carries no timing plate (rank 2 of §8.1a) — e.g. ["V:/_FOOTAGE/UNG"]. + /// Leave empty and old manifests simply fall through to the API fallback. + /// + public List PlateSearchRoots { get; set; } = new(); public string WorkerVersion { get; set; } = "1.0.0"; public string? AeVersion { get; set; } diff --git a/RenderWorker/VFXReviewWorker/WorkerService.cs b/RenderWorker/VFXReviewWorker/WorkerService.cs index bb8bf24..7f6bccb 100644 --- a/RenderWorker/VFXReviewWorker/WorkerService.cs +++ b/RenderWorker/VFXReviewWorker/WorkerService.cs @@ -61,9 +61,11 @@ public sealed class WorkerService : BackgroundService : "No interactive After Effects — preview jobs enabled"); _lastAeOpen = aeOpen; } + // Delivery builds are file work with no AE involvement, so they + // are always claimable — someone is usually waiting on the folder. var types = aeOpen - ? new[] { "AE_RENDER" } - : new[] { "AE_RENDER", "PREVIEW_ONLY" }; + ? new[] { "AE_RENDER", "DELIVERY_BUILD" } + : new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD" }; var job = await _api.ClaimAsync(_machineId!, types, stoppingToken); if (job is not null) diff --git a/RenderWorker/VFXReviewWorker/XxHash64.cs b/RenderWorker/VFXReviewWorker/XxHash64.cs new file mode 100644 index 0000000..6fccf75 --- /dev/null +++ b/RenderWorker/VFXReviewWorker/XxHash64.cs @@ -0,0 +1,158 @@ +namespace VFXReviewWorker; + +/// +/// xxHash64 (§8.2 `checksumAlgo: "xxh64"`). Implemented here rather than taken +/// from System.IO.Hashing so the worker stays a dependency-free single-file +/// publish for offline workstation installs. Verified against the reference +/// test vectors in XxHash64Tests. +/// +public static class XxHash64 +{ + private const ulong Prime1 = 11400714785074694791UL; + private const ulong Prime2 = 14029467366897019727UL; + private const ulong Prime3 = 1609587929392839161UL; + private const ulong Prime4 = 9650029242287828579UL; + private const ulong Prime5 = 2870177450012600261UL; + + public static ulong Hash(ReadOnlySpan data, ulong seed = 0) + { + var state = new State(seed); + state.Update(data); + return state.Digest(); + } + + public static string HashFile(string path, int bufferSize = 1 << 20) + { + var state = new State(0); + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize, FileOptions.SequentialScan); + var buffer = new byte[bufferSize]; + int read; + while ((read = fs.Read(buffer, 0, buffer.Length)) > 0) + { + state.Update(new ReadOnlySpan(buffer, 0, read)); + } + return state.Digest().ToString("x16"); + } + + /// Streaming state so multi-GB sequences hash without being held in memory. + public struct State + { + private ulong _v1, _v2, _v3, _v4; + private readonly ulong _seed; + private ulong _length; + private readonly byte[] _buffer; + private int _bufferLen; + + public State(ulong seed) + { + _seed = seed; + _v1 = seed + Prime1 + Prime2; + _v2 = seed + Prime2; + _v3 = seed; + _v4 = seed - Prime1; + _length = 0; + _buffer = new byte[32]; + _bufferLen = 0; + } + + public void Update(ReadOnlySpan data) + { + _length += (ulong)data.Length; + + if (_bufferLen > 0) + { + var need = Math.Min(32 - _bufferLen, data.Length); + data[..need].CopyTo(_buffer.AsSpan(_bufferLen)); + _bufferLen += need; + data = data[need..]; + if (_bufferLen < 32) return; + ProcessStripe(_buffer); + _bufferLen = 0; + } + + while (data.Length >= 32) + { + ProcessStripe(data[..32]); + data = data[32..]; + } + + if (data.Length > 0) + { + data.CopyTo(_buffer.AsSpan(0)); + _bufferLen = data.Length; + } + } + + private void ProcessStripe(ReadOnlySpan stripe) + { + _v1 = Round(_v1, BitConverter.ToUInt64(stripe[..8])); + _v2 = Round(_v2, BitConverter.ToUInt64(stripe.Slice(8, 8))); + _v3 = Round(_v3, BitConverter.ToUInt64(stripe.Slice(16, 8))); + _v4 = Round(_v4, BitConverter.ToUInt64(stripe.Slice(24, 8))); + } + + public ulong Digest() + { + ulong h; + if (_length >= 32) + { + h = RotateLeft(_v1, 1) + RotateLeft(_v2, 7) + RotateLeft(_v3, 12) + RotateLeft(_v4, 18); + h = MergeRound(h, _v1); + h = MergeRound(h, _v2); + h = MergeRound(h, _v3); + h = MergeRound(h, _v4); + } + else + { + h = _seed + Prime5; + } + + h += _length; + + var tail = new ReadOnlySpan(_buffer, 0, _bufferLen); + int i = 0; + while (i + 8 <= tail.Length) + { + h ^= Round(0, BitConverter.ToUInt64(tail.Slice(i, 8))); + h = RotateLeft(h, 27) * Prime1 + Prime4; + i += 8; + } + if (i + 4 <= tail.Length) + { + h ^= BitConverter.ToUInt32(tail.Slice(i, 4)) * Prime1; + h = RotateLeft(h, 23) * Prime2 + Prime3; + i += 4; + } + while (i < tail.Length) + { + h ^= tail[i] * Prime5; + h = RotateLeft(h, 11) * Prime1; + i++; + } + + h ^= h >> 33; + h *= Prime2; + h ^= h >> 29; + h *= Prime3; + h ^= h >> 32; + return h; + } + + private static ulong Round(ulong acc, ulong input) + { + acc += input * Prime2; + acc = RotateLeft(acc, 31); + acc *= Prime1; + return acc; + } + + private static ulong MergeRound(ulong acc, ulong val) + { + acc ^= Round(0, val); + acc = acc * Prime1 + Prime4; + return acc; + } + + private static ulong RotateLeft(ulong value, int bits) => (value << bits) | (value >> (64 - bits)); + } +} diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index 5476fb2..b409b06 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -28,7 +28,14 @@ var SETTINGS_KEY = "cfg"; lastActionText: null, pipelineStatusText: null, urgentCheckbox: null, - submissionNoteField: null + submissionNoteField: null, + qcList: null, + qcNotesField: null, + qcStatusText: null + }; + + var qcState = { + entries: [] }; var CURRENT_PROJECT = null; // { code, name, showId, projectType } @@ -47,7 +54,8 @@ var SETTINGS_KEY = "cfg"; var buildState = { lastStartSource: "", - lastStartFrames: 0 + lastStartFrames: 0, + lastStartShotCode: "" }; function parseJSON(text) { @@ -78,6 +86,264 @@ var SETTINGS_KEY = "cfg"; return String(value).replace(/^\s+/, "").replace(/\s+$/, ""); } + // ── Diagnostics ─────────────────────────────────────────────────────────── + // + // Every button records what it tried, in order, to an in-memory trace and to + // a log file next to config.json. A failure therefore reports the stage it + // died at and the paths/HTTP codes it was working with, instead of a bare + // "failed" that can only be diagnosed by sitting at the machine. + + var DIAG = { + lines: [], + maxLines: 300, + lastFailure: null + }; + + function getLogPath() { + return Folder.userData.fsName + "/VFXReviewConnector/connector.log"; + } + + function padTwo(value) { + return ("0" + value).slice(-2); + } + + function diagTimestamp() { + var now = new Date(); + + return now.getFullYear() + "-" + padTwo(now.getMonth() + 1) + "-" + padTwo(now.getDate()) + + " " + padTwo(now.getHours()) + ":" + padTwo(now.getMinutes()) + ":" + padTwo(now.getSeconds()); + } + + function diagWriteToFile(text) { + var dir; + var file; + + try { + dir = new Folder(Folder.userData.fsName + "/VFXReviewConnector"); + if (!dir.exists) { + dir.create(); + } + + file = new File(getLogPath()); + // Keep the log small enough to paste into a message or email. + if (file.exists && file.length > 1048576) { + file.remove(); + file = new File(getLogPath()); + } + + file.encoding = "UTF-8"; + if (!file.open("a")) { + return; + } + file.write(text + "\n"); + file.close(); + } catch (logError) { + } + } + + function diagLog(message) { + var line = "[" + diagTimestamp() + "] " + message; + + DIAG.lines.push(line); + if (DIAG.lines.length > DIAG.maxLines) { + DIAG.lines.shift(); + } + diagWriteToFile(line); + } + + function describeError(err) { + var text; + + if (!err) { + return "unknown error"; + } + + text = err.toString ? err.toString() : String(err); + if (err.line) { + text += " (line " + err.line + (err.fileName ? " of " + err.fileName : "") + ")"; + } + return text; + } + + // The token is never logged — only whether one is configured, which is what + // an artist actually needs to know when the API says 401. + function describeConfig() { + return "baseUrl=" + (CONFIG.baseUrl || "(empty)") + + " | token=" + (trimText(CONFIG.token) ? trimText(CONFIG.token).length + " chars" : "EMPTY") + + " | footageRoot=" + (CONFIG.footageRoot || "(empty)") + + " | exportRoot=" + (CONFIG.exportRoot || "(empty)") + + " | artistEmail=" + (CONFIG.artistEmail || "(empty)") + + " | handleFrames=" + CONFIG.handleFrames; + } + + function diagStart(action) { + DIAG.lines = []; + DIAG.lastFailure = null; + diagWriteToFile(""); + diagLog("=== " + action + " ==="); + diagLog("AE " + app.version + " | " + $.os + " | project " + + (app.project && app.project.file ? app.project.file.fsName : "(unsaved)")); + diagLog("config: " + describeConfig()); + diagLog("selected project: " + (CURRENT_PROJECT + ? CURRENT_PROJECT.code + " (showId " + CURRENT_PROJECT.showId + ", " + CURRENT_PROJECT.projectType + ")" + : "(none)")); + } + + function diagFail(stage, detail, hints) { + var i; + + DIAG.lastFailure = { stage: stage, detail: detail, hints: hints || [] }; + diagLog("FAILED [" + stage + "] " + detail); + for (i = 0; hints && i < hints.length; i += 1) { + diagLog(" - " + hints[i]); + } + } + + function diagReport() { + var text = DIAG.lines.join("\n"); + var failure = DIAG.lastFailure; + var i; + + if (failure) { + text += "\n\n--- Failure -------------------------------------------------\n"; + text += "Stage: " + failure.stage + "\n"; + text += "Detail: " + failure.detail + "\n"; + for (i = 0; i < failure.hints.length; i += 1) { + text += " - " + failure.hints[i] + "\n"; + } + } + + return text + "\n\nFull log: " + getLogPath(); + } + + // A scrollable, selectable dialog rather than alert(): the point is that the + // artist can copy the whole trace and send it on. + function showDiagDialog(titleText, bodyText) { + var dlg; + var heading; + var box; + var row; + var openLogButton; + + try { + dlg = new Window("dialog", "VFXReview Connector — Diagnostics"); + dlg.orientation = "column"; + dlg.alignChildren = ["fill", "top"]; + dlg.margins = 14; + dlg.spacing = 8; + + heading = dlg.add("statictext", undefined, titleText, { multiline: true }); + heading.preferredSize = [620, 34]; + + box = dlg.add("edittext", undefined, bodyText, { multiline: true, scrolling: true }); + box.preferredSize = [620, 340]; + + row = dlg.add("group"); + row.orientation = "row"; + row.alignment = ["right", "top"]; + row.spacing = 8; + + openLogButton = row.add("button", undefined, "Open Log Folder"); + openLogButton.onClick = function () { + try { + new File(getLogPath()).parent.execute(); + } catch (openError) { + } + }; + row.add("button", undefined, "Close", { name: "ok" }); + + dlg.layout.layout(true); + dlg.center(); + dlg.show(); + } catch (dialogError) { + alert(titleText + "\n\n" + bodyText); + } + } + + // ScriptUI silently swallows exceptions thrown inside onClick handlers, so a + // hard failure looks like a button that did nothing at all. Every handler is + // wrapped: the click is traced, and anything thrown is surfaced. + function guarded(label, handler) { + return function () { + diagStart(label); + try { + handler(); + } catch (handlerError) { + diagFail("unexpected error", describeError(handlerError), + [$.stack ? "stack: " + String($.stack).replace(/[\r\n]+/g, " > ") : "no stack available"]); + try { + app.endUndoGroup(); + } catch (undoError) { + } + updateStatus(getSelectedComps().length, label + " failed: " + describeError(handlerError)); + showDiagDialog(label + " failed unexpectedly. Send this to the pipeline TD.", diagReport()); + } + }; + } + + // The first ancestor that does exist separates "drive not mapped" from + // "show folder misnamed" from "only the leaf is missing". + function describeMissingPath(path) { + var probe = new Folder(path); + var guard = 0; + + while (probe && !probe.exists && guard < 24) { + probe = probe.parent; + guard += 1; + } + + if (!probe || !probe.exists) { + return "no part of that path exists on this machine — is the drive mapped?"; + } + return "deepest folder that does exist: " + probe.fsName; + } + + function listFolderNames(folder, limit) { + var names = []; + var entries; + var i; + + try { + entries = folder.getFiles(); + } catch (listError) { + return names; + } + + for (i = 0; i < entries.length && names.length < limit; i += 1) { + if (entries[i] instanceof Folder) { + names.push(entries[i].name); + } + } + + return names; + } + + function describeFolderContents(folder) { + var entries; + var exrCount = 0; + var otherCount = 0; + var folderCount = 0; + var i; + + try { + entries = folder.getFiles(); + } catch (contentsError) { + return "unreadable (permissions?)"; + } + + for (i = 0; i < entries.length; i += 1) { + if (entries[i] instanceof Folder) { + folderCount += 1; + } else if (/\.exr$/i.test(entries[i].name)) { + exrCount += 1; + } else { + otherCount += 1; + } + } + + return exrCount + " .exr, " + otherCount + " other file(s), " + folderCount + " sub-folder(s)"; + } + function getSelectedComps() { var comps = []; var selection; @@ -97,57 +363,99 @@ var SETTINGS_KEY = "cfg"; return comps; } - function getShotData(shotCode) { - var url; + // GET that keeps the HTTP code and the raw response body, so "could not + // reach the server" and "server said 401" stay distinguishable instead of + // both arriving as null. curl's own errors are folded into stdout with + // --stderr - rather than a 2>&1 redirect, which needs a shell that + // system.callSystem does not reliably give us. Returns { status, body, data }. + function httpGetDetailed(url) { var command; - var response; - var parsed; + var response = null; + var markerIndex; + var result = { status: 0, body: "", data: null }; + + command = "curl -s -S --stderr - " + + "-H \"Authorization: Bearer " + CONFIG.token + "\" " + + "-H \"Accept: application/json\" " + + "-w \"__HTTP__:%{http_code}\" " + + "\"" + url + "\""; try { - url = CONFIG.baseUrl + "/api/ext/shots/lookup" + - "?shotCode=" + encodeURIComponent(shotCode) + - "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : ""); - - command = "curl -s " + - "-H \"Authorization: Bearer " + CONFIG.token + "\" " + - "-H \"Accept: application/json\" " + - "\"" + url + "\""; - response = system.callSystem(command); } catch (callError) { - return null; + result.body = "curl could not be run: " + describeError(callError); + return result; } - parsed = parseJSON(response); - if (!parsed) { - return null; + if (!response) { + result.body = "empty response (curl produced no output)"; + return result; } - return parsed; + markerIndex = String(response).lastIndexOf("__HTTP__:"); + if (markerIndex >= 0) { + result.status = parseInt(String(response).substring(markerIndex + 9), 10) || 0; + result.body = String(response).substring(0, markerIndex); + } else { + result.body = String(response); + } + + result.data = parseJSON(result.body); + return result; + } + + function summariseResponse(result) { + var body = trimText(result.body).replace(/[\r\n]+/g, " "); + + if (body.length > 200) { + body = body.substring(0, 200) + "..."; + } + return "HTTP " + result.status + (body ? " — " + body : ""); + } + + // Hints that turn the HTTP code into the thing to actually go and check. + function hintsForResponse(result) { + if (result.status === 0) { + return ["The server was not reached at all: check Server URL in Settings, that the machine can " + + "resolve/route to it, and that curl.exe exists (Windows 10 1803+ ships it)."]; + } + if (result.status === 401 || result.status === 403) { + return ["The API token was rejected: paste the token from the server .env (API_SECRET_KEY) into Settings."]; + } + if (result.status === 404) { + return ["The server answered but has no such record — check the shot/project exists in the web app."]; + } + if (result.status >= 500) { + return ["The server errored. Check the web app logs for the same timestamp."]; + } + return []; + } + + function getShotData(shotCode) { + var url = CONFIG.baseUrl + "/api/ext/shots/lookup" + + "?shotCode=" + encodeURIComponent(shotCode) + + "&projectCode=" + (CURRENT_PROJECT ? CURRENT_PROJECT.code : ""); + var result = httpGetDetailed(url); + + DIAG.lastResponse = result; + + if (!result.data || !result.data.shot) { + diagLog("shot lookup " + shotCode + ": " + summariseResponse(result)); + } + + return result.data; } function getAPIData(url) { - var command; - var response; - var parsed; + var result = httpGetDetailed(url); - try { - command = "curl -s " + - "-H \"Authorization: Bearer " + CONFIG.token + "\" " + - "-H \"Accept: application/json\" " + - "\"" + url + "\""; + DIAG.lastResponse = result; - response = system.callSystem(command); - } catch (callError) { - return null; + if (!result.data) { + diagLog("GET " + url + ": " + summariseResponse(result)); } - parsed = parseJSON(response); - if (!parsed) { - return null; - } - - return parsed; + return result.data; } function getListFromResponse(data, keyName) { @@ -1239,25 +1547,85 @@ var SETTINGS_KEY = "cfg"; var primaryFootage = null; var startInfo; var i; + var groupFolder; + var folderNames; + var emptyFolderNotes = []; + var importFailure; - apiData = getShotData(shotCode); - if (!apiData || !apiData.shot) { + diagLog("build: shot=" + shotCode + " group=" + (groupCode || "(none)") + + " episodic=" + isEpisodicProject()); + + if (!CURRENT_PROJECT) { + diagFail("project", "no project is selected in the panel", + ["Pick a project in the Project dropdown. If it is empty, the server URL or token is wrong."]); return null; } + apiData = getShotData(shotCode); + if (!apiData || !apiData.shot) { + diagFail("shot lookup", "the server returned no shot record for " + shotCode + + " (" + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response") + ")", + hintsForResponse(DIAG.lastResponse || { status: 0, body: "" }).concat( + ["Looked up under project " + CURRENT_PROJECT.code + " — a shot belonging to another project will not be found."])); + return null; + } + diagLog("shot found: version " + (apiData.shot.shotVersion || "?") + + ", episode " + (apiData.shot.episode || "-") + ", scene " + (apiData.shot.scene || "-") + + ", tc " + (apiData.shot.timecodeStart || "-")); + if (existingShotComp) { - updateOverlayForComp(existingShotComp, shotCode, apiData); + diagLog("comp " + shotCode + " already exists — refreshing its overlay only"); + if (!updateOverlayForComp(existingShotComp, shotCode, apiData)) { + diagLog("overlay refresh failed (non-fatal): UNG_VFX_OVERLAY comp or its Essential Properties are missing"); + } return existingShotComp; } + if (!trimText(CONFIG.footageRoot)) { + diagFail("configuration", "Footage Root is empty in Settings", + ["Open Settings (the gear button) and set Footage Root — the bare root, without the show folder, e.g. V:/_FOOTAGE.", + "Settings are per-machine — a workstation that has never been configured starts blank."]); + return null; + } + + groupFolder = getFootageGroupFolder(groupCode); + diagLog("footage root: " + CONFIG.footageRoot + " -> show root: " + getShowFootageRoot()); + diagLog("plate search folder: " + groupFolder.fsName); + + if (!groupFolder.exists) { + diagFail("footage folder", groupFolder.fsName + " does not exist on this machine", + [describeMissingPath(groupFolder.fsName), + "Check the drive letter/UNC in Settings matches this machine's mapping — the path is stored per-machine.", + "Check the show folder is named " + (getShowId() || "(showId missing)") + ", which comes from the project's Show ID."]); + return null; + } + sequenceFolders = findSequenceFolders(shotCode, groupCode); + diagLog(sequenceFolders.length + " plate folder(s) matched for " + shotCode); if (sequenceFolders.length < 1) { + folderNames = listFolderNames(groupFolder, 15); + diagFail("plate folders", "no plate folder for " + shotCode + " under " + groupFolder.fsName, + [folderNames.length + ? "folders present there: " + folderNames.join(", ") + : "that folder holds no sub-folders at all (" + describeFolderContents(groupFolder) + ")", + "Expected either " + shotCode + "/ with plate sub-folders, or folders named " + shotCode + "_*.", + "A " + shotCode + "/ folder with no plate sub-folders inside also lands here — it is treated as 'no plates yet'."]); return null; } for (i = 0; i < sequenceFolders.length; i += 1) { - footage = importEXRSequence(sequenceFolders[i]); + footage = null; + importFailure = ""; + try { + footage = importEXRSequence(sequenceFolders[i]); + } catch (importError) { + importFailure = describeError(importError); + diagLog("import threw for " + sequenceFolders[i].fsName + ": " + importFailure); + } + if (footage) { + diagLog("imported " + sequenceFolders[i].name + " (" + footage.width + "x" + footage.height + + ", " + Math.round(footage.duration * 24) + " frames)"); footage.name = sequenceFolders[i].name; if (footageFolder) { footage.parentFolder = footageFolder; @@ -1270,48 +1638,70 @@ var SETTINGS_KEY = "cfg"; height = footage.height; primaryFootage = footage; } + } else { + emptyFolderNotes.push(sequenceFolders[i].name + ": " + + (importFailure ? "import failed — " + importFailure : describeFolderContents(sequenceFolders[i]))); } } if (footageItems.length < 1) { + diagFail("plate import", sequenceFolders.length + " plate folder(s) matched but none held an importable EXR sequence", + emptyFolderNotes.concat(["Only *.exr files are imported — DPX/JPEG plates and .exr files inside a further sub-folder are ignored."])); return null; } if (!primaryFootage) { primaryFootage = footageItems[0]; } - // Both comps start at the plate's native start timecode (as AE would - // when creating a comp from the footage), not at frame 0. - startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24); - buildState.lastStartSource = startInfo.source; - buildState.lastStartFrames = startInfo.frames; + try { + // Both comps start at the plate's native start timecode (as AE would + // when creating a comp from the footage), not at frame 0. + startInfo = getFootageStartFrames(primaryFootage, apiData.shot, 24); + buildState.lastStartSource = startInfo.source; + buildState.lastStartFrames = startInfo.frames; + buildState.lastStartShotCode = shotCode; + diagLog("start frame " + startInfo.frames + " from " + startInfo.source); - footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24); - setCompStartFrame(footageComp, startInfo.frames); - if (precompFolder) { - footageComp.parentFolder = precompFolder; + footageComp = app.project.items.addComp(shotCode + "_FOOTAGE", width, height, 1, maxDuration, 24); + setCompStartFrame(footageComp, startInfo.frames); + if (precompFolder) { + footageComp.parentFolder = precompFolder; + } + + for (i = footageItems.length - 1; i >= 0; i -= 1) { + footageLayer = footageComp.layers.add(footageItems[i]); + addOCIOToLayer(footageLayer); + } + + shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24); + setCompStartFrame(shotComp, startInfo.frames); + if (episodeProjectFolder) { + shotComp.parentFolder = episodeProjectFolder; + } else { + diagLog("no " + groupCode + " folder under __SHOTS — comp left at the project root"); + } + + shotComp.layers.add(footageComp); + } catch (compError) { + diagFail("comp creation", describeError(compError), + ["The plates imported, so this is an After Effects error rather than a path or server problem.", + "Run Initialise Workspace if the project's folder structure is missing."]); + return null; } - for (i = footageItems.length - 1; i >= 0; i -= 1) { - footageLayer = footageComp.layers.add(footageItems[i]); - addOCIOToLayer(footageLayer); - } - - shotComp = app.project.items.addComp(shotCode, width, height, 1, maxDuration, 24); - setCompStartFrame(shotComp, startInfo.frames); - if (episodeProjectFolder) { - shotComp.parentFolder = episodeProjectFolder; - } - - shotComp.layers.add(footageComp); - showLut = findComp("_SHOW LUT"); if (showLut) { lutLayer = shotComp.layers.add(showLut); lutLayer.collapseTransformation = true; + } else { + diagLog("no _SHOW LUT comp in this project — shot built without it"); } - updateOverlayForComp(shotComp, shotCode, apiData); + if (!updateOverlayForComp(shotComp, shotCode, apiData)) { + diagLog("overlay not applied (non-fatal): UNG_VFX_OVERLAY comp missing, or its DATE/SHOT NAME Essential Properties are not exposed"); + } + diagLog("built comp " + shotComp.name + " (" + width + "x" + height + ", " + + Math.round(maxDuration * 24) + " frames, " + footageItems.length + " plate layer(s))"); return shotComp; } @@ -1320,6 +1710,7 @@ var SETTINGS_KEY = "cfg"; var shotCode; var shotComp; var isEpisodic = isEpisodicProject(); + var failure; if (!uiState.episodeDropdown || !uiState.episodeDropdown.selection || !uiState.shotDropdown || !uiState.shotDropdown.selection) { updateStatus(getSelectedComps().length, "Choose a " + (isEpisodic ? "episode" : "scene") + " and shot first."); @@ -1343,9 +1734,159 @@ var SETTINGS_KEY = "cfg"; updateStatus(getSelectedComps().length, "Shot built: " + shotCode + " (start frame " + buildState.lastStartFrames + (buildState.lastStartSource ? " from " + buildState.lastStartSource : "") + ")"); - } else { - updateStatus(getSelectedComps().length, "Shot build failed: " + shotCode); + return; } + + // Report the stage it died at rather than a bare "failed", and hand the + // artist the whole trace so it can be sent on without a screen-share. + failure = DIAG.lastFailure || { stage: "unknown", detail: "no reason was recorded", hints: [] }; + updateStatus(getSelectedComps().length, "Build failed at " + failure.stage + ": " + failure.detail); + showDiagDialog("Could not build " + shotCode + " — failed at: " + failure.stage + + "\n" + failure.detail, diagReport()); + } + + // Checks everything Build Shot depends on, without building anything, so a + // workstation can be cleared start to finish before an artist is blocked. + function runDiagnostics() { + var problems = 0; + var curlVersion; + var projects; + var groupCode = ""; + var shotCode = ""; + var groupFolder; + var sequenceFolders; + var exportRoot; + var names; + var missingItems = []; + var requiredComps = ["UNG_VFX_OVERLAY", "UNG_EXPORT_TEMPLATE", "_SHOW LUT"]; + var requiredFolders = ["__SHOTS", "_FOOTAGE_4K", "_PRECOMPS", "_PREVIEWS"]; + var i; + + diagLog("config file: " + getConfigPath()); + + try { + curlVersion = trimText(String(system.callSystem("curl --version")).split(/[\r\n]/)[0]); + } catch (curlError) { + curlVersion = ""; + } + if (/^curl /i.test(curlVersion)) { + diagLog("curl: " + curlVersion); + } else { + problems += 1; + diagLog("PROBLEM curl not usable: " + (curlVersion || "no output") + + " — every server call goes through curl.exe, so nothing will load until it is on PATH"); + } + + if (!trimText(CONFIG.baseUrl)) { + problems += 1; + diagLog("PROBLEM Server URL is empty in Settings"); + } + if (!trimText(CONFIG.token)) { + problems += 1; + diagLog("PROBLEM API Token is empty in Settings"); + } + + projects = getAPIData(CONFIG.baseUrl + "/api/ext/projects"); + if (projects) { + diagLog("API OK: " + getListFromResponse(projects, "projects").length + " project(s) visible"); + } else { + problems += 1; + diagLog("PROBLEM API call failed: " + + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response")); + names = hintsForResponse(DIAG.lastResponse || { status: 0, body: "" }); + for (i = 0; i < names.length; i += 1) { + diagLog(" - " + names[i]); + } + } + + if (!CURRENT_PROJECT) { + problems += 1; + diagLog("PROBLEM no project selected — the rest of the path checks need one"); + } else { + if (uiState.episodeDropdown && uiState.episodeDropdown.selection) { + groupCode = isEpisodicProject() + ? (uiState.episodeDropdown.selection.episodeCode || deriveEpisodeCode(uiState.episodeDropdown.selection.text)) + : (uiState.episodeDropdown.selection.filterValue || uiState.episodeDropdown.selection.text); + } + if (uiState.shotDropdown && uiState.shotDropdown.selection) { + shotCode = uiState.shotDropdown.selection.shotCode || uiState.shotDropdown.selection.text; + } + + if (!trimText(CONFIG.footageRoot)) { + problems += 1; + diagLog("PROBLEM Footage Root is empty in Settings"); + } else { + groupFolder = getFootageGroupFolder(groupCode); + if (groupFolder.exists) { + diagLog("footage folder OK: " + groupFolder.fsName + " (" + describeFolderContents(groupFolder) + ")"); + if (shotCode) { + sequenceFolders = findSequenceFolders(shotCode, groupCode); + if (sequenceFolders.length) { + for (i = 0; i < sequenceFolders.length; i += 1) { + diagLog(" plate folder " + sequenceFolders[i].name + ": " + + describeFolderContents(sequenceFolders[i])); + } + } else { + problems += 1; + names = listFolderNames(groupFolder, 15); + diagLog("PROBLEM no plate folder for " + shotCode + + (names.length ? " — folders present: " + names.join(", ") : " — folder is empty")); + } + } + } else { + problems += 1; + diagLog("PROBLEM footage folder missing: " + groupFolder.fsName); + diagLog(" - " + describeMissingPath(groupFolder.fsName)); + } + } + + exportRoot = getShowExportRoot(); + if (!trimText(CONFIG.exportRoot)) { + problems += 1; + diagLog("PROBLEM Export Root is empty in Settings — renders and deliveries have nowhere to go"); + } else if (new Folder(exportRoot).exists) { + diagLog("export root OK: " + exportRoot); + } else { + problems += 1; + diagLog("PROBLEM export root missing: " + exportRoot); + diagLog(" - " + describeMissingPath(exportRoot)); + } + + if (shotCode) { + if (getShotData(shotCode)) { + diagLog("shot lookup OK for " + shotCode); + } else { + problems += 1; + diagLog("PROBLEM shot lookup failed for " + shotCode + ": " + + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response")); + } + } + } + + for (i = 0; i < requiredComps.length; i += 1) { + if (!findComp(requiredComps[i])) { + missingItems.push("comp " + requiredComps[i]); + } + } + for (i = 0; i < requiredFolders.length; i += 1) { + if (!findFolder(requiredFolders[i])) { + missingItems.push("folder " + requiredFolders[i]); + } + } + if (missingItems.length) { + diagLog("missing from the open AE project: " + missingItems.join(", ") + + " — run Initialise Workspace, or open the show template"); + } else { + diagLog("AE project template items all present"); + } + + diagLog(problems ? problems + " problem(s) found" : "no problems found"); + updateStatus(getSelectedComps().length, problems + ? "Diagnostics: " + problems + " problem(s) — see the report" + : "Diagnostics: no problems found"); + showDiagDialog(problems + ? problems + " problem(s) found. Send this report to the pipeline TD." + : "No problems found — server, paths and template items all check out.", diagReport()); } function buildPreviews() { @@ -2831,11 +3372,122 @@ var SETTINGS_KEY = "cfg"; } } + // Every file-backed footage item the comp actually renders from, found by + // walking its layers rather than re-deriving paths from a convention: what + // was comped is the only thing the render's timecode can be checked against + // (RenderPipeline2 §8.1a). Recurses through precomps, so the plates inside + // {shotCode}_FOOTAGE are found from the shot comp. + function collectPlateRefs(comp) { + var plates = []; + var seen = {}; + + function visit(item, depth) { + var i; + var layer; + var source; + var file; + var path; + var fps; + + if (!item || depth > 4) { + return; + } + + for (i = 1; i <= item.numLayers; i += 1) { + source = null; + try { + layer = item.layer(i); + source = layer.source; + } catch (layerError) { + continue; + } + if (!source) { + continue; + } + + if (source instanceof CompItem) { + visit(source, depth + 1); + continue; + } + if (!(source instanceof FootageItem)) { + continue; + } + + // Solids, placeholders and missing footage have no file on disk. + file = null; + try { + file = source.file; + } catch (fileError) { + file = null; + } + if (!file) { + continue; + } + + path = String(file.fsName).replace(/\\/g, "/"); + if (seen[path]) { + continue; + } + seen[path] = true; + + fps = source.frameRate || item.frameRate || 24; + plates.push({ + element: source.name, + path: path, + frames: Math.round(source.duration * fps), + duration: source.duration + }); + } + } + + visit(comp, 0); + return plates; + } + + // The element the comp's timing came from: longest duration, first on tie — + // the same rule buildShotFromCode uses to pick primaryFootage. + function pickTimingPlate(plates) { + var best = null; + var i; + + for (i = 0; i < plates.length; i += 1) { + if (!best || plates[i].duration > best.duration) { + best = plates[i]; + } + } + + return best; + } + + function toManifestPlate(plate, frameStart) { + var ref = { element: plate.element, path: plate.path }; + var parsed = parseFrameNumberFromPath(plate.path); + var start = parsed !== null ? parsed : frameStart; + + ref.frameStart = start; + ref.frameEnd = start + Math.max(0, plate.frames - 1); + return ref; + } + + // "UNG_102_019_010_BG01_TT_v001.01001.exr" -> 1001 + function parseFrameNumberFromPath(path) { + var match = String(path).match(/\.(\d{2,9})\.[A-Za-z0-9]+$/); + + if (!match) { + return null; + } + return parseInt(match[1], 10); + } + function buildRenderManifest(comp, shot) { var frameStart; var frameEnd; var frames; var aepPath = app.project.file.fsName.replace(/\\/g, "/"); + var plates; + var timingPlate; + var manifest; + var i; // Prefer the shot record's authoritative range (the server cross-checks // it) — but only when it looks real: shots imported without ranges @@ -2852,8 +3504,8 @@ var SETTINGS_KEY = "cfg"; frameEnd = frameStart + frames - 1; } - return { - manifestVersion: 1, + manifest = { + manifestVersion: 2, projectCode: CURRENT_PROJECT ? CURRENT_PROJECT.code : "", shotCode: shot.shotCode, shotId: shot.id, @@ -2867,8 +3519,39 @@ var SETTINGS_KEY = "cfg"; frameEnd: frameEnd, fps: Math.round(comp.frameRate * 1000) / 1000, width: comp.width, - height: comp.height + height: comp.height, + expected: { + // The API's editorial in-point. Only ever the worker's last-resort + // timecode reference, and advisory even then (§8.1a). + timecodeStart: shot.timecodeStart || undefined + } }; + + plates = collectPlateRefs(comp); + timingPlate = pickTimingPlate(plates); + if (timingPlate) { + manifest.source = { + timingPlate: toManifestPlate(timingPlate, frameStart), + plates: [], + handleFrames: CONFIG.handleFrames + }; + for (i = 0; i < plates.length; i += 1) { + manifest.source.plates.push(toManifestPlate(plates[i], frameStart)); + } + // Only claim a start source when this session actually resolved one + // for this shot — an AEP opened from disk has no such history, and a + // guess here would either cry wolf or hide a real problem. + if (buildState.lastStartShotCode === shot.shotCode && buildState.lastStartSource) { + manifest.source.startSource = buildState.lastStartSource; + manifest.source.startFrames = buildState.lastStartFrames; + } + diagLog("manifest plates: " + plates.length + ", timing plate " + timingPlate.element); + } else { + diagLog("no file-backed footage found in " + comp.name + + " — the render's timecode cannot be checked against a plate"); + } + + return manifest; } function submitExport(manifest, urgent, force) { @@ -3061,6 +3744,224 @@ var SETTINGS_KEY = "cfg"; } } + // ── QC (RenderPipeline2 §10) ────────────────────────────────────────────── + // + // Technical QC on the *rendered EXRs*, not the H.264 preview: the point is + // catching mask slips, export glitches and metadata faults at full res, so + // selecting a queue entry imports the real sequence. + + function loadQCQueue() { + var url = CONFIG.baseUrl + "/api/ext/qc/queue"; + var data; + var entries; + var i; + var entry; + var label; + + if (!uiState.qcList) { + return; + } + if (CURRENT_PROJECT) { + url += "?projectCode=" + encodeURIComponent(CURRENT_PROJECT.code); + } + + setQCStatus("Loading QC queue..."); + data = getAPIData(url); + if (!data) { + setQCStatus("QC queue failed: " + (DIAG.lastResponse ? summariseResponse(DIAG.lastResponse) : "no response")); + return; + } + + entries = getListFromResponse(data, "queue"); + qcState.entries = entries; + uiState.qcList.removeAll(); + + for (i = 0; i < entries.length; i += 1) { + entry = entries[i]; + label = entry.shotCode + " " + entry.versionString; + if (entry.warnings && entry.warnings.length) { + label += " (" + entry.warnings.length + " warning" + (entry.warnings.length > 1 ? "s" : "") + ")"; + } + uiState.qcList.add("item", label); + } + + if (entries.length) { + uiState.qcList.selection = 0; + setQCStatus(entries.length + " export(s) awaiting QC"); + } else { + setQCStatus("Nothing waiting for QC"); + } + } + + function getSelectedQCEntry() { + var index; + + if (!uiState.qcList || !uiState.qcList.selection) { + return null; + } + index = uiState.qcList.selection.index; + if (index < 0 || index >= qcState.entries.length) { + return null; + } + return qcState.entries[index]; + } + + function describeQCEntry(entry) { + var text = entry.shotCode + " " + entry.versionString; + var i; + + if (entry.readySince) { + text += " — ready since " + String(entry.readySince).substring(0, 10); + } + for (i = 0; entry.warnings && i < entry.warnings.length; i += 1) { + text += "\n" + entry.warnings[i].status + " " + entry.warnings[i].checkName + + (entry.warnings[i].message ? ": " + entry.warnings[i].message : ""); + } + return text; + } + + // The export's own outputPattern prefix, so a version folder that also holds + // the preview MOV/MP4 still resolves to the EXR sequence. + function importQCSequence(entry) { + var folder = new Folder(entry.outputDir); + var prefix = String(entry.outputPattern || "").split("[")[0].replace(/\.$/, ""); + var files; + var importOptions; + var footage; + var comp; + var compName = "QC_" + entry.shotCode + "_" + entry.versionString; + var existing = findComp(compName); + + if (existing) { + existing.openInViewer(); + setQCStatus(compName + " already open"); + return true; + } + + if (!folder.exists) { + setQCStatus("Render folder not found: " + entry.outputDir); + diagFail("QC import", entry.outputDir + " does not exist on this machine", + [describeMissingPath(entry.outputDir), + "The render folder is written by the farm — check this machine maps the same drive."]); + return false; + } + + files = folder.getFiles(function (f) { + return f instanceof File && /\.exr$/i.test(f.name) && + (!prefix || f.name.indexOf(prefix) === 0); + }); + if (!files || files.length < 1) { + files = folder.getFiles("*.exr"); + } + if (!files || files.length < 1) { + setQCStatus("No EXRs in " + entry.outputDir); + return false; + } + + files.sort(); + try { + importOptions = new ImportOptions(files[0]); + importOptions.sequence = true; + footage = app.project.importFile(importOptions); + footage.mainSource.conformFrameRate = entry.fps || 24; + } catch (importError) { + setQCStatus("Import failed: " + describeError(importError)); + return false; + } + + try { + comp = app.project.items.addComp(compName, footage.width, footage.height, 1, + footage.duration, entry.fps || 24); + setCompStartFrame(comp, entry.frameStart || 0); + comp.layers.add(footage); + comp.openInViewer(); + } catch (compError) { + setQCStatus("Could not build QC comp: " + describeError(compError)); + return false; + } + + diagLog("QC import: " + files.length + " frames from " + entry.outputDir); + return true; + } + + function openSelectedQCEntry() { + var entry = getSelectedQCEntry(); + + if (!entry) { + setQCStatus("Select a queue entry first"); + return; + } + + setQCStatus("Importing " + entry.shotCode + " " + entry.versionString + "..."); + app.beginUndoGroup("VFXReview Connector - QC Import"); + if (importQCSequence(entry)) { + setQCStatus(describeQCEntry(entry)); + } + app.endUndoGroup(); + } + + function submitQC(result) { + var entry = getSelectedQCEntry(); + var notes = uiState.qcNotesField ? trimText(uiState.qcNotesField.text) : ""; + var body; + var response; + + if (!entry) { + setQCStatus("Select a queue entry first"); + return; + } + + // A bare FAIL leaves the artist guessing at what to fix. + if (result === "FAIL" && !notes) { + if (!confirm("Fail " + entry.shotCode + " " + entry.versionString + " with no notes?\n\n" + + "The artist only sees the reason you write here.")) { + return; + } + } + if (result === "PASS" && !confirm("QC PASS " + entry.shotCode + " " + entry.versionString + "?\n\n" + + "This marks the version ready for delivery. Nothing is sent to the client.")) { + return; + } + + body = { result: result }; + if (notes) { + body.notes = notes; + } + if (CONFIG.artistEmail) { + body.reviewerEmail = CONFIG.artistEmail; + } + + setQCStatus("Submitting QC " + result + " for " + entry.shotCode + "..."); + response = postJSON(CONFIG.baseUrl + "/api/ext/exports/" + entry.exportId + "/qc", body); + + if (response.status === 200) { + if (uiState.qcNotesField) { + uiState.qcNotesField.text = ""; + } + setQCStatus(entry.shotCode + " " + entry.versionString + " — QC " + result + + (result === "FAIL" ? " (comp task set to CHANGES)" : " — ready for delivery")); + loadQCQueue(); + } else { + setQCStatus("QC " + result + " failed: " + + ((response.data && response.data.error) ? response.data.error : "HTTP " + response.status)); + } + } + + function qcPass() { + submitQC("PASS"); + } + + function qcFail() { + submitQC("FAIL"); + } + + function setQCStatus(message) { + if (uiState.qcStatusText) { + uiState.qcStatusText.text = message || ""; + } + diagLog("QC: " + message); + } + function buildUI(thisObjRef) { var win = (thisObjRef instanceof Panel) ? thisObjRef : new Window("palette", "VFXReview Connector", undefined, { resizeable: true }); var bannerFile; @@ -3107,6 +4008,16 @@ var SETTINGS_KEY = "cfg"; var workspacePanel; var initWorkspaceButton; var statusPanel; + var statusRow; + var diagnosticsButton; + var showLogButton; + var qcPanel; + var qcRow1; + var qcRow2; + var loadQCButton; + var openQCButton; + var qcPassButton; + var qcFailButton; win.orientation = "column"; win.alignChildren = ["fill", "fill"]; @@ -3221,6 +4132,47 @@ var SETTINGS_KEY = "cfg"; "Reuses the rendered EXRs and keeps the version number. Slate text is\n" + "re-read from the shot, so fix it on the web app's Slate tab first."; + qcPanel = scrollContent.add("panel", undefined, "QC"); + qcPanel.orientation = "column"; + qcPanel.alignChildren = ["fill", "top"]; + qcPanel.margins = 8; + qcPanel.spacing = 4; + + uiState.qcStatusText = qcPanel.add("statictext", undefined, "QC queue: not loaded", { multiline: true }); + uiState.qcStatusText.preferredSize = [340, 34]; + + uiState.qcList = qcPanel.add("listbox", undefined, []); + uiState.qcList.preferredSize = [340, 90]; + uiState.qcList.helpTip = + "Exports waiting for technical QC, oldest first.\n" + + "Double-click (or Open Render) to import the full-res EXRs."; + + qcRow1 = qcPanel.add("group"); + qcRow1.orientation = "row"; + qcRow1.alignChildren = ["fill", "center"]; + qcRow1.spacing = 6; + loadQCButton = qcRow1.add("button", undefined, "Load QC Queue"); + openQCButton = qcRow1.add("button", undefined, "Open Render"); + openQCButton.helpTip = "Import the selected export's EXR sequence into a QC comp at full resolution."; + + var qcNotesGroup = qcPanel.add("group"); + qcNotesGroup.orientation = "row"; + qcNotesGroup.alignChildren = ["fill", "top"]; + qcNotesGroup.spacing = 6; + var qcNotesLabel = qcNotesGroup.add("statictext", undefined, "Notes:"); + qcNotesLabel.preferredSize = [70, 20]; + uiState.qcNotesField = qcNotesGroup.add("edittext", undefined, "", { multiline: false }); + uiState.qcNotesField.preferredSize = [240, 22]; + + qcRow2 = qcPanel.add("group"); + qcRow2.orientation = "row"; + qcRow2.alignChildren = ["fill", "center"]; + qcRow2.spacing = 6; + qcPassButton = qcRow2.add("button", undefined, "QC Pass"); + qcPassButton.helpTip = "Marks the version ready for delivery. Nothing is shared to the client."; + qcFailButton = qcRow2.add("button", undefined, "QC Fail"); + qcFailButton.helpTip = "Sends the comp task back to CHANGES. Write what is wrong in Notes."; + buttonsPanel = scrollContent.add("panel", undefined, "Actions"); buttonsPanel.orientation = "column"; buttonsPanel.alignChildren = ["fill", "top"]; @@ -3313,6 +4265,17 @@ var SETTINGS_KEY = "cfg"; uiState.selectionText = statusPanel.add("statictext", undefined, "0 comps selected"); uiState.lastActionText = statusPanel.add("statictext", undefined, "Ready"); + statusRow = statusPanel.add("group"); + statusRow.orientation = "row"; + statusRow.alignChildren = ["fill", "center"]; + statusRow.spacing = 6; + diagnosticsButton = statusRow.add("button", undefined, "Diagnostics"); + diagnosticsButton.helpTip = + "Check server, token, footage/export paths and template items on this\n" + + "machine without building anything, and show a report that can be copied."; + showLogButton = statusRow.add("button", undefined, "Show Last Report"); + showLogButton.helpTip = "Re-open the trace from the last action, and a link to the log folder."; + uiState.projectDropdown.onChange = function () { if (uiState.projectDropdown.selection) { CURRENT_PROJECT = uiState.projectDropdown.selection.projectData; @@ -3324,36 +4287,45 @@ var SETTINGS_KEY = "cfg"; } }; uiState.episodeDropdown.onChange = populateShotDropdown; - // ScriptUI silently swallows exceptions thrown inside onClick handlers. - settingsBtn.onClick = function () { - try { - openSettingsDialog(); - } catch (settingsError) { - alert("VFXReview Connector \u2014 Settings error:\n" + settingsError.toString() + - (settingsError.line ? "\nLine " + settingsError.line : "")); + // Every handler goes through guarded(): it traces the click to the log + // and surfaces anything thrown, which ScriptUI would otherwise swallow. + settingsBtn.onClick = guarded("Settings", openSettingsDialog); + shotBuilderButton.onClick = guarded("Build Shot", buildShotFromDropdown); + queueExportButton.onClick = guarded("Queue Export", queueExport); + refreshExportButton.onClick = guarded("Refresh Status", refreshExportStatus); + retryExportButton.onClick = guarded("Retry Export", retryLatestExport); + regeneratePreviewButton.onClick = guarded("Regenerate Previews", regenerateLatestPreview); + refreshButton.onClick = guarded("Refresh", refreshSelection); + fixNamesButton.onClick = guarded("Fix Comp Names", fixCompNames); + overlayButton.onClick = guarded("Add Overlay", addOverlay); + previewButton.onClick = guarded("Build Preview", buildPreviews); + exrButton.onClick = guarded("Queue EXR", queueEXR); + exrPreviewButton.onClick = guarded("Queue EXR (Review)", queueEXRPreview); + importExrButton.onClick = guarded("Import EXR", importExportedEXR); + prepDeliveryButton.onClick = guarded("Prep Delivery", prepDelivery); + mp4Button.onClick = guarded("Queue MP4", queueMP4); + movButton.onClick = guarded("Queue MOV", queueMOV); + ocioButton.onClick = guarded("Add OCIO", applyOCIOColorSpace); + initWorkspaceButton.onClick = guarded("Initialise Workspace", initialiseWorkspace); + importRendersButton.onClick = guarded("Import Renders", importRenders); + pictureLockButton.onClick = guarded("Pull Picture Lock", pullPictureLock); + setStartTCButton.onClick = guarded("Set Start Timecode", setStartFrameFromTimecode); + incrementVersionButton.onClick = guarded("Increment Shot Version", incrementShotVersions); + loadQCButton.onClick = guarded("Load QC Queue", loadQCQueue); + openQCButton.onClick = guarded("Open QC Render", openSelectedQCEntry); + uiState.qcList.onDoubleClick = guarded("Open QC Render", openSelectedQCEntry); + uiState.qcList.onChange = function () { + var entry = getSelectedQCEntry(); + if (entry) { + setQCStatus(describeQCEntry(entry)); } }; - shotBuilderButton.onClick = buildShotFromDropdown; - queueExportButton.onClick = queueExport; - refreshExportButton.onClick = refreshExportStatus; - retryExportButton.onClick = retryLatestExport; - regeneratePreviewButton.onClick = regenerateLatestPreview; - refreshButton.onClick = refreshSelection; - fixNamesButton.onClick = fixCompNames; - overlayButton.onClick = addOverlay; - previewButton.onClick = buildPreviews; - exrButton.onClick = queueEXR; - exrPreviewButton.onClick = queueEXRPreview; - importExrButton.onClick = importExportedEXR; - prepDeliveryButton.onClick = prepDelivery; - mp4Button.onClick = queueMP4; - movButton.onClick = queueMOV; - ocioButton.onClick = applyOCIOColorSpace; - initWorkspaceButton.onClick = initialiseWorkspace; - importRendersButton.onClick = importRenders; - pictureLockButton.onClick = pullPictureLock; - setStartTCButton.onClick = setStartFrameFromTimecode; - incrementVersionButton.onClick = incrementShotVersions; + qcPassButton.onClick = guarded("QC Pass", qcPass); + qcFailButton.onClick = guarded("QC Fail", qcFail); + diagnosticsButton.onClick = guarded("Diagnostics", runDiagnostics); + showLogButton.onClick = function () { + showDiagDialog("Trace from the last action.", diagReport()); + }; win.layout.layout(true); win.layout.resize(); diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index a42e92f..85e6b6f 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -123,13 +123,15 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`. ## Panel Layout -The panel is divided into five sections: +The panel is divided into seven sections: 1. **Shot Builder** — Episode/shot selection and comp creation -2. **Actions** — Per-shot and batch operations -3. **Workspace** — One-time project initialisation -4. **Color Space** — OCIO effect management -5. **Status** — Live feedback on selected comps and last action +2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews +3. **QC** — Technical QC queue for rendered exports +4. **Actions** — Per-shot and batch operations +5. **Workspace** — One-time project initialisation +6. **Color Space** — OCIO effect management +7. **Status** — Live feedback, plus Diagnostics and the last report --- @@ -154,6 +156,28 @@ Constructs a full shot composite in the open AE project from the episode and sho 7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp. 8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders. +If any step fails, the status line names the stage and a copyable report opens — +see [Diagnostics and logging](#diagnostics-and-logging). + +--- + +## QC + +Technical QC on the **rendered EXRs**, not the H.264 preview — the point is +catching mask slips, export glitches and metadata faults at full resolution. +QC is per-export: a new export always needs new QC. + +| Control | Behaviour | +|---|---| +| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. | +| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. | +| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. | +| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. | +| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. | + +Passing an export is what makes it eligible for a delivery package on the web +app's **Pipeline → Deliveries** page. + --- ## Actions @@ -402,3 +426,65 @@ The **Status** panel at the bottom of the UI shows two lines: - **Line 2** — Result or progress message from the last operation. Both lines are updated throughout batch operations so progress is visible in real time. + +Below them are two buttons: + +- **Diagnostics** — runs the environment self-test described below. +- **Show Last Report** — re-opens the trace from the last button pressed. + +--- + +## Diagnostics and logging + +Every button press is traced: what it tried, in what order, with the paths and +HTTP codes it was working with. The trace goes to an in-memory buffer and to a +log file, and it is shown automatically when something fails. + +**Log file:** `%APPDATA%/VFXReviewConnector/connector.log` (next to `config.json`). +It is truncated once it passes 1 MB, so it can always be pasted into a message. +The API token is never written to it — only whether one is set. + +### When Build Shot fails + +The status line now names the stage it failed at, and a scrollable report opens +with the whole trace. The stages, and what each one means: + +| Stage | Meaning | Usual fix | +|---|---|---| +| `project` | No project selected in the panel | The project dropdown is empty because the server URL or token is wrong | +| `shot lookup` | The server returned no shot record | Report shows the HTTP code: `0` = server unreachable, `401/403` = bad token, `404` = shot not in that project | +| `configuration` | Footage Root is blank in Settings | Settings are **per-machine**; a fresh workstation starts blank | +| `footage folder` | The show/episode folder does not exist here | Report names the deepest folder that *does* exist — usually an unmapped drive or a different drive letter/UNC on that machine | +| `plate folders` | Folder exists, no plates for that shot code | Report lists the folders that *are* there, so a naming mismatch is visible | +| `plate import` | Plate folders matched but held no importable EXRs | Report lists each folder's contents (`n .exr, n other files`) | +| `comp creation` | After Effects threw while building the comps | Not a path or server problem — the AE error text is in the report | +| `unexpected error` | Anything thrown that was not anticipated | Report carries the error text, line number and stack | + +Non-fatal conditions are traced too rather than passing silently: a missing +`_SHOW LUT`, an overlay that could not be applied, a missing `__SHOTS/{group}` +folder. + +### Diagnostics button + +Checks everything Build Shot depends on **without building anything**, so a +workstation can be cleared before an artist is blocked. It verifies: + +1. `curl.exe` is present and runnable (every server call goes through it). +2. Server URL and API token are set, and `GET /api/ext/projects` succeeds. +3. Footage Root resolves to a folder that exists, and lists the plate folders + found for the currently selected shot. +4. Export Root resolves to a folder that exists. +5. The shot lookup succeeds for the currently selected shot. +6. The open AE project has `UNG_VFX_OVERLAY`, `UNG_EXPORT_TEMPLATE`, + `_SHOW LUT`, and the `__SHOTS` / `_FOOTAGE_4K` / `_PRECOMPS` / `_PREVIEWS` + folders. + +Each failure is reported as a `PROBLEM` line with the offending path or HTTP +response, and the report ends with a problem count. + +### Errors that used to be invisible + +ScriptUI silently swallows exceptions thrown inside a button handler, so a hard +failure looked like a button that did nothing at all. Every handler is now +wrapped: anything thrown is caught, logged with its line number and stack, shown +in the status line, and surfaced in the report dialog. diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx index 9732105..3b4fab6 100644 --- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx +++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; -import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server } from "lucide-react"; +import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { @@ -112,6 +112,12 @@ export function PipelineQueueClient({ projects }: Props) {

+ + + + +

Delivery packages

+
+ + {/* Picker */} +
+ + {episodes.length > 0 && ( + + )} +
+ + {/* Ready to build */} + {projectId && ( +
+
+ Ready for delivery +
+ {readyLoading &&
Loading…
} + {!readyLoading && shown.length === 0 && ( +
+ Nothing has passed QC yet. Exports appear here once QC passes them. +
+ )} + {shown.map((ep) => ( +
+
+
+ + {ep.episode ? `Episode ${ep.episode}` : "No episode"} + + Ready: {ep.count} shots + {ep.exports.some((x) => !x.hasChecksum) && ( + + {ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum + + )} +
+ {canBuild && ( + + )} +
+
+ {ep.exports.map((x) => ( +
+ + {x.shotCode} + + {x.versionString} + {x.fileCount ?? "?"} frames + {formatBytes(x.totalBytes)} + {!x.deliveryMovPath && no MOV} +
+ ))} +
+
+ ))} +
+ )} + + {/* History — packages are permanent (§11.5) */} +
+
Packages
+
+ {(packages?.packages ?? []).map((p) => ( +
+ + #{p.packageNumber} + {p.name} + + {statusLabel(p.status)} + + + {p.episode ? `episode ${p.episode} · ` : ""} + {p.verifiedCount}/{p.itemCount} shots + + {new Date(p.createdAt).toLocaleDateString()} + {p.status === "BUILDING" && } + + {canBuild && p.status === "READY" && ( + + )} + {canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && ( + + )} + + +
+ {p.rootPath &&
{p.rootPath}
} + {p.errorMessage &&
{p.errorMessage}
} + {p.deliveredAt && ( +
+ Delivered {new Date(p.deliveredAt).toLocaleString()} +
+ )} +
+ {p.items.map((item) => ( +
+ + {item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"} + + {item.shotCode} + {item.versionString} + {item.fileCount != null && {item.fileCount}f} + {item.linkMode && {item.linkMode}} + {item.errorMessage && {item.errorMessage}} +
+ ))} +
+
+
+ ))} + {(packages?.packages ?? []).length === 0 && ( +
No packages built yet
+ )} +
+
+ + ); +} diff --git a/app/(dashboard)/pipeline/deliveries/page.tsx b/app/(dashboard)/pipeline/deliveries/page.tsx new file mode 100644 index 0000000..163cf92 --- /dev/null +++ b/app/(dashboard)/pipeline/deliveries/page.tsx @@ -0,0 +1,12 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { DeliveriesClient } from "./DeliveriesClient"; + +export const dynamic = "force-dynamic"; + +export default async function DeliveriesPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (session.user.role === "CLIENT") redirect("/dashboard"); + return ; +} diff --git a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx index c0f6635..8b67811 100644 --- a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx +++ b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx @@ -66,9 +66,42 @@ interface ExportDetail { note: string | null; createdAt: string; }[]; + validations: { + id: string; + renderJobId: string; + checkName: string; + status: string; + expected: string | null; + actual: string | null; + message: string | null; + details: unknown; + createdAt: string; + }[]; + qcReviews: { + id: string; + result: string; + notes: string | null; + reviewerName: string | null; + source: string; + createdAt: string; + }[]; + deliveryItems: { + id: string; + status: string; + exrPath: string | null; + linkMode: string | null; + package: { id: string; name: string; episode: string; status: string; packageNumber: number }; + }[]; }; } +const CHECK_STYLES: Record = { + PASS: "text-emerald-400", + FAIL: "text-red-400", + WARN: "text-amber-400", + SKIPPED: "text-zinc-500", +}; + export function ExportDetailClient({ exportId }: { exportId: string }) { const { toast } = useToast(); const queryClient = useQueryClient(); @@ -189,6 +222,93 @@ export function ExportDetailClient({ exportId }: { exportId: string }) { )} + {/* Validation results — newest run first */} + {e.validations.length > 0 && ( +
+
+ Validation + {(() => { + const latestRun = e.validations[e.validations.length - 1].renderJobId; + const rows = e.validations.filter((v) => v.renderJobId === latestRun); + const failed = rows.filter((v) => v.status === "FAIL").length; + const warned = rows.filter((v) => v.status === "WARN").length; + return ( + + {failed ? `${failed} failed` : warned ? `${warned} warning${warned > 1 ? "s" : ""}` : "all checks passed"} + + ); + })()} +
+
+ {(() => { + const latestRun = e.validations[e.validations.length - 1].renderJobId; + // Failures first: the reason the export stopped should not need scrolling for. + const order = { FAIL: 0, WARN: 1, PASS: 2, SKIPPED: 3 } as Record; + return e.validations + .filter((v) => v.renderJobId === latestRun) + .slice() + .sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9)) + .map((v) => ( +
+ + {v.status} + + {v.checkName} + + {v.message ?? + (v.expected === v.actual ? v.actual : `expected ${v.expected ?? "—"}, got ${v.actual ?? "—"}`)} + +
+ )); + })()} +
+
+ )} + + {/* QC history — latest decision is the authoritative one */} + {e.qcReviews.length > 0 && ( +
+
QC
+
+ {e.qcReviews.map((q) => ( +
+
+ + QC {q.result} + + {q.reviewerName ?? "unknown reviewer"} + {q.source === "AE_PANEL" ? "AE panel" : "web"} + {new Date(q.createdAt).toLocaleString()} +
+ {q.notes &&
{q.notes}
} +
+ ))} +
+
+ )} + + {/* Delivery membership */} + {e.deliveryItems.length > 0 && ( +
+
Deliveries
+
+ {e.deliveryItems.map((d) => ( +
+ + #{d.package.packageNumber} · {d.package.name} + + episode {d.package.episode || "—"} + {d.package.status} + + item {d.status.toLowerCase()} + {d.linkMode ? ` · ${d.linkMode}` : ""} + +
+ ))} +
+
+ )} + {/* Render attempts */}
diff --git a/app/(dashboard)/pipeline/status-colors.ts b/app/(dashboard)/pipeline/status-colors.ts index f1e7bec..c0e3b3a 100644 --- a/app/(dashboard)/pipeline/status-colors.ts +++ b/app/(dashboard)/pipeline/status-colors.ts @@ -18,6 +18,16 @@ export const EXPORT_STATUS_STYLES: Record = { CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", }; +export const DELIVERY_STATUS_STYLES: Record = { + DRAFT: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30", + QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30", + BUILDING: "bg-blue-500/15 text-blue-400 border-blue-500/30", + READY: "bg-green-500/15 text-green-400 border-green-500/30", + DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", + FAILED: "bg-red-500/15 text-red-400 border-red-500/30", + CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30", +}; + export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"]; export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"]; diff --git a/app/api/deliveries/[packageId]/cancel/route.ts b/app/api/deliveries/[packageId]/cancel/route.ts new file mode 100644 index 0000000..3cfa5a4 --- /dev/null +++ b/app/api/deliveries/[packageId]/cancel/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth"; +import { cancelDeliveryPackage } from "@/lib/render-pipeline/deliveries"; + +// ── POST /api/deliveries/{id}/cancel ───────────────────────────────────────── +// +// Packages are never deleted (§11.5); a mistaken one is cancelled. + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ packageId: string }> } +) { + try { + const user = await requirePipelineAdmin(); + const { packageId } = await params; + const result = await cancelDeliveryPackage(packageId, user.id); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/deliveries/[packageId]/delivered/route.ts b/app/api/deliveries/[packageId]/delivered/route.ts new file mode 100644 index 0000000..d91da3f --- /dev/null +++ b/app/api/deliveries/[packageId]/delivered/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth"; +import { markDelivered } from "@/lib/render-pipeline/deliveries"; + +// ── POST /api/deliveries/{id}/delivered (E19) ──────────────────────────────── +// +// The folder has been uploaded to the client. Moves every packaged export to +// DELIVERED — the end of the pipeline. + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ packageId: string }> } +) { + try { + const user = await requirePipelineAdmin(); + const { packageId } = await params; + const result = await markDelivered(packageId, user.id); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/deliveries/ready/route.ts b/app/api/deliveries/ready/route.ts new file mode 100644 index 0000000..3fd039c --- /dev/null +++ b/app/api/deliveries/ready/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineUser } from "@/lib/render-pipeline/session-auth"; +import { getReadyForDelivery } from "@/lib/render-pipeline/deliveries"; + +// ── GET /api/deliveries/ready?projectId=&episode= (E18) ────────────────────── +// +// READY_FOR_DELIVERY exports grouped by episode — "Episode 101 — Ready: 18 shots". + +export async function GET(req: NextRequest) { + try { + await requirePipelineUser(); + const { searchParams } = new URL(req.url); + const projectId = searchParams.get("projectId"); + if (!projectId) { + return NextResponse.json({ error: "projectId is required" }, { status: 400 }); + } + const result = await getReadyForDelivery(projectId, searchParams.get("episode")); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/deliveries/route.ts b/app/api/deliveries/route.ts new file mode 100644 index 0000000..ee2b987 --- /dev/null +++ b/app/api/deliveries/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { DeliveryStatus } from "@prisma/client"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth"; +import { + createDeliveryPackage, + createDeliverySchema, + listDeliveryPackages, +} from "@/lib/render-pipeline/deliveries"; + +// ── GET /api/deliveries (E17) · POST /api/deliveries (E16) ─────────────────── + +export async function GET(req: NextRequest) { + try { + await requirePipelineUser(); + const { searchParams } = new URL(req.url); + const status = searchParams.get("status"); + const result = await listDeliveryPackages({ + projectId: searchParams.get("projectId") ?? undefined, + episode: searchParams.get("episode") ?? undefined, + status: status ? (status as DeliveryStatus) : undefined, + }); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} + +export async function POST(req: NextRequest) { + try { + // Building a package writes to the delivery volume and locks versions — + // producer/supervisor territory, not any signed-in user. + const user = await requirePipelineAdmin(); + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = createDeliverySchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid delivery request", details: parsed.error.issues }, { status: 422 }); + } + + const result = await createDeliveryPackage(parsed.data, user.id); + return NextResponse.json(result, { status: 201 }); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/ext/exports/[exportId]/qc/route.ts b/app/api/ext/exports/[exportId]/qc/route.ts new file mode 100644 index 0000000..b2babb8 --- /dev/null +++ b/app/api/ext/exports/[exportId]/qc/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc"; + +// ── POST /api/ext/exports/{exportId}/qc (E4) ───────────────────────────────── +// +// QC Pass / QC Fail from the AE panel. PASS → READY_FOR_DELIVERY and nothing +// else; FAIL → QC_FAILED and the comp task back to CHANGES (§10.2). + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ exportId: string }> } +) { + if (!isExtAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { exportId } = await params; + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = qcSubmitSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 }); + } + + try { + const result = await submitQCReview(exportId, parsed.data, { source: "AE_PANEL" }); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/ext/qc/queue/route.ts b/app/api/ext/qc/queue/route.ts new file mode 100644 index 0000000..0f48a16 --- /dev/null +++ b/app/api/ext/qc/queue/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { getQCQueue } from "@/lib/render-pipeline/qc"; + +// ── GET /api/ext/qc/queue?projectCode=&episode= (E5) ───────────────────────── +// +// Exports sitting in READY_FOR_QC, oldest first, with everything the panel +// needs to import the real EXR sequence for full-res QC. + +export async function GET(req: NextRequest) { + if (!isExtAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + try { + const result = await getQCQueue({ + projectCode: searchParams.get("projectCode"), + episode: searchParams.get("episode"), + limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined, + }); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/ext/render/jobs/[jobId]/complete-render/route.ts b/app/api/ext/render/jobs/[jobId]/complete-render/route.ts index 91a0263..1e9ab77 100644 --- a/app/api/ext/render/jobs/[jobId]/complete-render/route.ts +++ b/app/api/ext/render/jobs/[jobId]/complete-render/route.ts @@ -5,8 +5,10 @@ import { completeRender } from "@/lib/render-pipeline/jobs"; // ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ────────────────── // -// aerender finished with exit 0. Phase 2 interim: Export goes to a provisional -// READY_FOR_QC; Phase 3 inserts VALIDATING between (§17.3). +// aerender finished with exit 0 → Export goes to VALIDATING and the worker +// keeps the job to run the checks (E12). With validation disabled the preview +// stage is queued directly. `validationRequired` in the response tells the +// worker which of the two happened. export async function POST( req: NextRequest, diff --git a/app/api/ext/render/jobs/[jobId]/delivery/route.ts b/app/api/ext/render/jobs/[jobId]/delivery/route.ts new file mode 100644 index 0000000..1fbe52d --- /dev/null +++ b/app/api/ext/render/jobs/[jobId]/delivery/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { deliveryReportSchema, reportDeliveryBuild } from "@/lib/render-pipeline/deliveries"; + +// ── POST /api/ext/render/jobs/{jobId}/delivery ─────────────────────────────── +// +// The worker finished (or gave up on) a DELIVERY_BUILD job: per-item results, +// the manifest it wrote, and whether the package as a whole is good. + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ jobId: string }> } +) { + if (!isExtAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { jobId } = await params; + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = deliveryReportSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "Delivery report is malformed", details: parsed.error.issues }, { status: 422 }); + } + + try { + const result = await reportDeliveryBuild(jobId, parsed.data); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/ext/render/jobs/[jobId]/validation/route.ts b/app/api/ext/render/jobs/[jobId]/validation/route.ts new file mode 100644 index 0000000..ae85563 --- /dev/null +++ b/app/api/ext/render/jobs/[jobId]/validation/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { reportValidation, validationReportSchema } from "@/lib/render-pipeline/validation"; + +// ── POST /api/ext/render/jobs/{jobId}/validation (E12) ─────────────────────── +// +// The worker reports every check from one validation run in a single call. +// Any FAIL stops the pipeline (Export → VALIDATION_FAILED); otherwise the +// preview stage is queued. + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ jobId: string }> } +) { + if (!isExtAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { jobId } = await params; + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = validationReportSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation report is malformed", details: parsed.error.issues }, + { status: 422 } + ); + } + + try { + const result = await reportValidation(jobId, parsed.data); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/render/exports/[exportId]/qc/route.ts b/app/api/render/exports/[exportId]/qc/route.ts new file mode 100644 index 0000000..8325a52 --- /dev/null +++ b/app/api/render/exports/[exportId]/qc/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineUser } from "@/lib/render-pipeline/session-auth"; +import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc"; + +// ── POST /api/render/exports/{exportId}/qc ─────────────────────────────────── +// +// Web twin of E4 — same service, session auth. QC is not locked to AE (§10.2). + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ exportId: string }> } +) { + try { + const user = await requirePipelineUser(); + const { exportId } = await params; + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = qcSubmitSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 }); + } + + const result = await submitQCReview(exportId, parsed.data, { + source: "WEB", + userId: user.id, + name: user.name ?? user.email ?? null, + }); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/render/qc/queue/route.ts b/app/api/render/qc/queue/route.ts new file mode 100644 index 0000000..83e20db --- /dev/null +++ b/app/api/render/qc/queue/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineUser } from "@/lib/render-pipeline/session-auth"; +import { getQCQueue } from "@/lib/render-pipeline/qc"; + +// ── GET /api/render/qc/queue?projectId=&episode= ───────────────────────────── +// +// Web twin of E5 for the QC page. + +export async function GET(req: NextRequest) { + try { + await requirePipelineUser(); + const { searchParams } = new URL(req.url); + const result = await getQCQueue({ + projectId: searchParams.get("projectId"), + episode: searchParams.get("episode"), + limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined, + }); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/lib/render-pipeline/config.ts b/lib/render-pipeline/config.ts index 0229b34..63ce3df 100644 --- a/lib/render-pipeline/config.ts +++ b/lib/render-pipeline/config.ts @@ -12,7 +12,20 @@ export const PIPELINE_CONFIG_DEFAULTS = { "render.stallTimeoutSeconds": 600, "render.urgentPriorityThreshold": 20, "render.outputRoot": "//SAN", + // Validation stage (§8). Disabling it restores the pre-Phase-3 flow + // (render → preview) without a redeploy, for a bad engine release. + "validation.enabled": "true", "validation.sampleEvery": 25, + /** Per-check severity overrides, e.g. {"exr_type":"warn"} (§17.3). */ + "validation.overrides": "{}", + /** Frames of head handle the plates carry; only used for the API timecode fallback (§8.1a). */ + "validation.handleFrames": 8, + /** + * Studio-wide delivery defaults (§11.2). A project's own `deliveryConfig` + * column overrides these key by key; `{}` falls through to the built-ins in + * lib/render-pipeline/deliveries.ts. + */ + "delivery.defaultConfig": "{}", "preview.maxWidth": 1920, // Preview stage (§9): headless AE rebuild of the render with slate/burn-ins "preview.enabled": "true", @@ -71,13 +84,23 @@ export async function getPreviewConfig() { /** Worker-facing config bundle returned by E6 registration. */ export async function getWorkerConfig() { - const [pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds] = - await Promise.all([ - getConfigNumber("render.pollSeconds"), - getConfigNumber("render.heartbeatSeconds"), - getConfigNumber("render.leaseSeconds"), - getConfigNumber("render.maxAttempts"), - getConfigNumber("render.stallTimeoutSeconds"), - ]); - return { pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds }; + const [ + pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds, + validationEnabled, validationSampleEvery, validationHandleFrames, + ] = await Promise.all([ + getConfigNumber("render.pollSeconds"), + getConfigNumber("render.heartbeatSeconds"), + getConfigNumber("render.leaseSeconds"), + getConfigNumber("render.maxAttempts"), + getConfigNumber("render.stallTimeoutSeconds"), + getConfigBoolean("validation.enabled"), + getConfigNumber("validation.sampleEvery"), + getConfigNumber("validation.handleFrames"), + ]); + return { + pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds, + // Whether the validation stage runs is the server's call, so switching it + // off is a config change here rather than a redeploy of every worker (§8). + validationEnabled, validationSampleEvery, validationHandleFrames, + }; } diff --git a/lib/render-pipeline/deliveries.ts b/lib/render-pipeline/deliveries.ts new file mode 100644 index 0000000..c7ac2dd --- /dev/null +++ b/lib/render-pipeline/deliveries.ts @@ -0,0 +1,611 @@ +import { DeliveryStatus, Prisma } from "@prisma/client"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { PipelineError } from "./errors"; +import { applyTransition } from "./transitions"; +import { getConfigNumber, getConfigValue } from "./config"; + +/** + * Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a + * package and records it; a worker does the file work through the same queue + * and claim path as a render. + * + * Version locking: a package references Export rows and denormalises + * shotCode/versionString at creation, so a later re-render can never mutate + * delivery history. + */ + +// ── Config (§11.2) ─────────────────────────────────────────────────────────── + +export interface DeliveryConfig { + packageNameTemplate: string; + layout: { packageDir: string; shotDir: string; reportFile: string }; + deliveryRoot: string; + includeMovs: boolean; + checksumAlgo: string; + reportFormats: string[]; + copyMode: "auto" | "always-copy"; +} + +export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = { + packageNameTemplate: "{yymmdd}_Delivery", + layout: { + packageDir: "{deliveryRoot}/{episode}/{packageName}", + shotDir: "{packageDir}/{shotCode}", + reportFile: "{packageDir}/{packageName}_report.csv", + }, + deliveryRoot: "//SAN/DELIVERY", + includeMovs: true, + checksumAlgo: "xxh64", + reportFormats: ["csv", "json"], + copyMode: "auto", +}; + +/** + * Project config → SystemConfig default → built-in. Merged shallowly per top + * level key so a project can override just `deliveryRoot` without restating the + * whole layout. + */ +export async function resolveDeliveryConfig(projectDeliveryConfig: unknown): Promise { + let systemDefault: Partial = {}; + const raw = await getConfigValue("delivery.defaultConfig"); + if (raw?.trim() && raw.trim() !== "{}") { + try { + systemDefault = JSON.parse(raw) as Partial; + } catch { + console.error("[render-pipeline] delivery.defaultConfig is not valid JSON — ignoring"); + } + } + + const project = (projectDeliveryConfig ?? {}) as Partial; + return { + ...DELIVERY_CONFIG_DEFAULT, + ...systemDefault, + ...project, + layout: { + ...DELIVERY_CONFIG_DEFAULT.layout, + ...(systemDefault.layout ?? {}), + ...(project.layout ?? {}), + }, + }; +} + +export interface TokenValues { + showId: string; + episode: string; + yymmdd: string; + yyyymmdd: string; + packageName: string; + packageDir: string; + deliveryRoot: string; + shotCode: string; + versionString: string; + packageNumber: string; +} + +export function applyTokens(template: string, tokens: Partial): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => { + const value = (tokens as Record)[key]; + return value === undefined ? whole : value; + }); +} + +export function dateTokens(date: Date) { + const yyyy = String(date.getFullYear()); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + return { yymmdd: `${yyyy.slice(2)}${mm}${dd}`, yyyymmdd: `${yyyy}${mm}${dd}` }; +} + +// ── E18: what is ready ─────────────────────────────────────────────────────── + +/** + * READY_FOR_DELIVERY exports grouped by episode. Only the newest ready export + * per shot is offered: an older ready version of the same shot is history, not + * a second thing to ship. + */ +export async function getReadyForDelivery(projectId: string, episode?: string | null) { + const rows = await db.export.findMany({ + where: { + projectId, + status: "READY_FOR_DELIVERY", + ...(episode ? { shot: { episode } } : {}), + }, + orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }], + include: { + shot: { select: { id: true, shotCode: true, episode: true } }, + qcReviews: { orderBy: { createdAt: "desc" }, take: 1, select: { result: true, reviewerName: true, createdAt: true } }, + }, + }); + + const newestPerShot = new Map(); + for (const row of rows) { + if (!newestPerShot.has(row.shotId)) newestPerShot.set(row.shotId, row); + } + + const byEpisode = new Map[]>(); + for (const row of newestPerShot.values()) { + const key = row.shot.episode ?? ""; + if (!byEpisode.has(key)) byEpisode.set(key, []); + byEpisode.get(key)!.push(serialiseReady(row)); + } + + return { + episodes: [...byEpisode.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([ep, exports]) => ({ + episode: ep, + count: exports.length, + exports: exports.sort((a, b) => a.shotCode.localeCompare(b.shotCode)), + })), + }; +} + +function serialiseReady(row: { + id: string; + versionString: string; + outputDir: string; + exrFileCount: number | null; + exrTotalBytes: bigint | null; + deliveryMovPath: string | null; + checksum: string | null; + frameStart: number; + frameEnd: number; + shot: { id: string; shotCode: string; episode: string | null }; + qcReviews: { result: string; reviewerName: string | null; createdAt: Date }[]; +}) { + return { + exportId: row.id, + shotId: row.shot.id, + shotCode: row.shot.shotCode, + episode: row.shot.episode ?? "", + versionString: row.versionString, + outputDir: row.outputDir, + frameStart: row.frameStart, + frameEnd: row.frameEnd, + fileCount: row.exrFileCount, + totalBytes: row.exrTotalBytes == null ? null : Number(row.exrTotalBytes), + deliveryMovPath: row.deliveryMovPath, + hasChecksum: row.checksum != null, + qc: row.qcReviews[0] + ? { result: row.qcReviews[0].result, reviewer: row.qcReviews[0].reviewerName, at: row.qcReviews[0].createdAt } + : null, + }; +} + +// ── E16: create a package ──────────────────────────────────────────────────── + +export const createDeliverySchema = z.object({ + projectId: z.string().min(1), + episode: z.string().max(50).optional().nullable(), + /** Omitted → every ready export in the episode. */ + exportIds: z.array(z.string().min(1)).max(500).optional(), + options: z + .object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + name: z.string().max(120).optional(), + includeMovs: z.boolean().optional(), + copyMode: z.enum(["auto", "always-copy"]).optional(), + }) + .optional(), +}); + +export type CreateDeliveryInput = z.infer; + +export async function createDeliveryPackage(input: CreateDeliveryInput, userId: string) { + const project = await db.project.findUnique({ + where: { id: input.projectId }, + select: { id: true, code: true, showId: true, deliveryConfig: true }, + }); + if (!project) throw new PipelineError(404, "Project not found"); + + const config = await resolveDeliveryConfig(project.deliveryConfig); + if (input.options?.includeMovs !== undefined) config.includeMovs = input.options.includeMovs; + if (input.options?.copyMode) config.copyMode = input.options.copyMode; + + const buildDate = input.options?.date ? new Date(`${input.options.date}T00:00:00`) : new Date(); + if (Number.isNaN(buildDate.getTime())) throw new PipelineError(422, "options.date is not a valid date"); + + const maxAttempts = await getConfigNumber("render.maxAttempts"); + + return db.$transaction(async (tx) => { + const exports = await tx.export.findMany({ + where: { + projectId: project.id, + status: "READY_FOR_DELIVERY", + ...(input.exportIds?.length ? { id: { in: input.exportIds } } : {}), + ...(input.episode ? { shot: { episode: input.episode } } : {}), + }, + include: { shot: { select: { id: true, shotCode: true, episode: true } } }, + orderBy: { shotId: "asc" }, + }); + + if (exports.length === 0) { + throw new PipelineError(422, "No READY_FOR_DELIVERY exports match — nothing to package"); + } + if (input.exportIds?.length && exports.length !== input.exportIds.length) { + const found = new Set(exports.map((e) => e.id)); + throw new PipelineError( + 409, + "Some exports are no longer ready for delivery (re-rendered or already packaged?)", + { missing: input.exportIds.filter((id) => !found.has(id)) } + ); + } + // One export per shot: two versions of the same shot in one package is + // always a mistake, and it is cheaper to refuse than to ship it. + const shotIds = new Set(); + for (const e of exports) { + if (shotIds.has(e.shotId)) { + throw new PipelineError(422, `Two versions of ${e.shot.shotCode} selected — pick one`); + } + shotIds.add(e.shotId); + } + + const episode = input.episode ?? exports[0].shot.episode ?? ""; + const dates = dateTokens(buildDate); + const showId = project.showId || project.code; + + const lastNumber = await tx.deliveryPackage.aggregate({ + where: { projectId: project.id }, + _max: { packageNumber: true }, + }); + const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1; + + const tokens: TokenValues = { + showId, + episode, + ...dates, + packageName: "", + packageDir: "", + deliveryRoot: config.deliveryRoot, + shotCode: "", + versionString: "", + packageNumber: String(packageNumber), + }; + tokens.packageName = input.options?.name ?? applyTokens(config.packageNameTemplate, tokens); + tokens.packageDir = applyTokens(config.layout.packageDir, tokens); + + const pkg = await tx.deliveryPackage.create({ + data: { + projectId: project.id, + episode, + name: tokens.packageName, + packageNumber, + status: "QUEUED", + rootPath: tokens.packageDir, + // Snapshot: historic packages always show the rules they were built with. + configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue, + createdById: userId, + }, + }); + + await tx.deliveryItem.createMany({ + data: exports.map((e) => ({ + packageId: pkg.id, + exportId: e.id, + shotId: e.shotId, + shotCode: e.shot.shotCode, + versionString: e.versionString, + })), + }); + + const manifest = await buildDeliveryJobManifest(tx, pkg.id); + const job = await tx.renderJob.create({ + data: { + type: "DELIVERY_BUILD", + deliveryId: pkg.id, + attempt: 1, + maxAttempts, + // Deliveries are interactive: someone is waiting on the folder, and the + // work is file copying rather than hours of CPU, so it jumps renders. + priority: 10, + manifest: manifest as unknown as Prisma.InputJsonValue, + }, + }); + + return { + package: { + id: pkg.id, + name: pkg.name, + packageNumber, + episode, + status: pkg.status, + rootPath: pkg.rootPath, + itemCount: exports.length, + }, + renderJob: { id: job.id }, + }; + }); +} + +// ── The DELIVERY_BUILD manifest ────────────────────────────────────────────── + +export interface DeliveryManifestItem { + itemId: string; + exportId: string; + shotCode: string; + versionString: string; + sourceDir: string; + outputPattern: string; + frameStart: number; + frameEnd: number; + fileCount: number | null; + checksum: string | null; + deliveryMovPath: string | null; + shotDir: string; +} + +export interface DeliveryManifest { + stage: "DELIVERY"; + packageId: string; + packageName: string; + packageNumber: number; + episode: string; + packageDir: string; + reportFile: string; + includeMovs: boolean; + copyMode: string; + checksumAlgo: string; + reportFormats: string[]; + items: DeliveryManifestItem[]; +} + +export async function buildDeliveryJobManifest( + tx: Prisma.TransactionClient, + packageId: string +): Promise { + const pkg = await tx.deliveryPackage.findUniqueOrThrow({ + where: { id: packageId }, + include: { + items: { include: { export: true } }, + project: { select: { code: true, showId: true } }, + }, + }); + + const snapshot = pkg.configSnapshot as unknown as DeliveryConfig & { tokens: TokenValues }; + const baseTokens: TokenValues = { + ...snapshot.tokens, + packageName: pkg.name, + packageDir: pkg.rootPath ?? snapshot.tokens.packageDir, + episode: pkg.episode, + }; + + return { + stage: "DELIVERY", + packageId: pkg.id, + packageName: pkg.name, + packageNumber: pkg.packageNumber, + episode: pkg.episode, + packageDir: baseTokens.packageDir, + reportFile: applyTokens(snapshot.layout.reportFile, baseTokens), + includeMovs: snapshot.includeMovs, + copyMode: snapshot.copyMode ?? "auto", + checksumAlgo: snapshot.checksumAlgo, + reportFormats: snapshot.reportFormats, + items: pkg.items.map((item) => ({ + itemId: item.id, + exportId: item.exportId, + shotCode: item.shotCode, + versionString: item.versionString, + sourceDir: item.export.outputDir, + outputPattern: item.export.outputPattern, + frameStart: item.export.frameStart, + frameEnd: item.export.frameEnd, + fileCount: item.export.exrFileCount, + checksum: item.export.checksum, + deliveryMovPath: snapshot.includeMovs ? item.export.deliveryMovPath : null, + shotDir: applyTokens(snapshot.layout.shotDir, { + ...baseTokens, + shotCode: item.shotCode, + versionString: item.versionString, + }), + })), + }; +} + +// ── Worker reporting ───────────────────────────────────────────────────────── + +export const deliveryReportSchema = z.object({ + machineId: z.string().min(1), + items: z + .array( + z.object({ + itemId: z.string().min(1), + status: z.enum(["COPIED", "VERIFIED", "FAILED"]), + exrPath: z.string().max(1000).optional().nullable(), + movPath: z.string().max(1000).optional().nullable(), + fileCount: z.number().int().nonnegative().optional(), + totalBytes: z.number().nonnegative().optional(), + checksum: z.string().max(200).optional().nullable(), + linkMode: z.enum(["hardlink", "copy"]).optional().nullable(), + errorMessage: z.string().max(2000).optional().nullable(), + }) + ) + .max(500), + manifest: z.unknown().optional(), + reportKey: z.string().max(500).optional().nullable(), + rootPath: z.string().max(1000).optional().nullable(), + errorMessage: z.string().max(2000).optional().nullable(), + /** false → the package failed as a whole. */ + ok: z.boolean(), +}); + +export type DeliveryReport = z.infer; + +/** + * The worker finished (or gave up on) a DELIVERY_BUILD job. Exports move to + * PACKAGED only for items that actually verified — a package with a failed item + * is FAILED, and nothing about it is silently treated as delivered. + */ +export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) { + return db.$transaction(async (tx) => { + const job = await tx.renderJob.findUnique({ where: { id: jobId } }); + if (!job) throw new PipelineError(404, "Render job not found"); + if (job.machineId !== body.machineId) { + throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)"); + } + if (!job.deliveryId) throw new PipelineError(422, "Job has no delivery package"); + + const pkg = await tx.deliveryPackage.findUnique({ + where: { id: job.deliveryId }, + include: { items: true }, + }); + if (!pkg) throw new PipelineError(404, "Delivery package not found"); + if (pkg.status === "READY" || pkg.status === "DELIVERED") { + return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true }; + } + + await tx.renderJob.update({ + where: { id: job.id }, + data: { + status: body.ok ? "COMPLETED" : "FAILED", + progress: 1, + finishedAt: new Date(), + errorMessage: body.errorMessage ?? null, + }, + }); + + for (const reported of body.items) { + await tx.deliveryItem.updateMany({ + where: { id: reported.itemId, packageId: pkg.id }, + data: { + status: reported.status, + exrPath: reported.exrPath ?? null, + movPath: reported.movPath ?? null, + fileCount: reported.fileCount ?? null, + totalBytes: reported.totalBytes == null ? null : BigInt(Math.round(reported.totalBytes)), + checksum: reported.checksum ?? null, + linkMode: reported.linkMode ?? null, + errorMessage: reported.errorMessage ?? null, + }, + }); + } + + const verified = body.items.filter((i) => i.status === "VERIFIED"); + const failed = body.items.filter((i) => i.status === "FAILED"); + const success = body.ok && failed.length === 0 && verified.length === pkg.items.length; + + if (success) { + for (const item of pkg.items) { + const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } }); + if (exportRow?.status === "READY_FOR_DELIVERY") { + await applyTransition(tx, exportRow, "PACKAGED", { + type: "WORKER", + id: body.machineId, + note: `Packaged into #${pkg.packageNumber} ${pkg.name}`, + }); + } + } + } + + const updated = await tx.deliveryPackage.update({ + where: { id: pkg.id }, + data: { + status: success ? "READY" : "FAILED", + builtAt: new Date(), + rootPath: body.rootPath ?? pkg.rootPath, + manifest: (body.manifest ?? undefined) as Prisma.InputJsonValue | undefined, + reportKey: body.reportKey ?? pkg.reportKey, + errorMessage: success + ? null + : (body.errorMessage ?? + (failed.length ? `${failed.length} item(s) failed: ${failed.map((f) => f.itemId).join(", ")}` : "Build did not complete")), + }, + }); + + return { + package: { id: updated.id, status: updated.status }, + verified: verified.length, + failed: failed.length, + alreadyApplied: false, + }; + }); +} + +// ── E17 / E19 / cancel ─────────────────────────────────────────────────────── + +export async function listDeliveryPackages(filter: { projectId?: string; episode?: string; status?: DeliveryStatus }) { + const packages = await db.deliveryPackage.findMany({ + where: { + ...(filter.projectId ? { projectId: filter.projectId } : {}), + ...(filter.episode ? { episode: filter.episode } : {}), + ...(filter.status ? { status: filter.status } : {}), + }, + orderBy: { createdAt: "desc" }, + include: { + items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } }, + project: { select: { id: true, name: true, code: true } }, + }, + }); + + return { + packages: packages.map((p) => ({ + id: p.id, + packageNumber: p.packageNumber, + name: p.name, + episode: p.episode, + status: p.status, + rootPath: p.rootPath, + errorMessage: p.errorMessage, + projectName: p.project.name, + projectCode: p.project.code, + createdAt: p.createdAt, + builtAt: p.builtAt, + deliveredAt: p.deliveredAt, + itemCount: p.items.length, + verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length, + items: p.items, + })), + }; +} + +/** E19 — the human uploaded the folder to the client and is confirming it. */ +export async function markDelivered(packageId: string, userId: string) { + return db.$transaction(async (tx) => { + const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } }); + if (!pkg) throw new PipelineError(404, "Delivery package not found"); + if (pkg.status === "DELIVERED") { + return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true }; + } + if (pkg.status !== "READY") { + throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`); + } + + for (const item of pkg.items) { + const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } }); + if (exportRow?.status === "PACKAGED") { + await applyTransition(tx, exportRow, "DELIVERED", { + type: "USER", + id: userId, + note: `Delivered in #${pkg.packageNumber} ${pkg.name}`, + }); + } + } + + const updated = await tx.deliveryPackage.update({ + where: { id: pkg.id }, + data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId }, + }); + return { package: { id: updated.id, status: updated.status }, alreadyApplied: false }; + }); +} + +/** A mistaken package is cancelled, never deleted (§11.5). */ +export async function cancelDeliveryPackage(packageId: string, userId: string) { + return db.$transaction(async (tx) => { + const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } }); + if (!pkg) throw new PipelineError(404, "Delivery package not found"); + if (!["DRAFT", "QUEUED", "FAILED"].includes(pkg.status)) { + throw new PipelineError(409, `Package is ${pkg.status} — only DRAFT, QUEUED or FAILED packages can be cancelled`); + } + await tx.renderJob.updateMany({ + where: { deliveryId: pkg.id, status: { in: ["QUEUED", "CLAIMED", "RUNNING"] } }, + data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Delivery package cancelled" }, + }); + const updated = await tx.deliveryPackage.update({ + where: { id: pkg.id }, + data: { status: "CANCELLED", errorMessage: `Cancelled by user ${userId}` }, + }); + return { package: { id: updated.id, status: updated.status } }; + }); +} diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index c6f1a0f..7d541b3 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -1,7 +1,7 @@ import { ExportStatus, Prisma } from "@prisma/client"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; -import { renderManifestSchema, type RenderManifest } from "./manifest"; +import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest"; import { applyTransition, type TransitionActor } from "./transitions"; import { getConfigNumber, getConfigValue } from "./config"; @@ -227,6 +227,9 @@ export async function createExport(input: CreateExportInput) { }, }); + // Plate provenance is recorded at queue time so a suspect export is + // visible in the queue before it renders (§8.1a). + const provenance = describePlateProvenance(resolvedManifest); await tx.exportEvent.create({ data: { exportId: created.id, @@ -234,7 +237,9 @@ export async function createExport(input: CreateExportInput) { toStatus: "QUEUED", actorType: "USER", actorId: submittedBy?.id ?? null, - note: `Queue Export ${versionString}${input.force ? " (force)" : ""}`, + note: + `Queue Export ${versionString}${input.force ? " (force)" : ""} — ` + + `${provenance.ok ? "" : "WARNING: "}${provenance.note}`, }, }); @@ -333,10 +338,22 @@ export async function getExportDetail(exportId: string) { include: { machine: { select: { id: true, name: true } } }, }, events: { orderBy: { createdAt: "asc" } }, + validations: { orderBy: { createdAt: "asc" } }, + qcReviews: { orderBy: { createdAt: "desc" } }, + deliveryItems: { + include: { + package: { select: { id: true, name: true, episode: true, status: true, packageNumber: true } }, + }, + }, }, }); if (!exp) throw new PipelineError(404, "Export not found"); - return { export: serializeExport(exp) }; + return { + export: { + ...serializeExport(exp), + deliveryItems: exp.deliveryItems.map((i) => ({ ...i, totalBytes: i.totalBytes == null ? null : Number(i.totalBytes) })), + }, + }; } /** diff --git a/lib/render-pipeline/jobs.ts b/lib/render-pipeline/jobs.ts index d8a5dba..1c6ff33 100644 --- a/lib/render-pipeline/jobs.ts +++ b/lib/render-pipeline/jobs.ts @@ -74,6 +74,14 @@ export async function claimNextJob(machineId: string, types?: string[]) { }); } + // Delivery packages have their own small lifecycle on the same queue. + if (job.deliveryId) { + await db.deliveryPackage.updateMany({ + where: { id: job.deliveryId, status: "QUEUED" }, + data: { status: "BUILDING" }, + }); + } + return { job: { id: job.id, @@ -232,8 +240,11 @@ export async function reportFail( /** * E11 — aerender finished OK. - * Phase 2 interim (§17.3): transitions straight to a provisional READY_FOR_QC; - * Phase 3 will insert VALIDATING between (see transitions.ts note). + * + * Hands off to validation (§8) when it is enabled: the worker then runs the + * checks and reports them via E12, which queues the preview stage. With + * validation disabled the old direct handoff to the preview stage is used, so + * the kill switch restores the pre-Phase-3 flow exactly. */ export async function completeRender( jobId: string, @@ -255,12 +266,17 @@ export async function completeRender( throw new PipelineError(409, `Job is ${job.status} — contradictory complete report rejected`); } + // With validation to follow, the job deliberately stays open and keeps its + // lease: a worker that dies mid-validation is then reaped and retried like + // any other stalled job, instead of leaving the Export stuck in VALIDATING. + const validationEnabled = job.export ? await getConfigBoolean("validation.enabled") : false; + await tx.renderJob.update({ where: { id: job.id }, data: { - status: "COMPLETED", + status: validationEnabled ? "RUNNING" : "COMPLETED", progress: 1, - finishedAt: new Date(), + ...(validationEnabled ? {} : { finishedAt: new Date() }), exitCode: 0, logFileKey: body.logFileKey ?? job.logFileKey, logTail: body.logTail?.slice(-20000) ?? job.logTail, @@ -277,8 +293,25 @@ export async function completeRender( ...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}), }; - // The EXR render is only the first stage: hand off to the preview stage - // (headless AE rebuild → delivery MOV + review MP4) when it is enabled. + // The EXR render is only the first stage. Validation comes next when + // enabled; the worker reports its results via E12 on the same job. + if (validationEnabled) { + const exp = await applyTransition(tx, job.export, "VALIDATING", { + type: "WORKER", + id: machineId, + note: `Render complete in ${body.renderSeconds ?? "?"}s — validating`, + }, stats); + return { + job: { id: job.id, status: "RUNNING" }, + export: { id: exp.id, status: exp.status }, + previewJob: null, + validationRequired: true, + alreadyApplied: false, + }; + } + + // Validation disabled: hand off to the preview stage (headless AE rebuild + // → delivery MOV + review MP4) exactly as before Phase 3. const previewEnabled = await getConfigBoolean("preview.enabled"); if (previewEnabled) { const manifest = await buildPreviewManifest(exportRow); diff --git a/lib/render-pipeline/manifest.ts b/lib/render-pipeline/manifest.ts index cb47106..dcfe141 100644 --- a/lib/render-pipeline/manifest.ts +++ b/lib/render-pipeline/manifest.ts @@ -9,6 +9,20 @@ import { z } from "zod"; * generates them from the shot's exrOutput convention and the NEW version * number it decides (§6.4 E1). */ +/** + * A plate element imported into the comp, resolved by the panel from the comp's + * own footage layers — not re-derived from a path convention (§6.2). + */ +export const plateRefSchema = z.object({ + element: z.string().optional(), + /** First frame of the sequence on disk; the timecode reference file. */ + path: z.string().min(1), + frameStart: z.number().int().optional(), + frameEnd: z.number().int().optional(), +}); + +export type PlateRef = z.infer; + export const renderManifestSchema = z .object({ manifestVersion: z.number().int().min(1).default(1), @@ -37,6 +51,30 @@ export const renderManifestSchema = z timecodeStart: z.string().optional(), }) .optional(), + /** + * Plate provenance for the timecode check (§8.1a). Optional so + * manifestVersion 1 panels keep working — validation then degrades to a + * worker-derived plate, and finally to the API value as a WARN. + */ + source: z + .object({ + /** + * The element the comp's start time was anchored to (the panel's + * primaryFootage). A shot's elements carry unrelated camera timecodes, + * so the timing reference must be named rather than guessed. + */ + timingPlate: plateRefSchema.optional(), + plates: z.array(plateRefSchema).optional(), + /** + * Verbatim from the panel's start-frame resolution: "footage metadata" + * (trustworthy) or "API timecode …" (fallback, suspect). Known at queue + * time, so a suspect export is flagged before it burns render hours. + */ + startSource: z.string().optional(), + startFrames: z.number().int().optional(), + handleFrames: z.number().int().optional(), + }) + .optional(), preview: z .object({ template: z.string().optional(), @@ -50,3 +88,29 @@ export const renderManifestSchema = z }); export type RenderManifest = z.infer; + +/** + * Queue-time read of the manifest's plate provenance (§8.1a). A comp whose + * start time came from the API rather than the plate's own metadata is very + * likely to fail the timecode check — worth recording before the render burns + * machine hours, not after. + */ +export function describePlateProvenance(manifest: RenderManifest): { ok: boolean; note: string } { + const src = manifest.source; + if (!src?.timingPlate?.path) { + return { + ok: false, + note: "no timing plate in manifest — timecode will be checked against a worker-derived plate", + }; + } + // Only warn on a start source we positively know to be the API fallback. An + // absent startSource means the comp predates this session (an AEP opened from + // disk), which is normal — the worker still does the authoritative check. + if (src.startSource && /\bAPI\b/i.test(src.startSource)) { + return { + ok: false, + note: `comp start came from "${src.startSource}", not the plate's own metadata — timecode check likely to fail`, + }; + } + return { ok: true, note: `timing plate ${src.timingPlate.element ?? src.timingPlate.path}` }; +} diff --git a/lib/render-pipeline/qc.ts b/lib/render-pipeline/qc.ts new file mode 100644 index 0000000..d2cc348 --- /dev/null +++ b/lib/render-pipeline/qc.ts @@ -0,0 +1,173 @@ +import { Prisma, QCResult } from "@prisma/client"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { PipelineError } from "./errors"; +import { applyTransition } from "./transitions"; + +/** + * QC workflow (RenderPipeline2 §10) — technical/final-delivery QC on shots the + * client has usually already approved. One service, two front doors: the AE + * panel (API key) and the web QC page (session). + * + * PASS means "ready to upload" and nothing else: it never shares to the client + * portal, never changes approval state, never creates client-visible media + * (§10.0). FAIL does move the comp task to CHANGES — the shot genuinely needs + * rework, and the existing REVISIONS derivation should surface that. + */ + +export const qcSubmitSchema = z.object({ + result: z.enum(["PASS", "FAIL"]), + notes: z.string().max(4000).optional().nullable(), + /** Panel sends the artist's email; web callers pass the session user instead. */ + reviewerEmail: z.string().email().optional().nullable(), + reviewerName: z.string().max(200).optional().nullable(), +}); + +export type QCSubmitInput = z.infer; + +export interface QCActor { + source: "AE_PANEL" | "WEB"; + userId?: string | null; + name?: string | null; +} + +/** Statuses a QC decision can be recorded from. */ +const QC_ELIGIBLE = ["READY_FOR_QC", "QC_FAILED", "READY_FOR_DELIVERY"] as const; + +export async function submitQCReview(exportId: string, input: QCSubmitInput, actor: QCActor) { + const reviewer = input.reviewerEmail + ? await db.user.findUnique({ where: { email: input.reviewerEmail }, select: { id: true, name: true } }) + : null; + const reviewedById = actor.userId ?? reviewer?.id ?? null; + const reviewerName = actor.name ?? reviewer?.name ?? input.reviewerName ?? input.reviewerEmail ?? null; + + return db.$transaction(async (tx) => { + const exportRow = await tx.export.findUnique({ + where: { id: exportId }, + include: { shot: { select: { shotCode: true } } }, + }); + if (!exportRow) throw new PipelineError(404, "Export not found"); + + if (!QC_ELIGIBLE.includes(exportRow.status as (typeof QC_ELIGIBLE)[number])) { + throw new PipelineError( + 409, + `Export is ${exportRow.status} — QC can only be recorded on ${QC_ELIGIBLE.join(", ")}` + ); + } + + const result = input.result as QCResult; + const review = await tx.qCReview.create({ + data: { + exportId, + result, + notes: input.notes?.trim() || null, + reviewedById, + reviewerName, + source: actor.source, + }, + }); + + const who = reviewerName ?? "unknown reviewer"; + let taskUpdated: { id: string; status: string } | null = null; + + if (result === "PASS") { + // Re-passing an export that is already READY_FOR_DELIVERY is a no-op + // transition-wise; the QCReview row is still the record of the decision. + const updated = + exportRow.status === "READY_FOR_DELIVERY" + ? exportRow + : await applyTransition(tx, exportRow, "READY_FOR_DELIVERY", { + type: "USER", + id: reviewedById, + note: `QC PASS by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`, + }); + + return { + export: { id: updated.id, status: updated.status }, + review: { id: review.id, result, createdAt: review.createdAt }, + task: null, + }; + } + + // FAIL — the shot needs rework, so the comp task goes back to CHANGES. + const updated = + exportRow.status === "QC_FAILED" + ? exportRow + : await applyTransition(tx, exportRow, "QC_FAILED", { + type: "USER", + id: reviewedById, + note: `QC FAIL by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`, + }); + + if (exportRow.taskId) { + const task = await tx.task.findUnique({ where: { id: exportRow.taskId }, select: { id: true, status: true } }); + if (task && task.status !== "CHANGES") { + await tx.task.update({ where: { id: task.id }, data: { status: "CHANGES" } }); + taskUpdated = { id: task.id, status: "CHANGES" }; + } + } + + return { + export: { id: updated.id, status: updated.status }, + review: { id: review.id, result, createdAt: review.createdAt }, + task: taskUpdated, + }; + }); +} + +export interface QCQueueFilter { + projectCode?: string | null; + projectId?: string | null; + episode?: string | null; + limit?: number; +} + +/** + * E5 — everything waiting for a human. QC_FAILED is deliberately excluded: a + * failed export is the artist's problem until they queue a new version. + */ +export async function getQCQueue(filter: QCQueueFilter) { + const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); + + const where: Prisma.ExportWhereInput = { + status: "READY_FOR_QC", + ...(filter.projectId ? { projectId: filter.projectId } : {}), + ...(filter.projectCode ? { shot: { project: { code: filter.projectCode } } } : {}), + ...(filter.episode + ? { shot: { ...(filter.projectCode ? { project: { code: filter.projectCode } } : {}), episode: filter.episode } } + : {}), + }; + + const rows = await db.export.findMany({ + where, + orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC + take: limit, + include: { + shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } }, + validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } }, + }, + }); + + return { + queue: rows.map((e) => ({ + exportId: e.id, + shotCode: e.shot.shotCode, + episode: e.shot.episode, + scene: e.shot.scene, + projectCode: e.shot.project.code, + versionString: e.versionString, + readySince: e.statusChangedAt, + // Everything the panel needs to import the actual EXRs for full-res QC + outputDir: e.outputDir, + outputPattern: e.outputPattern, + frameStart: e.frameStart, + frameEnd: e.frameEnd, + fps: e.fps, + deliveryMovPath: e.deliveryMovPath, + thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null, + previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null, + // Warnings the automated pass raised — the human should look harder at these + warnings: e.validations.map((v) => ({ checkName: v.checkName, status: v.status, message: v.message })), + })), + }; +} diff --git a/lib/render-pipeline/reaper.ts b/lib/render-pipeline/reaper.ts index 68b1fb4..6678ef3 100644 --- a/lib/render-pipeline/reaper.ts +++ b/lib/render-pipeline/reaper.ts @@ -29,6 +29,21 @@ export async function reapExpiredLeases() { where: { id: job.id }, data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" }, }); + + // A delivery build that died leaves a half-written folder, so it is + // failed rather than silently retried: someone should look at what + // landed on the delivery volume before it is rebuilt. + if (job.deliveryId) { + await tx.deliveryPackage.updateMany({ + where: { id: job.deliveryId, status: "BUILDING" }, + data: { + status: "FAILED", + errorMessage: "Worker became unreachable mid-build — check the package folder before rebuilding", + }, + }); + return; + } + if (!job.export) return; if (job.attempt < job.maxAttempts) { @@ -42,16 +57,19 @@ export async function reapExpiredLeases() { manifest: job.manifest as Prisma.InputJsonValue, }, }); - if (job.export.status === "RENDERING") { + // VALIDATING counts too: the worker holds the same job through the + // validation stage, so dying there means the render must be redone — + // nothing was established about the EXRs either way. + if (["RENDERING", "VALIDATING"].includes(job.export.status)) { await applyTransition(tx, job.export, "QUEUED", { type: "SYSTEM", - note: `Lease expired on attempt ${job.attempt} — requeued as attempt ${next.attempt}`, + note: `Lease expired on attempt ${job.attempt} during ${job.export.status} — requeued as attempt ${next.attempt}`, }); } - } else if (job.export.status === "RENDERING") { + } else if (["RENDERING", "VALIDATING"].includes(job.export.status)) { await applyTransition(tx, job.export, "RENDER_FAILED", { type: "SYSTEM", - note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} — no retries remain`, + note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} during ${job.export.status} — no retries remain`, }); } }); diff --git a/lib/render-pipeline/transitions.ts b/lib/render-pipeline/transitions.ts index 0bd2957..259e49a 100644 --- a/lib/render-pipeline/transitions.ts +++ b/lib/render-pipeline/transitions.ts @@ -5,22 +5,24 @@ import { PipelineError } from "./errors"; * Export lifecycle state machine (RenderPipeline2 §4). * The server is the sole authority on state; workers *request* transitions. * - * NOTE (Phase 2 interim): RENDERING → READY_FOR_QC is temporarily legal. - * Phase 3 inserts VALIDATING between them (§17.3) — remove READY_FOR_QC from - * RENDERING's list when the validation engine ships. + * RENDERING → GENERATING_PREVIEW / READY_FOR_QC are the `validation.enabled=false` + * paths (the Phase 3 kill switch, §8), not interim edges: with validation on, + * a completed render always goes RENDERING → VALIDATING first. */ export const EXPORT_TRANSITIONS: Record = { QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only RENDERING: [ "VALIDATING", - "GENERATING_PREVIEW", // interim: preview stage runs before validation exists + "GENERATING_PREVIEW", // validation disabled "RENDER_FAILED", "QUEUED", // lease expiry / retryable fail with attempts remaining (T5) "CANCELLED", - "READY_FOR_QC", // preview disabled / manual mark-done + "READY_FOR_QC", // preview + validation disabled / manual mark-done ], RENDER_FAILED: ["QUEUED", "SUPERSEDED"], - VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW"], + // QUEUED/RENDER_FAILED: the worker died *during* validation, so the render is + // retried — nothing was learned about the EXRs either way (reaper). + VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW", "READY_FOR_QC", "QUEUED", "RENDER_FAILED", "CANCELLED"], VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"], GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"], PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"], diff --git a/lib/render-pipeline/validation.ts b/lib/render-pipeline/validation.ts new file mode 100644 index 0000000..1afa674 --- /dev/null +++ b/lib/render-pipeline/validation.ts @@ -0,0 +1,284 @@ +import { Prisma, ValidationStatus } from "@prisma/client"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { PipelineError } from "./errors"; +import { applyTransition } from "./transitions"; +import { getConfigBoolean, getConfigValue, getPreviewConfig } from "./config"; +import { buildPreviewManifest, createPreviewJob } from "./preview"; + +/** + * Validation engine results (RenderPipeline2 §8). The worker runs the checks — + * it has the files — and reports every result in one call. The server decides + * what they mean: it is the only thing that moves an Export's state. + */ + +export const validationCheckSchema = z.object({ + checkName: z.string().min(1).max(64), + status: z.enum(["PASS", "FAIL", "WARN", "SKIPPED"]), + expected: z.string().max(500).optional().nullable(), + actual: z.string().max(500).optional().nullable(), + message: z.string().max(2000).optional().nullable(), + details: z.unknown().optional(), +}); + +export const validationReportSchema = z.object({ + machineId: z.string().min(1), + checks: z.array(validationCheckSchema).min(1).max(200), + /** Sequence-level digest reused by the delivery builder for copy verification. */ + checksum: z.string().max(200).optional(), + checksumAlgo: z.string().max(32).optional(), + exrFileCount: z.number().int().nonnegative().optional(), + exrTotalBytes: z.number().nonnegative().optional(), + validationSeconds: z.number().nonnegative().optional(), +}); + +export type ValidationReport = z.infer; + +/** + * Per-check severity overrides, e.g. `{"exr_type":"warn"}` (§17.3 risk row). + * A false FAIL blocks an artist, so a check can be demoted without a redeploy. + * `timecode_plate_match` is deliberately overridable too — but demoting it + * should be a considered, temporary act: it is the check the engine exists for. + */ +async function getSeverityOverrides(): Promise> { + const raw = await getConfigValue("validation.overrides"); + if (!raw?.trim()) return {}; + try { + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + for (const [check, level] of Object.entries(parsed)) { + const up = String(level).toUpperCase(); + if (up === "PASS" || up === "FAIL" || up === "WARN" || up === "SKIPPED") { + out[check] = up as ValidationStatus; + } + } + return out; + } catch { + console.error("[render-pipeline] validation.overrides is not valid JSON — ignoring"); + return {}; + } +} + +function summarise(checks: { checkName: string; status: ValidationStatus }[]): string { + const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIPPED: 0 }; + for (const c of checks) counts[c.status] += 1; + return `${counts.PASS} passed, ${counts.FAIL} failed, ${counts.WARN} warned, ${counts.SKIPPED} skipped`; +} + +/** + * E12 — the worker reports every check from one validation run. + * + * Any FAIL stops the pipeline (§8.2): Export → VALIDATION_FAILED with the exact + * reasons queryable as rows. Otherwise the preview stage is queued, exactly as + * a successful render used to do directly. + */ +export async function reportValidation(jobId: string, body: ValidationReport) { + const overrides = await getSeverityOverrides(); + const previewEnabled = (await getPreviewConfig()).enabled; + + const effective = body.checks.map((c) => ({ + ...c, + status: (overrides[c.checkName] ?? c.status) as ValidationStatus, + overridden: overrides[c.checkName] != null && overrides[c.checkName] !== c.status, + })); + + return db.$transaction(async (tx) => { + const job = await tx.renderJob.findUnique({ + where: { id: jobId }, + include: { export: true }, + }); + if (!job) throw new PipelineError(404, "Render job not found"); + if (job.machineId !== body.machineId) { + throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)"); + } + const exportRow = job.export; + if (!exportRow) throw new PipelineError(422, "Job has no export to validate"); + + // Replayed report (worker retried the call after a network cut): the rows + // for this attempt already exist, so don't duplicate them or re-transition. + const existing = await tx.validationResult.count({ + where: { exportId: exportRow.id, renderJobId: job.id }, + }); + if (existing > 0) { + return { + export: { id: exportRow.id, status: exportRow.status }, + results: existing, + failed: exportRow.status === "VALIDATION_FAILED", + alreadyApplied: true, + }; + } + + if (exportRow.status !== "VALIDATING") { + throw new PipelineError( + 409, + `Export is ${exportRow.status} — validation results are only accepted while VALIDATING` + ); + } + + // The render job was held open through validation (see completeRender) so a + // worker dying here is still reaped; the work is finished now. + await tx.renderJob.update({ + where: { id: job.id }, + data: { status: "COMPLETED", progress: 1, finishedAt: new Date() }, + }); + + await tx.validationResult.createMany({ + data: effective.map((c) => ({ + exportId: exportRow.id, + renderJobId: job.id, + checkName: c.checkName, + status: c.status, + expected: c.expected ?? null, + actual: c.actual ?? null, + message: c.overridden + ? `${c.message ?? "check reported " + c.status} [severity overridden by config]` + : (c.message ?? null), + details: (c.details ?? undefined) as Prisma.InputJsonValue | undefined, + })), + }); + + const failures = effective.filter((c) => c.status === "FAIL"); + const stats = { + ...(body.exrFileCount != null ? { exrFileCount: body.exrFileCount } : {}), + ...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}), + ...(body.checksum ? { checksum: body.checksum } : {}), + }; + const summary = summarise(effective); + + if (failures.length > 0) { + const reasons = failures + .slice(0, 5) + .map((f) => `${f.checkName}: ${f.message ?? `expected ${f.expected}, got ${f.actual}`}`) + .join("; "); + const updated = await applyTransition( + tx, + exportRow, + "VALIDATION_FAILED", + { + type: "WORKER", + id: body.machineId, + note: `Validation failed (${summary}) — ${reasons}${failures.length > 5 ? ` (+${failures.length - 5} more)` : ""}`, + }, + stats + ); + return { + export: { id: updated.id, status: updated.status }, + results: effective.length, + failed: true, + failures: failures.map((f) => f.checkName), + alreadyApplied: false, + }; + } + + // Passed: hand off to the preview stage, the same handoff completeRender + // used to make directly before validation existed. + if (!previewEnabled) { + const updated = await applyTransition( + tx, + exportRow, + "READY_FOR_QC", + { type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); preview stage disabled` }, + stats + ); + return { + export: { id: updated.id, status: updated.status }, + results: effective.length, + failed: false, + previewJob: null, + alreadyApplied: false, + }; + } + + const manifest = await buildPreviewManifest(exportRow); + const previewJob = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority); + const updated = await applyTransition( + tx, + exportRow, + "GENERATING_PREVIEW", + { type: "WORKER", id: body.machineId, note: `Validation passed (${summary}) — queued preview build` }, + stats + ); + + return { + export: { id: updated.id, status: updated.status }, + results: effective.length, + failed: false, + previewJob: { id: previewJob.id, type: previewJob.type }, + alreadyApplied: false, + }; + }); +} + +/** Whether the validation stage runs at all (kill switch for a bad engine release). */ +export async function isValidationEnabled(): Promise { + return getConfigBoolean("validation.enabled"); +} + +export interface ValidationBrowserFilter { + projectId?: string; + episode?: string; + checkName?: string; + status?: ValidationStatus; + page?: number; + limit?: number; +} + +/** + * Cross-export browser (§13) — the point is spotting a systemic fault + * ("every shot this week failed timecode_plate_match"), not one bad render. + */ +export async function listValidationResults(filter: ValidationBrowserFilter) { + const page = Math.max(1, filter.page ?? 1); + const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); + const where: Prisma.ValidationResultWhereInput = { + ...(filter.checkName ? { checkName: filter.checkName } : {}), + ...(filter.status ? { status: filter.status } : { status: { in: ["FAIL", "WARN"] } }), + ...(filter.projectId || filter.episode + ? { + export: { + ...(filter.projectId ? { projectId: filter.projectId } : {}), + ...(filter.episode ? { shot: { episode: filter.episode } } : {}), + }, + } + : {}), + }; + + const [total, rows] = await Promise.all([ + db.validationResult.count({ where }), + db.validationResult.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + include: { + export: { + select: { + id: true, + versionString: true, + status: true, + shot: { select: { shotCode: true, episode: true } }, + }, + }, + }, + }), + ]); + + return { + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + results: rows.map((r) => ({ + id: r.id, + checkName: r.checkName, + status: r.status, + expected: r.expected, + actual: r.actual, + message: r.message, + details: r.details, + createdAt: r.createdAt, + exportId: r.exportId, + versionString: r.export.versionString, + exportStatus: r.export.status, + shotCode: r.export.shot.shotCode, + episode: r.export.shot.episode, + })), + }; +} diff --git a/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql b/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql new file mode 100644 index 0000000..01f6597 --- /dev/null +++ b/prisma/migrations/20260813000000_render_pipeline_phase3_4_5/migration.sql @@ -0,0 +1,110 @@ +-- RenderPipeline2 Phase 3 (validation), Phase 4 QC half, Phase 5 (delivery). +-- Purely additive: no existing table is altered, no backfill needed. + +-- CreateTable +CREATE TABLE "validation_results" ( + "id" TEXT NOT NULL, + "exportId" TEXT NOT NULL, + "renderJobId" TEXT NOT NULL, + "checkName" TEXT NOT NULL, + "status" "ValidationStatus" NOT NULL, + "expected" TEXT, + "actual" TEXT, + "message" TEXT, + "details" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "validation_results_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "qc_reviews" ( + "id" TEXT NOT NULL, + "exportId" TEXT NOT NULL, + "result" "QCResult" NOT NULL, + "notes" TEXT, + "reviewedById" TEXT, + "reviewerName" TEXT, + "source" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "qc_reviews_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "delivery_packages" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "episode" TEXT NOT NULL, + "name" TEXT NOT NULL, + "packageNumber" INTEGER NOT NULL, + "status" "DeliveryStatus" NOT NULL DEFAULT 'DRAFT', + "rootPath" TEXT, + "configSnapshot" JSONB NOT NULL, + "manifest" JSONB, + "reportKey" TEXT, + "errorMessage" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "builtAt" TIMESTAMP(3), + "deliveredAt" TIMESTAMP(3), + "deliveredById" TEXT, + + CONSTRAINT "delivery_packages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "delivery_items" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "exportId" TEXT NOT NULL, + "shotId" TEXT NOT NULL, + "shotCode" TEXT NOT NULL, + "versionString" TEXT NOT NULL, + "exrPath" TEXT, + "movPath" TEXT, + "fileCount" INTEGER, + "totalBytes" BIGINT, + "checksum" TEXT, + "linkMode" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "errorMessage" TEXT, + + CONSTRAINT "delivery_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "validation_results_exportId_renderJobId_idx" ON "validation_results"("exportId", "renderJobId"); + +-- CreateIndex +CREATE INDEX "qc_reviews_exportId_idx" ON "qc_reviews"("exportId"); + +-- CreateIndex +CREATE INDEX "delivery_packages_projectId_episode_idx" ON "delivery_packages"("projectId", "episode"); + +-- CreateIndex +CREATE INDEX "delivery_packages_status_idx" ON "delivery_packages"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "delivery_packages_projectId_packageNumber_key" ON "delivery_packages"("projectId", "packageNumber"); + +-- CreateIndex +CREATE INDEX "delivery_items_exportId_idx" ON "delivery_items"("exportId"); + +-- CreateIndex +CREATE UNIQUE INDEX "delivery_items_packageId_exportId_key" ON "delivery_items"("packageId", "exportId"); + +-- AddForeignKey +ALTER TABLE "validation_results" ADD CONSTRAINT "validation_results_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "qc_reviews" ADD CONSTRAINT "qc_reviews_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delivery_packages" ADD CONSTRAINT "delivery_packages_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delivery_items" ADD CONSTRAINT "delivery_items_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "delivery_packages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delivery_items" ADD CONSTRAINT "delivery_items_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "exports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2fe96c2..1e3f457 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -215,40 +215,40 @@ enum AttachmentCategory { // ───────────────────────────────────────────── model User { - id String @id @default(cuid()) - name String? - email String @unique - emailVerified DateTime? - image String? + id String @id @default(cuid()) + name String? + email String @unique + emailVerified DateTime? + image String? passwordHash String? role Role @default(ARTIST) isActive Boolean @default(true) mustChangePassword Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // NextAuth relations accounts Account[] sessions Session[] // App relations - producedProjects Project[] @relation("ProducerProjects") - supervisedProjects Project[] @relation("SupervisorProjects") - assignedShots Shot[] @relation("ArtistShots") - uploadedVersions Version[] - comments Comment[] - commentReplies CommentReply[] - annotations Annotation[] - approvals Approval[] - notifications Notification[] - clientAccess ClientAccess[] - assignedTasks Task[] @relation("TaskArtist") - createdTasks Task[] @relation("TaskCreator") - sharedVersions Version[] @relation("VersionSharedBy") - ledAssets Asset[] @relation("AssetLead") - createdShootDays ShootDay[] @relation("ShootDayCreator") - createdTakes Take[] @relation("TakeCreator") - uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader") + producedProjects Project[] @relation("ProducerProjects") + supervisedProjects Project[] @relation("SupervisorProjects") + assignedShots Shot[] @relation("ArtistShots") + uploadedVersions Version[] + comments Comment[] + commentReplies CommentReply[] + annotations Annotation[] + approvals Approval[] + notifications Notification[] + clientAccess ClientAccess[] + assignedTasks Task[] @relation("TaskArtist") + createdTasks Task[] @relation("TaskCreator") + sharedVersions Version[] @relation("VersionSharedBy") + ledAssets Asset[] @relation("AssetLead") + createdShootDays ShootDay[] @relation("ShootDayCreator") + createdTakes Take[] @relation("TakeCreator") + uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader") @@map("users") } @@ -360,7 +360,8 @@ model Project { /// Per-production delivery naming/layout templates (RenderPipeline2 §11.2); /// falls back to SystemConfig defaults when null - deliveryConfig Json? + deliveryConfig Json? + deliveryPackages DeliveryPackage[] @@map("projects") } @@ -380,60 +381,60 @@ model EpisodeDueDate { } model Shot { - id String @id @default(cuid()) - shotCode String - scene String @default("") - episode String? - shotNumber Int @default(0) - sequence String? - description String? @db.Text - notes String? @db.Text - status ShotStatus @default(WAITING) - priority ShotPriority @default(NORMAL) - artistId String? - projectId String - frameStart Int? - frameEnd Int? - fps Float @default(24) - dueDate DateTime? - thumbnailUrl String? - originalFootageUrl String? - originalFootageKey String? - shotGroupId String? - shotApprovalStatus ShotApprovalStatus @default(PENDING) - sharedWithClient Boolean @default(false) + id String @id @default(cuid()) + shotCode String + scene String @default("") + episode String? + shotNumber Int @default(0) + sequence String? + description String? @db.Text + notes String? @db.Text + status ShotStatus @default(WAITING) + priority ShotPriority @default(NORMAL) + artistId String? + projectId String + frameStart Int? + frameEnd Int? + fps Float @default(24) + dueDate DateTime? + thumbnailUrl String? + originalFootageUrl String? + originalFootageKey String? + shotGroupId String? + shotApprovalStatus ShotApprovalStatus @default(PENDING) + sharedWithClient Boolean @default(false) // EDL / pull CSV fields - sourceClip String? - timecodeStart String? - timecodeEnd String? - clipDuration String? - exrOutput String? + sourceClip String? + timecodeStart String? + timecodeEnd String? + clipDuration String? + exrOutput String? // Sequence / picture lock timecodes - seqTimecodeStart String? - seqTimecodeEnd String? + seqTimecodeStart String? + seqTimecodeEnd String? // High-res deliverable (stored in Hetzner Object Storage) - highResKey String? - highResFilename String? + highResKey String? + highResFilename String? // Shot-level version tracking (e.g. v001, v002) - shotVersion String @default("v001") + shotVersion String @default("v001") // Key shot flag — highlighted for prioritisation - isKeyShot Boolean @default(false) + isKeyShot Boolean @default(false) // Netflix slate fields — managed via the Slate tab, used by the preview pipeline - slateType String? - slateDescription String? @db.Text - slateVfxScope String? @db.Text - slateSubmissionNote String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + slateType String? + slateDescription String? @db.Text + slateVfxScope String? @db.Text + slateSubmissionNote String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - artist User? @relation("ArtistShots", fields: [artistId], references: [id]) - shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id]) - versions Version[] - tasks Task[] + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + artist User? @relation("ArtistShots", fields: [artistId], references: [id]) + shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id]) + versions Version[] + tasks Task[] footagePlates FootagePlate[] references ShotReference[] - loggedTakes Take[] @relation("TakeToShot") + loggedTakes Take[] @relation("TakeToShot") exports Export[] @@unique([projectId, shotCode]) @@ -565,7 +566,7 @@ model Annotation { commentId String? authorId String? frameNumber Int - drawingData Json // Array of AnnotationShape objects + drawingData Json // Array of AnnotationShape objects color String @default("#ef4444") isVisible Boolean @default(true) createdAt DateTime @default(now()) @@ -597,7 +598,7 @@ model Notification { type NotificationType title String message String @db.Text - data Json? // Extra context (versionId, shotCode, etc.) + data Json? // Extra context (versionId, shotCode, etc.) isRead Boolean @default(false) createdAt DateTime @default(now()) @@ -647,13 +648,13 @@ model Task { scheduledStartDate DateTime? scheduledEndDate DateTime? - scheduleNotes String? @db.Text + scheduleNotes String? @db.Text - shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade) - asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) - assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id]) - createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade) + asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade) + assignedArtist User? @relation("TaskArtist", fields: [assignedArtistId], references: [id]) + createdBy User? @relation("TaskCreator", fields: [createdById], references: [id], onDelete: SetNull) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) versions Version[] @@index([scheduledStartDate]) @@ -692,9 +693,9 @@ model SystemConfig { /// Physical display event log — polled by ESP32 dot-matrix display model DisplayEvent { id Int @id @default(autoincrement()) - type String // APPROVED | CHANGES | COMMENT | REJECTED + type String // APPROVED | CHANGES | COMMENT | REJECTED shotCode String - by String // actor name (reviewer / commenter) + by String // actor name (reviewer / commenter) createdAt DateTime @default(now()) @@index([createdAt]) @@ -716,8 +717,8 @@ model ShootDay { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + createdBy User? @relation("ShootDayCreator", fields: [createdById], references: [id], onDelete: SetNull) setups Setup[] @@map("shoot_days") @@ -747,9 +748,9 @@ model Take { pipelineShotId String? // General - scene String? - shotLabel String? - unitLabel String? + scene String? + shotLabel String? + unitLabel String? // Camera cameraLetter String? @@ -880,8 +881,11 @@ model Export { submittedByName String? // free text from panel config as fallback supersededById String? // newer Export that replaced this one - renderJobs RenderJob[] - events ExportEvent[] + renderJobs RenderJob[] + events ExportEvent[] + validations ValidationResult[] + qcReviews QCReview[] + deliveryItems DeliveryItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -984,6 +988,96 @@ model WorkerHeartbeat { @@map("worker_heartbeats") } +/// One row per check per validation run, so the UI shows exactly what failed +/// and history survives retries (§5.4). +model ValidationResult { + id String @id @default(cuid()) + exportId String + export Export @relation(fields: [exportId], references: [id]) + renderJobId String // which attempt produced this run + checkName String // "timecode_plate_match", "missing_frames", ... + status ValidationStatus + expected String? + actual String? + message String? + details Json? // e.g. list of missing frame numbers, or the timecode reference used + createdAt DateTime @default(now()) + + @@index([exportId, renderJobId]) + @@map("validation_results") +} + +/// Human technical QC on an Export (§5.5). Multiple rows allowed — the latest +/// is authoritative and drives T13/T14. +model QCReview { + id String @id @default(cuid()) + exportId String + export Export @relation(fields: [exportId], references: [id]) + result QCResult + notes String? @db.Text + reviewedById String? // resolved User where possible + reviewerName String? // fallback for API-key reviews from the panel + source String // "AE_PANEL" | "WEB" + createdAt DateTime @default(now()) + + @@index([exportId]) + @@map("qc_reviews") +} + +/// A dated delivery folder built from READY_FOR_DELIVERY exports (§5.7). +/// Never deleted — a mistaken package is CANCELLED or superseded. +model DeliveryPackage { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id]) + episode String // "101" — empty string on standard (non-episodic) shows + name String // "260805_Delivery" + packageNumber Int // sequential per project → "Package #15" + status DeliveryStatus @default(DRAFT) + rootPath String? // final path on the delivery volume + configSnapshot Json // naming template + options at build time (immutable record) + manifest Json? // built manifest: files, sizes, checksums + reportKey String? // delivery report in object storage; also written into the folder + errorMessage String? + createdById String + createdAt DateTime @default(now()) + builtAt DateTime? + deliveredAt DateTime? + deliveredById String? + + items DeliveryItem[] + + @@unique([projectId, packageNumber]) + @@index([projectId, episode]) + @@index([status]) + @@map("delivery_packages") +} + +/// Version lock: the exact Export delivered. shotCode/versionString are +/// denormalised so a later shot rename or re-render never mutates history. +model DeliveryItem { + id String @id @default(cuid()) + packageId String + package DeliveryPackage @relation(fields: [packageId], references: [id], onDelete: Cascade) + exportId String + export Export @relation(fields: [exportId], references: [id]) + shotId String + shotCode String + versionString String + exrPath String? // path inside the package + movPath String? + fileCount Int? + totalBytes BigInt? + checksum String? + linkMode String? // "hardlink" | "copy" — what the builder actually did + status String @default("PENDING") // PENDING | COPIED | VERIFIED | FAILED + errorMessage String? + + @@unique([packageId, exportId]) + @@index([exportId]) + @@map("delivery_items") +} + model SketchTemplate { id String @id @default(cuid()) name String