diff --git a/DELIVERY_WORKFLOW.md b/DELIVERY_WORKFLOW.md index e6e675d..8f180df 100644 --- a/DELIVERY_WORKFLOW.md +++ b/DELIVERY_WORKFLOW.md @@ -2,6 +2,11 @@ How a finished shot becomes a dated folder ready to upload to the client. +There are two kinds of package. An **EXR delivery** is the shippable one and is +what most of this guide describes. A **MOV review** is a flat folder of preview +MOVs shared for a formal review — see +[MOV review packages](#mov-review-packages) at the end. + This is the operational guide. The design rationale and normative rules live in [RenderPipeline2.md](RenderPipeline2.md) §11. @@ -236,3 +241,70 @@ a different spec is a config change rather than a code change. Omit `exportIds` to take every ready export in the episode; `options` accepts `date`, `name`, `includeMovs` and `copyMode` to override the config for one build. + +--- + +## MOV review packages + +For a formal review you share the preview MOVs, not the EXRs. That is its own +kind of package, built the same way and listed on the same page. + +### Building one + +**Shot Status**, tick the shots, then **Bulk Actions → Build MOV Review +Package**. A dialog shows exactly what would be built before anything happens: +the folder name, the version each shot contributes, and any shots being left +out. Requires **admin, producer or supervisor**. + +The version each shot contributes is its **newest export that actually has a +preview MOV** — not necessarily its newest export. A shot whose latest version +is still rendering goes into the review as the version that can be watched. +A shot with no MOV at all is listed as left out, with the reason, rather than +quietly reducing the folder. + +### What lands on disk + +``` +{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/ +├── EP6_VFX_012_v004.mov +├── EP6_VFX_013_v002.mov +└── UNG_106_010_040_cmp_TT_v001.mov +``` + +Flat — the MOVs sit beside each other so they can be scrubbed through in order. +Names follow the same client-naming rule as a delivery: the production's own +code where it has one, ours where it does not, so a shot reads identically in a +review folder and in the delivery that follows it. + +The folder is **exactly the MOVs**: no CSV, no `manifest.json` beside them. The +manifest still goes back to the app, so the record of what was shared is kept +without cluttering the share. + +Second review the same day is `20260828_Producer_review_02`, then `_03`. A +number is never reused, even if the package that took it was cancelled. + +### What it does not do + +**A review never moves a shot along.** Reviews happen throughout a shot's life, +long before QC passes it, and the same version is routinely shown twice — so +building one changes nothing about the exports in it, and there is no +**Mark delivered** on a review package. Shots stay exactly where they are in +the pipeline. + +A **missing MOV fails the package** here, unlike in an EXR delivery where a +missing MOV is logged and the build continues. In a review the MOV is the +deliverable, and a review folder quietly one shot short is worse than one that +says so. + +### Configuration + +Two more keys in the same `deliveryConfig` (§11.6): + +```json +{ + "reviewNameTemplate": "{yyyymmdd}_Producer_review", + "layout": { + "reviewDir": "{deliveryRoot}/PRODUCER_REVIEWS/{packageName}" + } +} +``` diff --git a/RenderPipeline2.md b/RenderPipeline2.md index 8a2fd11..da7b2c8 100644 --- a/RenderPipeline2.md +++ b/RenderPipeline2.md @@ -448,6 +448,7 @@ model DeliveryPackage { projectId String episode String // "101" name String // "260805_Delivery" (per 18.1-Q6 convention) + kind DeliveryKind @default(EXR_DELIVERY) // EXR_DELIVERY | MOV_REVIEW (§11.6) packageNumber Int // sequential per project → "Package #15" status DeliveryStatus @default(DRAFT) rootPath String? // final path on delivery volume @@ -464,6 +465,7 @@ model DeliveryPackage { @@unique([projectId, packageNumber]) @@index([projectId, episode]) + @@index([projectId, kind]) } model DeliveryItem { @@ -488,6 +490,8 @@ model DeliveryItem { **Version locking:** the package references `Export` rows, and items denormalise `shotCode`/`versionString` at creation. Later re-renders of a shot never mutate a package — delivery history is immutable by construction. +**`kind`** distinguishes a shippable EXR delivery from a MOV review folder (§11.6). Both use these two models and the same `DELIVERY_BUILD` job; only a review leaves `exrPath` null, and only a delivery advances its exports to PACKAGED/DELIVERED. + ### 5.8 Changes to existing models (additive only) | Model | Addition | Purpose | @@ -1248,6 +1252,27 @@ Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shot The Delivery Packages page (Section 13) lists every package permanently: `#15 · 101/260805_Delivery · Episode 101 · 18 shots · built 2026-08-05 · delivered 2026-08-05 by Chris`, expandable to items → Export → full render/validation/QC history. Packages are never deleted; a mistaken package is CANCELLED (DRAFT/QUEUED/FAILED only) or superseded by building a new one. +### 11.6 MOV review packages (`DeliveryPackage.kind = MOV_REVIEW`) + +A formal review is shared as a flat, dated folder of the preview MOVs — the same package/job/verification machinery as §11.1–11.5, a different shape on disk: + +``` +{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/ +├── EP6_VFX_012_v004.mov ← client naming where the production supplies it +├── EP6_VFX_013_v002.mov +└── UNG_106_010_040_cmp_TT_v001.mov ← ours where it does not +``` + +The second review that day is `_02`, the third `_03`; the first carries no suffix. Numbering counts every package built under that base — cancelled and failed ones included — so a number is never reused for a different folder. Config: `reviewNameTemplate` (default `{yyyymmdd}_Producer_review`) and `layout.reviewDir` (default `{deliveryRoot}/PRODUCER_REVIEWS/{packageName}`). + +Three rules separate a review from a delivery: + +- **It never advances an Export.** Reviews happen throughout a shot's life, long before QC passes it, and the same version is routinely shown twice. Nothing is marked PACKAGED or DELIVERED when the folder is built, and `Mark delivered` is refused on a review package. +- **Selection is by shot, not by export.** The user picks shots on Shot Status → *Bulk Actions → Build MOV Review Package*; the server resolves each to its **newest export that actually has a `deliveryMovPath`** — not necessarily its newest export, since a shot whose latest version is still rendering should go into the review as the version that can be watched. Shots with no MOV at all are listed in the confirm dialog and left out rather than silently dropped. +- **The folder is exactly the MOVs.** No CSV, no `manifest.json` written beside them. The manifest still returns to the server, so the record of what was shared is kept without cluttering the share. + +Verification is proportional as in §11.3, adapted to a single file with no validation-time digest: a hard link is the source and needs nothing; a copy is checked against its source by length always, and by full hash under `"verifyMode": "always-hash"`. A missing MOV **fails the item** here — in an EXR delivery the MOV is a viewing copy beside the real deliverable, but in a review it *is* the deliverable, and a review folder quietly one shot short is worse than one that says so. + --- ## 12. AE Panel Changes diff --git a/RenderWorker/VFXReviewWorker.Tests/MovReviewStageTests.cs b/RenderWorker/VFXReviewWorker.Tests/MovReviewStageTests.cs new file mode 100644 index 0000000..3578713 --- /dev/null +++ b/RenderWorker/VFXReviewWorker.Tests/MovReviewStageTests.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging.Abstractions; +using VFXReviewWorker; +using Xunit; + +namespace VFXReviewWorker.Tests; + +/// +/// MOV review packages (RenderPipeline2 §11.6). A review folder is opened +/// directly by a producer, so the things pinned here are the ones they would +/// notice: it is flat, it carries the client's own naming, and it contains the +/// MOVs and nothing else. +/// +public class MovReviewStageTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_review_{Guid.NewGuid():N}"); + private readonly string _renderDir; + private readonly string _reviewDir; + + public MovReviewStageTests() + { + _renderDir = Path.Combine(_root, "renders", "SHOT_001", "v004"); + _reviewDir = Path.Combine(_root, "DELIVERY", "PRODUCER_REVIEWS", "20260828_Producer_review"); + Directory.CreateDirectory(_renderDir); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private string WriteMov(string shotCode, string version, string contents = "mov bytes") + { + var path = Path.Combine(_renderDir, $"{shotCode}_cmp_TT_{version}.mov"); + File.WriteAllText(path, contents); + return path; + } + + private DeliveryManifest Manifest(params DeliveryManifestItem[] items) => new() + { + Kind = "MOV_REVIEW", + PackageId = "pkg1", + PackageName = "20260828_Producer_review", + PackageNumber = 7, + Episode = "101", + PackageDir = _reviewDir, + // A review writes no report; the server keeps the record instead. + ReportFile = "", + Items = items.ToList(), + }; + + private DeliveryManifestItem Item( + string shotCode, string version, string? movPath, string? deliveredBase = null) => new() + { + ItemId = $"item_{shotCode}", + ExportId = $"exp_{shotCode}", + ShotCode = shotCode, + VersionString = version, + SourceDir = "", + OutputPattern = "", + FrameStart = 1001, + FrameEnd = 1050, + DeliveryMovPath = movPath, + // Flat: every item's folder is the package folder itself. + ShotDir = _reviewDir, + DeliveredBaseName = deliveredBase ?? $"{shotCode}_cmp_TT_{version}", + }; + + 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 BuildsAFlatFolderOfMovsUnderTheClientFacingName() + { + var mov = WriteMov("SHOT_001", "v004"); + var manifest = Manifest(Item("SHOT_001", "v004", mov, deliveredBase: "EP6_VFX_012_v004")); + + 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(1, item.FileCount); + // No shot subfolder: the MOVs sit beside each other to be scrubbed through. + Assert.Null(item.ExrPath); + Assert.Equal(Path.Combine(_reviewDir, "EP6_VFX_012_v004.mov"), item.MovPath); + Assert.Single(Directory.GetDirectories(_reviewDir).Concat(Directory.GetFiles(_reviewDir))); + } + + [Fact] + public async Task LeavesNoReportOrManifestBesideTheMovs() + { + var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004"))); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.True(result.Ok); + Assert.Null(result.ReportPath); + // What a producer opens is exactly the MOVs — no manifest.json, no CSV. + Assert.False(File.Exists(Path.Combine(_reviewDir, "manifest.json"))); + Assert.All(Directory.GetFiles(_reviewDir), f => Assert.EndsWith(".mov", f)); + } + + [Fact] + public async Task HardLinksSoASeventyShotReviewCostsNoDisk() + { + var mov = WriteMov("SHOT_001", "v004"); + var manifest = Manifest(Item("SHOT_001", "v004", mov)); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.Equal("hardlink", result.Items[0].LinkMode); + File.WriteAllText(mov, "rewritten"); + Assert.Equal("rewritten", File.ReadAllText(result.Items[0].MovPath!)); + } + + [Fact] + public async Task AlwaysCopyModeProducesAnIndependentFile() + { + var mov = WriteMov("SHOT_001", "v004"); + var manifest = Manifest(Item("SHOT_001", "v004", mov)); + manifest.CopyMode = "always-copy"; + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal("copy", result.Items[0].LinkMode); + File.WriteAllText(mov, "rewritten"); + Assert.NotEqual("rewritten", File.ReadAllText(result.Items[0].MovPath!)); + } + + [Fact] + public async Task AMissingMovFailsTheReviewRatherThanShippingItShort() + { + // In an EXR delivery a missing MOV is a missing viewing copy and the + // package continues. In a review the MOV *is* the deliverable. + var manifest = Manifest(Item("SHOT_001", "v004", Path.Combine(_renderDir, "never_rendered.mov"))); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal("FAILED", result.Items[0].Status); + Assert.Contains("Preview MOV not found", result.Items[0].ErrorMessage!); + } + + [Fact] + public async Task AnItemWithNoRecordedMovFails() + { + var manifest = Manifest(Item("SHOT_001", "v004", null)); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Contains("No preview MOV", result.Items[0].ErrorMessage!); + } + + [Fact] + public async Task HashesEveryFileWhenTheConfigDemandsIt() + { + var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004"))); + manifest.VerifyMode = "always-hash"; + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.True(result.Ok); + // A MOV has no validation-time digest of its own, so the one recorded + // here is the one this build computed. + Assert.False(string.IsNullOrWhiteSpace(result.Items[0].Checksum)); + } + + [Fact] + public async Task OneBadShotFailsThePackageAndTheRestStillReport() + { + var manifest = Manifest( + Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")), + Item("SHOT_002", "v002", Path.Combine(_renderDir, "absent.mov"))); + + var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1", + CancellationToken.None, CancellationToken.None); + + Assert.False(result.Ok); + Assert.Equal("VERIFIED", result.Items[0].Status); + Assert.Equal("FAILED", result.Items[1].Status); + } +} diff --git a/RenderWorker/VFXReviewWorker/DeliveryStage.cs b/RenderWorker/VFXReviewWorker/DeliveryStage.cs index 4824a9d..b8169ae 100644 --- a/RenderWorker/VFXReviewWorker/DeliveryStage.cs +++ b/RenderWorker/VFXReviewWorker/DeliveryStage.cs @@ -11,6 +11,10 @@ namespace VFXReviewWorker; /// 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. /// +/// A MOV_REVIEW package (§11.6) runs the same path for a different shape: one +/// flat folder, one preview MOV per shot, and nothing written into it but the +/// MOVs themselves — it is shared with a producer, not filed as a delivery. +/// /// 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 @@ -38,9 +42,10 @@ public sealed class DeliveryStage var manifestFiles = new List(); var alwaysCopy = string.Equals(manifest.CopyMode, "always-copy", StringComparison.OrdinalIgnoreCase); var alwaysHash = string.Equals(manifest.VerifyMode, "always-hash", StringComparison.OrdinalIgnoreCase); + var isReview = string.Equals(manifest.Kind, "MOV_REVIEW", StringComparison.OrdinalIgnoreCase); - _log.LogInformation("Building delivery package {Name} ({Count} shots) at {Dir}", - manifest.PackageName, manifest.Items.Count, packageDir); + _log.LogInformation("Building {What} {Name} ({Count} shots) at {Dir}", + isReview ? "MOV review" : "delivery package", manifest.PackageName, manifest.Items.Count, packageDir); Directory.CreateDirectory(packageDir); for (var i = 0; i < manifest.Items.Count; i++) @@ -51,7 +56,9 @@ public sealed class DeliveryStage 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, alwaysHash, manifestFiles); + var result = isReview + ? BuildReviewItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles) + : BuildItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles); results.Add(result); if (result.Status == "FAILED") { @@ -63,15 +70,22 @@ public sealed class DeliveryStage // 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. + // + // A review folder gets neither: it is opened by a producer who wants the + // MOVs and nothing else. The manifest still travels back to the server, + // so the record of what was shared is kept without cluttering the share. string? reportPath = null; - try + if (!isReview) { - 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); + 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); @@ -234,6 +248,108 @@ public sealed class DeliveryStage } } + /// + /// One shot of a MOV review package: place its preview MOV straight into + /// the (flat) review folder under the client-facing name. + /// + /// A missing MOV is fatal here, unlike in an EXR delivery where the MOV is + /// a viewing copy beside the real deliverable — in a review it *is* the + /// deliverable, and a review folder quietly one shot short is worse than + /// one that says so. + /// + private DeliveryItemResult BuildReviewItem( + DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, bool alwaysHash, List manifestFiles) + { + try + { + if (string.IsNullOrWhiteSpace(item.DeliveryMovPath)) + { + return DeliveryItemResult.Failed(item, "No preview MOV is recorded for this version"); + } + + var movSrc = mapper.Map(item.DeliveryMovPath!); + if (!File.Exists(movSrc)) + { + return DeliveryItemResult.Failed(item, $"Preview MOV not found: {movSrc}"); + } + + // ShotDir is the package folder itself for a review — the MOVs sit + // beside each other so a producer can scrub through them in order. + var reviewDir = mapper.Map(item.ShotDir); + Directory.CreateDirectory(reviewDir); + + var name = string.IsNullOrWhiteSpace(item.DeliveredBaseName) + ? Path.GetFileName(movSrc) + : item.DeliveredBaseName + Path.GetExtension(movSrc); + var dest = Path.Combine(reviewDir, name); + + if (Place(movSrc, dest, alwaysCopy, out var copied) is string error) + { + return DeliveryItemResult.Failed(item, error); + } + + var sourceLength = new FileInfo(movSrc).Length; + var destLength = new FileInfo(dest).Length; + + // There is no validation-time digest for a MOV to check against, so + // a copy is proved against the source it came from: length always + // (it catches the truncation a full disk causes, for free), and the + // full hash when the config asks for it. A hard link is the source. + string? digest = null; + if (copied) + { + if (destLength != sourceLength) + { + return DeliveryItemResult.Failed(item, + $"Copied MOV is {destLength} bytes but the source is {sourceLength} — the copy is incomplete"); + } + if (alwaysHash) + { + digest = XxHash64.HashFile(dest); + var sourceDigest = XxHash64.HashFile(movSrc); + if (!string.Equals(digest, sourceDigest, StringComparison.OrdinalIgnoreCase)) + { + return DeliveryItemResult.Failed(item, + $"Copied MOV does not match its source: expected {sourceDigest}, got {digest}"); + } + } + } + else if (alwaysHash) + { + digest = XxHash64.HashFile(dest); + } + + manifestFiles.Add(new + { + shotCode = item.ShotCode, + relativePath = name, + bytes = destLength, + exportId = item.ExportId, + }); + + return new DeliveryItemResult + { + ItemId = item.ItemId, + ShotCode = item.ShotCode, + VersionString = item.VersionString, + Status = "VERIFIED", + // No EXR sequence is involved, so there is no shot folder to record. + ExrPath = null, + MovPath = dest, + FileCount = 1, + TotalBytes = destLength, + Checksum = digest, + LinkMode = copied ? "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 diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs index ec24c93..79c077a 100644 --- a/RenderWorker/VFXReviewWorker/Dtos.cs +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -100,6 +100,12 @@ public sealed class BlenderManifest public sealed class DeliveryManifest { public string Stage { get; set; } = "DELIVERY"; + /// + /// "EXR_DELIVERY" builds the per-shot folders of EXRs + MOVs; "MOV_REVIEW" + /// builds a flat folder of preview MOVs for a formal review (§11.6). One + /// job type and one claim path either way — only what lands on disk differs. + /// + public string Kind { get; set; } = "EXR_DELIVERY"; public string PackageId { get; set; } = ""; public string PackageName { get; set; } = ""; public int PackageNumber { get; set; } @@ -138,6 +144,13 @@ public sealed class DeliveryManifestItem public string DeliveredBaseName { get; set; } = ""; /// When this export's validation ran — lets a linked package detect a source changed since QC. public DateTimeOffset? ValidatedAt { get; set; } + + /// + /// True on a MOV_REVIEW item: there is no EXR sequence, so SourceDir, + /// OutputPattern, FileCount and Checksum are empty and DeliveryMovPath is + /// the whole deliverable. + /// + public bool IsMovOnly => string.IsNullOrWhiteSpace(SourceDir); } /// PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9). diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj index 2d54cb5..d9fb8b0 100644 --- a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj +++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj @@ -15,7 +15,7 @@ every change that gets published. publish.ps1 appends the git SHA via SourceRevisionId, giving "1.1.0+feb1c7b". --> - 1.5.2 + 1.6.0 diff --git a/actions/deliveries.ts b/actions/deliveries.ts new file mode 100644 index 0000000..4d5f838 --- /dev/null +++ b/actions/deliveries.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth"; +import { PipelineError } from "@/lib/render-pipeline/errors"; +import { + createMovReviewPackage, + previewMovReviewPackage, +} from "@/lib/render-pipeline/reviews"; + +/** + * Server actions behind "Build MOV Review Package" on the shot-status page + * (RenderPipeline2 §11.6). The heavy lifting lives in lib/render-pipeline; + * these only carry the session and turn service errors into messages a toast + * can show. + */ + +function message(err: unknown): string { + if (err instanceof PipelineError) return err.message; + return err instanceof Error ? err.message : "Unknown error"; +} + +/** What the confirm dialog shows: the folder, the resolved versions, the gaps. */ +export async function previewMovReview(shotIds: string[]) { + try { + await requirePipelineAdmin(); + const preview = await previewMovReviewPackage(shotIds); + return { ok: true as const, ...preview }; + } catch (err) { + return { ok: false as const, error: message(err) }; + } +} + +export async function buildMovReview(shotIds: string[]) { + try { + const user = await requirePipelineAdmin(); + const result = await createMovReviewPackage({ shotIds }, user.id); + revalidatePath("/pipeline/deliveries"); + return { ok: true as const, ...result }; + } catch (err) { + return { ok: false as const, error: message(err) }; + } +} diff --git a/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx index f3bd066..11a97fb 100644 --- a/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx +++ b/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx @@ -29,6 +29,7 @@ interface PackageRow { id: string; packageNumber: number; name: string; + kind: "EXR_DELIVERY" | "MOV_REVIEW"; episode: string; status: string; rootPath: string | null; @@ -242,6 +243,11 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) { #{p.packageNumber} {p.name} + {p.kind === "MOV_REVIEW" && ( + + MOV review + + )} {new Date(p.createdAt).toLocaleDateString()} {p.status === "BUILDING" && } - {canBuild && p.status === "READY" && ( + {canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && ( + + + + + ); +} + // ── NotesCell ───────────────────────────────────────────────────────────────── function NotesCell({ @@ -575,6 +724,7 @@ export function ShotStatusClient({ }, [selectedProjectId]); const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false); const toggleEpisode = (ep: string) => setExpandedEpisodes((prev) => { @@ -820,6 +970,10 @@ export function ShotStatusClient({ Change Due Date + setMovReviewDialogOpen(true)}> + + Build MOV Review Package + setDeleteDialogOpen(true)} @@ -1032,6 +1186,17 @@ export function ShotStatusClient({ onConfirm={handleBulkDelete} count={selectionCount} /> + + {/* MOV review package (§11.6) */} + setMovReviewDialogOpen(false)} + onBuilt={() => { + setMovReviewDialogOpen(false); + clearSelection(); + }} + shotIds={[...selectedIds]} + /> ); } diff --git a/app/api/deliveries/route.ts b/app/api/deliveries/route.ts index ee2b987..7abe439 100644 --- a/app/api/deliveries/route.ts +++ b/app/api/deliveries/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { DeliveryStatus } from "@prisma/client"; +import { DeliveryKind, DeliveryStatus } from "@prisma/client"; import { toErrorResponse } from "@/lib/render-pipeline/errors"; import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth"; import { @@ -15,10 +15,12 @@ export async function GET(req: NextRequest) { await requirePipelineUser(); const { searchParams } = new URL(req.url); const status = searchParams.get("status"); + const kind = searchParams.get("kind"); const result = await listDeliveryPackages({ projectId: searchParams.get("projectId") ?? undefined, episode: searchParams.get("episode") ?? undefined, status: status ? (status as DeliveryStatus) : undefined, + kind: kind ? (kind as DeliveryKind) : undefined, }); return NextResponse.json(result); } catch (err) { diff --git a/lib/render-pipeline/deliveries.ts b/lib/render-pipeline/deliveries.ts index 6d9ffaf..b19a70a 100644 --- a/lib/render-pipeline/deliveries.ts +++ b/lib/render-pipeline/deliveries.ts @@ -1,4 +1,4 @@ -import { DeliveryStatus, Prisma } from "@prisma/client"; +import { DeliveryKind, DeliveryStatus, Prisma } from "@prisma/client"; import { z } from "zod"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; @@ -20,7 +20,14 @@ import { clientShotCode } from "@/lib/shot-code"; export interface DeliveryConfig { packageNameTemplate: string; - layout: { packageDir: string; shotDir: string; reportFile: string }; + /** + * MOV review packages (§11.6). A formal review is shared as a flat, dated + * folder of preview MOVs, so it gets its own name template and its own + * directory under the delivery root — never mixed in with the episode + * folders that hold shippable EXR packages. + */ + reviewNameTemplate: string; + layout: { packageDir: string; shotDir: string; reportFile: string; reviewDir: string }; deliveryRoot: string; includeMovs: boolean; checksumAlgo: string; @@ -37,10 +44,12 @@ export interface DeliveryConfig { export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = { packageNameTemplate: "{yymmdd}_Delivery", + reviewNameTemplate: "{yyyymmdd}_Producer_review", layout: { packageDir: "{deliveryRoot}/{episode}/{packageName}", shotDir: "{packageDir}/{shotCode}", reportFile: "{packageDir}/{packageName}_report.csv", + reviewDir: "{deliveryRoot}/PRODUCER_REVIEWS/{packageName}", }, deliveryRoot: "//SAN/DELIVERY", includeMovs: true, @@ -356,6 +365,12 @@ export interface DeliveryManifestItem { export interface DeliveryManifest { stage: "DELIVERY"; + /** + * EXR_DELIVERY builds the per-shot folders of EXRs + MOVs; MOV_REVIEW builds + * a flat folder of preview MOVs only. One job type, one claim path, one + * verification story — the worker branches on this. + */ + kind: DeliveryKind; packageId: string; packageName: string; packageNumber: number; @@ -402,6 +417,7 @@ export async function buildDeliveryJobManifest( return { stage: "DELIVERY", + kind: pkg.kind, packageId: pkg.id, packageName: pkg.name, packageNumber: pkg.packageNumber, @@ -522,7 +538,9 @@ export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) { const failed = body.items.filter((i) => i.status === "FAILED"); const success = body.ok && failed.length === 0 && verified.length === pkg.items.length; - if (success) { + // A review is not a delivery: the same version is shown in as many reviews + // as it takes, so building one must never advance the Export state machine. + if (success && pkg.kind === "EXR_DELIVERY") { 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") { @@ -561,12 +579,18 @@ export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) { // ── E17 / E19 / cancel ─────────────────────────────────────────────────────── -export async function listDeliveryPackages(filter: { projectId?: string; episode?: string; status?: DeliveryStatus }) { +export async function listDeliveryPackages(filter: { + projectId?: string; + episode?: string; + status?: DeliveryStatus; + kind?: DeliveryKind; +}) { const packages = await db.deliveryPackage.findMany({ where: { ...(filter.projectId ? { projectId: filter.projectId } : {}), ...(filter.episode ? { episode: filter.episode } : {}), ...(filter.status ? { status: filter.status } : {}), + ...(filter.kind ? { kind: filter.kind } : {}), }, orderBy: { createdAt: "desc" }, include: { @@ -580,6 +604,7 @@ export async function listDeliveryPackages(filter: { projectId?: string; episode id: p.id, packageNumber: p.packageNumber, name: p.name, + kind: p.kind, episode: p.episode, status: p.status, rootPath: p.rootPath, @@ -607,6 +632,12 @@ export async function markDelivered(packageId: string, userId: string) { if (pkg.status !== "READY") { throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`); } + if (pkg.kind !== "EXR_DELIVERY") { + throw new PipelineError( + 409, + "A MOV review package is shared for review, not delivered — it has no delivered state" + ); + } for (const item of pkg.items) { const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } }); diff --git a/lib/render-pipeline/reviews.test.ts b/lib/render-pipeline/reviews.test.ts new file mode 100644 index 0000000..81768bf --- /dev/null +++ b/lib/render-pipeline/reviews.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { nextReviewName, reviewMovName } from "./reviews"; + +/** + * Review folders are named by date, and several reviews a day are normal — + * a morning pass and an afternoon one land in the same studio's inbox, so the + * numbering has to be unambiguous about which came first. + */ +describe("nextReviewName", () => { + const BASE = "20260828_Producer_review"; + + it("leaves the first review of the day unnumbered", () => { + expect(nextReviewName(BASE, [])).toBe(BASE); + }); + + it("numbers from _02 once the plain name is taken", () => { + expect(nextReviewName(BASE, [BASE])).toBe(`${BASE}_02`); + expect(nextReviewName(BASE, [BASE, `${BASE}_02`])).toBe(`${BASE}_03`); + }); + + it("counts from the highest number, not the count", () => { + // A cancelled _02 still holds its number: reusing it would give two + // different folders the same name in the delivery history. + expect(nextReviewName(BASE, [BASE, `${BASE}_02`, `${BASE}_04`])).toBe(`${BASE}_05`); + }); + + it("picks up numbering even when the plain name was never used", () => { + expect(nextReviewName(BASE, [`${BASE}_02`])).toBe(`${BASE}_03`); + }); + + it("ignores names that merely start with the base", () => { + // A hand-named "…_review_final" is somebody's own folder, not part of + // this sequence, and must not push the number along. + expect(nextReviewName(BASE, [`${BASE}_final`, `${BASE}_02x`])).toBe(BASE); + }); + + it("does not confuse another day's reviews", () => { + expect(nextReviewName(BASE, ["20260827_Producer_review_03"])).toBe(BASE); + }); + + it("treats the base as a literal, not a pattern", () => { + const dotted = "2026.08.28_review"; + expect(nextReviewName(dotted, ["2026x08x28_review"])).toBe(dotted); + }); +}); + +describe("reviewMovName", () => { + const SOURCE = "//SAN/renders/UNG_106_010_020/v004/UNG_106_010_020_cmp_TT_v004.mov"; + + it("uses the client's own naming when the production supplies it", () => { + expect(reviewMovName({ shotCode: "UNG_106_010_020", clientCode: "EP6_VFX_012" }, "v004", SOURCE)).toBe( + "EP6_VFX_012_v004.mov" + ); + }); + + it("falls back to the studio convention", () => { + expect(reviewMovName({ shotCode: "UNG_106_010_020", clientCode: null }, "v004", SOURCE)).toBe( + "UNG_106_010_020_cmp_TT_v004.mov" + ); + }); + + it("ignores a whitespace-only client code", () => { + expect(reviewMovName({ shotCode: "UNG_106_010_020", clientCode: " " }, "v004", SOURCE)).toBe( + "UNG_106_010_020_cmp_TT_v004.mov" + ); + }); + + it("keeps whatever extension the source actually has", () => { + expect( + reviewMovName({ shotCode: "UNG_106_010_020", clientCode: null }, "v004", "//SAN/a/b_v004.mxf") + ).toBe("UNG_106_010_020_cmp_TT_v004.mxf"); + }); + + it("does not mistake a dot in a folder name for an extension", () => { + expect( + reviewMovName({ shotCode: "UNG_106_010_020", clientCode: null }, "v004", "//SAN/v1.2/rendered_mov") + ).toBe("UNG_106_010_020_cmp_TT_v004.mov"); + }); +}); diff --git a/lib/render-pipeline/reviews.ts b/lib/render-pipeline/reviews.ts new file mode 100644 index 0000000..d3dd5aa --- /dev/null +++ b/lib/render-pipeline/reviews.ts @@ -0,0 +1,424 @@ +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { PipelineError } from "./errors"; +import { getConfigNumber } from "./config"; +import { + applyTokens, + dateTokens, + resolveDeliveryConfig, + type DeliveryConfig, + type DeliveryManifest, + type TokenValues, +} from "./deliveries"; + +/** + * MOV review packages (RenderPipeline2 §11.6). + * + * A formal review is shared as a flat, dated folder of the preview MOVs — + * `20260828_Producer_review`, `_02` for the second one that day. It is built by + * the same worker, through the same queue and the same claim path, as an EXR + * delivery; only the shape of what lands on disk differs. + * + * The one rule that separates it from a delivery: **a review never advances an + * Export**. Reviews happen throughout a shot's life, long before QC passes it, + * and the same version is routinely shown twice. So there is no eligibility + * gate beyond "this version has a MOV", and nothing is marked PACKAGED or + * DELIVERED when the folder is built. + */ + +// ── Which version of each shot gets reviewed ───────────────────────────────── + +export interface ReviewCandidate { + shotId: string; + shotCode: string; + clientCode: string | null; + episode: string; + exportId: string; + versionString: string; + versionNumber: number; + status: string; + deliveryMovPath: string; + /** What the MOV is named in the review folder. */ + deliveredName: string; + frameStart: number; + frameEnd: number; +} + +export interface ReviewSkip { + shotId: string; + shotCode: string; + reason: string; +} + +/** + * `{clientCode}_{versionString}` when the production supplies its own naming, + * else the studio's `{shotCode}_cmp_TT_{versionString}` — the same rule the EXR + * delivery names its files by, so a shot reads identically in a review folder + * and in the delivery that follows it. + */ +export function reviewMovName( + shot: { shotCode: string; clientCode: string | null }, + versionString: string, + sourcePath: string +): string { + const base = shot.clientCode?.trim() + ? `${shot.clientCode.trim()}_${versionString}` + : `${shot.shotCode}_cmp_TT_${versionString}`; + const dot = sourcePath.lastIndexOf("."); + const slash = Math.max(sourcePath.lastIndexOf("/"), sourcePath.lastIndexOf("\\")); + const ext = dot > slash ? sourcePath.slice(dot) : ".mov"; + return `${base}${ext}`; +} + +/** + * The newest export of each shot that actually has a delivery MOV on the SAN. + * + * Newest-with-a-MOV rather than newest outright: a shot whose latest version is + * still rendering should go into the review as the version that can be watched, + * not be dropped from it. Shots with no MOV at all come back in `skipped` so + * the caller can say which ones and why rather than silently shipping fewer. + */ +export async function resolveReviewCandidates(shotIds: string[]) { + if (shotIds.length === 0) { + throw new PipelineError(422, "No shots selected"); + } + + const shots = await db.shot.findMany({ + where: { id: { in: shotIds } }, + select: { + id: true, + shotCode: true, + clientCode: true, + episode: true, + projectId: true, + project: { select: { id: true, name: true, code: true, showId: true, deliveryConfig: true } }, + }, + }); + if (shots.length === 0) throw new PipelineError(404, "No such shots"); + + const projectIds = [...new Set(shots.map((s) => s.projectId))]; + if (projectIds.length > 1) { + // Delivery roots and naming templates are per project; one folder cannot + // honour two of them. + throw new PipelineError(422, "Selected shots span more than one project — review one project at a time"); + } + + const exports = await db.export.findMany({ + where: { + shotId: { in: shots.map((s) => s.id) }, + deliveryMovPath: { not: null }, + status: { notIn: ["CANCELLED"] }, + }, + orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }], + select: { + id: true, + shotId: true, + versionString: true, + versionNumber: true, + status: true, + deliveryMovPath: true, + frameStart: true, + frameEnd: true, + }, + }); + + const newestPerShot = new Map(); + for (const e of exports) { + if (!newestPerShot.has(e.shotId)) newestPerShot.set(e.shotId, e); + } + + const candidates: ReviewCandidate[] = []; + const skipped: ReviewSkip[] = []; + + for (const shot of shots) { + const chosen = newestPerShot.get(shot.id); + if (!chosen?.deliveryMovPath) { + skipped.push({ + shotId: shot.id, + shotCode: shot.shotCode, + reason: "no preview MOV rendered yet", + }); + continue; + } + candidates.push({ + shotId: shot.id, + shotCode: shot.shotCode, + clientCode: shot.clientCode, + episode: shot.episode ?? "", + exportId: chosen.id, + versionString: chosen.versionString, + versionNumber: chosen.versionNumber, + status: chosen.status, + deliveryMovPath: chosen.deliveryMovPath, + deliveredName: reviewMovName(shot, chosen.versionString, chosen.deliveryMovPath), + frameStart: chosen.frameStart, + frameEnd: chosen.frameEnd, + }); + } + + candidates.sort((a, b) => a.shotCode.localeCompare(b.shotCode)); + skipped.sort((a, b) => a.shotCode.localeCompare(b.shotCode)); + + return { project: shots[0].project, candidates, skipped }; +} + +// ── Folder naming ──────────────────────────────────────────────────────────── + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * `20260828_Producer_review`, then `_02`, `_03` for further reviews the same + * day. Numbering counts every package built under that base — cancelled and + * failed ones included — so a number is never reused for a different folder. + */ +export function nextReviewName(base: string, existingNames: string[]): string { + const pattern = new RegExp(`^${escapeRegExp(base)}(?:_(\\d+))?$`); + let highest = 0; + for (const name of existingNames) { + const match = pattern.exec(name); + if (!match) continue; + highest = Math.max(highest, match[1] ? Number.parseInt(match[1], 10) : 1); + } + return highest === 0 ? base : `${base}_${String(highest + 1).padStart(2, "0")}`; +} + +async function reviewNameFor( + tx: Prisma.TransactionClient | typeof db, + projectId: string, + base: string +): Promise { + const existing = await tx.deliveryPackage.findMany({ + where: { projectId, kind: "MOV_REVIEW", name: { startsWith: base } }, + select: { name: true }, + }); + return nextReviewName(base, existing.map((p) => p.name)); +} + +function reviewTokens( + config: DeliveryConfig, + project: { code: string; showId: string | null }, + episode: string, + buildDate: Date, + packageNumber: number +): TokenValues { + return { + showId: project.showId || project.code, + episode, + ...dateTokens(buildDate), + packageName: "", + packageDir: "", + deliveryRoot: config.deliveryRoot, + shotCode: "", + versionString: "", + packageNumber: String(packageNumber), + }; +} + +/** + * The episode a review folder belongs to: the one every selected shot shares, + * or "" when the review spans several. It is a label on the record only — + * unlike an EXR package, the folder path never includes it. + */ +function commonEpisode(candidates: ReviewCandidate[]): string { + const episodes = new Set(candidates.map((c) => c.episode)); + return episodes.size === 1 ? [...episodes][0] : ""; +} + +// ── Preview: what would this build ─────────────────────────────────────────── + +/** + * What the dialog shows before anyone commits: the folder that would be + * created, the version each shot resolves to, and the shots that have no MOV. + * The name is what it would be *right now* — another review built in between + * takes the number and this one moves up. + */ +export async function previewMovReviewPackage(shotIds: string[]) { + const { project, candidates, skipped } = await resolveReviewCandidates(shotIds); + const config = await resolveDeliveryConfig(project.deliveryConfig); + const buildDate = new Date(); + + const tokens = reviewTokens(config, project, commonEpisode(candidates), buildDate, 0); + const base = applyTokens(config.reviewNameTemplate, tokens); + tokens.packageName = await reviewNameFor(db, project.id, base); + tokens.packageDir = applyTokens(config.layout.reviewDir, tokens); + + return { + projectId: project.id, + projectName: project.name, + packageName: tokens.packageName, + packageDir: tokens.packageDir, + candidates, + skipped, + }; +} + +// ── Create ─────────────────────────────────────────────────────────────────── + +export const createMovReviewSchema = z.object({ + shotIds: z.array(z.string().min(1)).min(1).max(500), + options: z + .object({ + /** Override the dated name entirely, e.g. for a re-share under an agreed folder name. */ + name: z.string().max(120).optional(), + copyMode: z.enum(["auto", "always-copy"]).optional(), + }) + .optional(), +}); + +export type CreateMovReviewInput = z.infer; + +/** + * Build a MOV review package from selected shots. Shots without a MOV are + * skipped and reported rather than failing the build — a producer asking for a + * review of 15 shots wants the 13 that exist, plus a clear list of the 2 that + * do not. + */ +export async function createMovReviewPackage(input: CreateMovReviewInput, userId: string) { + const { project, candidates, skipped } = await resolveReviewCandidates(input.shotIds); + if (candidates.length === 0) { + throw new PipelineError(422, "None of the selected shots has a preview MOV yet — nothing to review"); + } + + const config = await resolveDeliveryConfig(project.deliveryConfig); + if (input.options?.copyMode) config.copyMode = input.options.copyMode; + + const buildDate = new Date(); + const maxAttempts = await getConfigNumber("render.maxAttempts"); + const episode = commonEpisode(candidates); + + return db.$transaction(async (tx) => { + const lastNumber = await tx.deliveryPackage.aggregate({ + where: { projectId: project.id }, + _max: { packageNumber: true }, + }); + const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1; + + const tokens = reviewTokens(config, project, episode, buildDate, packageNumber); + const base = applyTokens(config.reviewNameTemplate, tokens); + tokens.packageName = input.options?.name ?? (await reviewNameFor(tx, project.id, base)); + tokens.packageDir = applyTokens(config.layout.reviewDir, tokens); + + const pkg = await tx.deliveryPackage.create({ + data: { + projectId: project.id, + kind: "MOV_REVIEW", + episode, + name: tokens.packageName, + packageNumber, + status: "QUEUED", + rootPath: tokens.packageDir, + configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue, + createdById: userId, + }, + }); + + // Same version lock as a delivery: shotCode, clientCode and versionString + // are copied in, so a later rename or re-render cannot rewrite what this + // review was. + await tx.deliveryItem.createMany({ + data: candidates.map((c) => ({ + packageId: pkg.id, + exportId: c.exportId, + shotId: c.shotId, + shotCode: c.shotCode, + clientCode: c.clientCode, + versionString: c.versionString, + })), + }); + + const manifest = await buildMovReviewJobManifest(tx, pkg.id); + const job = await tx.renderJob.create({ + data: { + type: "DELIVERY_BUILD", + deliveryId: pkg.id, + attempt: 1, + maxAttempts, + // Same reasoning as a delivery build: someone is waiting on the folder + // and no After Effects is involved, so it jumps the render queue. + priority: 10, + manifest: manifest as unknown as Prisma.InputJsonValue, + }, + }); + + return { + package: { + id: pkg.id, + kind: pkg.kind, + name: pkg.name, + packageNumber, + episode, + status: pkg.status, + rootPath: pkg.rootPath, + itemCount: candidates.length, + }, + skipped, + renderJob: { id: job.id }, + }; + }); +} + +// ── The MOV_REVIEW manifest ────────────────────────────────────────────────── + +/** + * The DELIVERY_BUILD manifest for a review: the same envelope the EXR builder + * gets, with `kind: "MOV_REVIEW"` and one MOV per item. Every item's `shotDir` + * is the package folder itself — a review is flat, so the MOVs sit beside each + * other where a producer can scrub through them in order. + */ +export async function buildMovReviewJobManifest( + 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 packageDir = pkg.rootPath ?? snapshot.tokens.packageDir; + + return { + stage: "DELIVERY", + kind: "MOV_REVIEW", + packageId: pkg.id, + packageName: pkg.name, + packageNumber: pkg.packageNumber, + episode: pkg.episode, + packageDir, + // A review folder is exactly the MOVs — no CSV, no manifest.json dropped + // in beside them. The manifest still comes back to the server, so the + // record of what was shared is kept without cluttering what is shared. + reportFile: "", + includeMovs: true, + copyMode: snapshot.copyMode ?? "auto", + verifyMode: snapshot.verifyMode ?? "auto", + checksumAlgo: snapshot.checksumAlgo, + reportFormats: [], + items: pkg.items.map((item) => ({ + itemId: item.id, + exportId: item.exportId, + shotCode: item.shotCode, + versionString: item.versionString, + // No EXR sequence is involved, so the fields that describe one are empty + // rather than misleadingly populated. + sourceDir: "", + outputPattern: "", + frameStart: item.export.frameStart, + frameEnd: item.export.frameEnd, + fileCount: null, + checksum: null, + deliveryMovPath: item.export.deliveryMovPath, + shotDir: packageDir, + deliveredBaseName: item.clientCode + ? `${item.clientCode}_${item.versionString}` + : `${item.shotCode}_cmp_TT_${item.versionString}`, + validatedAt: null, + })), + }; +} diff --git a/prisma/migrations/20260828000000_mov_review_package/migration.sql b/prisma/migrations/20260828000000_mov_review_package/migration.sql new file mode 100644 index 0000000..1c0ba4c --- /dev/null +++ b/prisma/migrations/20260828000000_mov_review_package/migration.sql @@ -0,0 +1,14 @@ +-- MOV review packages (RenderPipeline2 §11.6): a flat, dated folder of preview +-- MOVs shared for a formal review. Same package/job/verification machinery as +-- an EXR delivery, but it never advances an Export — a shot can appear in as +-- many reviews as it takes before the delivery that actually ships it. + +-- CreateEnum +CREATE TYPE "DeliveryKind" AS ENUM ('EXR_DELIVERY', 'MOV_REVIEW'); + +-- AlterTable: every existing package predates reviews, so it is a delivery. +ALTER TABLE "delivery_packages" + ADD COLUMN "kind" "DeliveryKind" NOT NULL DEFAULT 'EXR_DELIVERY'; + +-- CreateIndex +CREATE INDEX "delivery_packages_projectId_kind_idx" ON "delivery_packages"("projectId", "kind"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b53c7f..5c83c54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,6 +173,15 @@ enum MachineStatus { DISABLED } +/// What a package is for. A MOV_REVIEW package is a flat folder of preview +/// MOVs shared for a formal review — it never touches the Export state +/// machine, so a shot can appear in as many reviews as it takes before the +/// EXR delivery that actually ships it. +enum DeliveryKind { + EXR_DELIVERY + MOV_REVIEW +} + enum DeliveryStatus { DRAFT QUEUED @@ -1097,8 +1106,9 @@ 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" + episode String // "101" — empty string on standard (non-episodic) shows, and on reviews spanning several + name String // "260805_Delivery" | "20260828_Producer_review_02" + kind DeliveryKind @default(EXR_DELIVERY) packageNumber Int // sequential per project → "Package #15" status DeliveryStatus @default(DRAFT) rootPath String? // final path on the delivery volume @@ -1116,6 +1126,7 @@ model DeliveryPackage { @@unique([projectId, packageNumber]) @@index([projectId, episode]) + @@index([projectId, kind]) @@index([status]) @@map("delivery_packages") }