From 86962eb4bd0a65df6f9f1de965af0c01af289407 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:59:05 +0200 Subject: [PATCH] feat(pipeline): automate picture-lock trimming in the preview build Previews used to ship the full padded (handle-included) render to clients, with an artist manually pulling picture lock and hand-trimming the comp to match. The automated preview stage now trims the client comp to the picture-lock edit itself (a fixed handle count off each end, per the studio's existing convention) and separately builds an ungraded, full-duration picture-lock reference MP4 as a diagnostic sidecar to check back against. A new "No Picture Lock" panel checkbox is the single override for shots where picture lock is missing or wrong, falling back to today's untrimmed behavior. Co-Authored-By: Claude Sonnet 5 --- RenderWorker/VFXReviewWorker/Dtos.cs | 17 ++ RenderWorker/VFXReviewWorker/JobExecutor.cs | 14 ++ RenderWorker/VFXReviewWorker/PreviewStage.cs | 35 +++- RenderWorker/scripts/vfxr_build_preview.jsx | 166 +++++++++++++++++- VFXReviewConnector.jsx | 10 ++ .../exports/[exportId]/ExportDetailClient.tsx | 13 +- app/api/ext/exports/route.ts | 4 +- .../jobs/[jobId]/artifact-presign/route.ts | 1 + lib/render-pipeline/config.ts | 11 +- lib/render-pipeline/exports.ts | 9 +- lib/render-pipeline/preview.ts | 36 +++- lib/render-pipeline/project-templates.ts | 9 +- .../migration.sql | 6 + prisma/schema.prisma | 11 ++ 14 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260822000000_export_piclock_fields/migration.sql diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs index 8143cc7..108a18d 100644 --- a/RenderWorker/VFXReviewWorker/Dtos.cs +++ b/RenderWorker/VFXReviewWorker/Dtos.cs @@ -157,6 +157,19 @@ public sealed class PreviewManifest public string? SlateSubmissionProp { get; set; } public SlateInfo Slate { get; set; } = new(); + /// Skip picture-lock trimming and the reference render entirely. + public bool NoPictureLock { get; set; } + /// "{footageRoot}/{showId}/_PICLOCKS" — empty when not configured. + public string? PiclockDir { get; set; } + /// e.g. "UNG_106" — the picture-lock file glob prefix. + public string? PiclockGlobPrefix { get; set; } + public string? SeqTimecodeStart { get; set; } + public string? SeqTimecodeEnd { get; set; } + /// Studio-standard head/tail handle count, applied symmetrically. + public int HandleFrames { get; set; } + /// Full-duration (handles included), ungraded picture-lock reference render. + public string? PiclockRefOutput { get; set; } + public int TotalFrames => FrameEnd - FrameStart + 1; } @@ -186,6 +199,10 @@ public sealed class PreviewBuildResult public int? FrameCount { get; set; } public int? Width { get; set; } public int? Height { get; set; } + /// Whether the picture-lock reference comp was found, positioned and queued. + public bool PiclockRefBuilt { get; set; } + /// Why the picture-lock reference/trim was skipped, when it was (non-fatal). + public string? PiclockSkippedReason { get; set; } } /// The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw. diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs index b5a8d67..42f3c7e 100644 --- a/RenderWorker/VFXReviewWorker/JobExecutor.cs +++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs @@ -438,6 +438,19 @@ public sealed class JobExecutor job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown); } + // Diagnostic sidecar, not a deliverable — an upload failure here must + // never hold back the mov/mp4/thumbnail that already succeeded. + string? piclockRefKey = null; + if (result.PiclockRefPath is not null) + { + piclockRefKey = await _api.UploadArtifactAsync( + job.Id, machineId, "piclockRef", result.PiclockRefPath, "video/mp4", shutdown); + if (piclockRefKey is null) + { + _log.LogWarning("Picture-lock reference upload failed for job {JobId} (non-fatal)", job.Id); + } + } + _spooler.Enqueue("finalize", job.Id, new Dictionary { ["machineId"] = machineId, @@ -446,6 +459,7 @@ public sealed class JobExecutor ["deliveryMovPath"] = manifest.MovOutput, ["previewMovKey"] = previewKey, ["thumbnailKey"] = thumbKey, + ["piclockRefKey"] = piclockRefKey, }, ["renderStats"] = new Dictionary { diff --git a/RenderWorker/VFXReviewWorker/PreviewStage.cs b/RenderWorker/VFXReviewWorker/PreviewStage.cs index 9eec8f3..169a221 100644 --- a/RenderWorker/VFXReviewWorker/PreviewStage.cs +++ b/RenderWorker/VFXReviewWorker/PreviewStage.cs @@ -14,7 +14,8 @@ public sealed record PreviewStageResult( string? ThumbnailPath, string? TempAep, PreviewBuildResult? Build, - double Seconds); + double Seconds, + string? PiclockRefPath = null); /// /// Stage 3 — preview generation (RenderPipeline2 §9), the second half of a @@ -59,6 +60,8 @@ public sealed class PreviewStage var movLocal = mapper.Map(manifest.MovOutput); var mp4Local = mapper.Map(manifest.Mp4Output); + var piclockDirLocal = string.IsNullOrWhiteSpace(manifest.PiclockDir) ? null : mapper.Map(manifest.PiclockDir); + var piclockRefLocal = string.IsNullOrWhiteSpace(manifest.PiclockRefOutput) ? null : mapper.Map(manifest.PiclockRefOutput); // Everything the script needs, in machine-local paths. var context = new @@ -83,6 +86,13 @@ public sealed class PreviewStage slateScopeProp = manifest.SlateScopeProp, slateSubmissionProp = manifest.SlateSubmissionProp, slate = manifest.Slate, + noPictureLock = manifest.NoPictureLock, + piclockDir = piclockDirLocal, + piclockGlobPrefix = manifest.PiclockGlobPrefix, + seqTimecodeStart = manifest.SeqTimecodeStart, + seqTimecodeEnd = manifest.SeqTimecodeEnd, + handleFrames = manifest.HandleFrames, + piclockRefOutput = piclockRefLocal, }; await File.WriteAllTextAsync(contextPath, JsonSerializer.Serialize(context, WorkerOptions.JsonOpts), shutdown); @@ -181,12 +191,29 @@ public sealed class PreviewStage var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, shutdown); + // Non-fatal by design: the picture-lock reference is a diagnostic aid, + // not part of the deliverable, so a missing file here must never hold + // back the mov/mp4 that already rendered successfully. + string? piclockRefResult = null; + if (build.PiclockRefBuilt) + { + if (!string.IsNullOrWhiteSpace(piclockRefLocal) && File.Exists(piclockRefLocal)) + { + piclockRefResult = piclockRefLocal; + } + else + { + _log.LogWarning("Picture-lock reference was queued but is missing after render: {Path}", piclockRefLocal); + } + } + sw.Stop(); - _log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})", - job.Id, sw.Elapsed.TotalSeconds, thumbnail is null ? "" : " + thumbnail"); + _log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb}{Piclock})", + job.Id, sw.Elapsed.TotalSeconds, thumbnail is null ? "" : " + thumbnail", + piclockRefResult is null ? "" : " + piclock ref"); return new PreviewStageResult(true, true, null, movLocal, mp4Local, thumbnail, - build.TempAep, build, sw.Elapsed.TotalSeconds); + build.TempAep, build, sw.Elapsed.TotalSeconds, piclockRefResult); } private async Task RunAfterFxAsync( diff --git a/RenderWorker/scripts/vfxr_build_preview.jsx b/RenderWorker/scripts/vfxr_build_preview.jsx index 81c8b13..f21109c 100644 --- a/RenderWorker/scripts/vfxr_build_preview.jsx +++ b/RenderWorker/scripts/vfxr_build_preview.jsx @@ -30,7 +30,9 @@ queued: [], frameCount: null, width: null, - height: null + height: null, + piclockRefBuilt: false, + piclockSkippedReason: null }; var resultPath = null; @@ -156,6 +158,67 @@ return null; } + // Mirrors the panel's parseTimecodeToSeconds exactly. + function parseTimecodeToSeconds(tc, fps) { + var parts = String(tc).split(/[:;]/); + if (parts.length < 4) { + return 0; + } + return parseInt(parts[0], 10) * 3600 + parseInt(parts[1], 10) * 60 + + parseInt(parts[2], 10) + parseInt(parts[3], 10) / (fps || 24); + } + + // Locates the picture-lock reference footage the same way the panel's + // pullPictureLock does: the _PICLOCKS project folder first, then a disk + // glob under ctx.piclockDir. Returns the FootageItem, or null (with a + // warn()) if it cannot be found — never fatal to the preview build. + function findPiclockFootage(ctx) { + var piclocksProjectFolder = findFolder("_PICLOCKS"); + var i; + var item; + var piclocksDir; + var files; + var importOptions; + + if (piclocksProjectFolder) { + for (i = 1; i <= app.project.numItems; i += 1) { + item = app.project.item(i); + if (item instanceof FootageItem && + item.parentFolder === piclocksProjectFolder && + item.name.indexOf(ctx.piclockGlobPrefix + "_") === 0) { + return item; + } + } + } + + if (!ctx.piclockDir) { + warn("Picture lock: no footage root configured for this project — skipping reference render"); + return null; + } + piclocksDir = new Folder(ctx.piclockDir); + if (!piclocksDir.exists) { + warn("Picture lock: folder not found: " + piclocksDir.fsName); + return null; + } + files = piclocksDir.getFiles(ctx.piclockGlobPrefix + "_*"); + if (!files || files.length < 1) { + warn("Picture lock: no file matching " + ctx.piclockGlobPrefix + "_* found in " + piclocksDir.fsName); + return null; + } + for (i = 0; i < files.length; i += 1) { + if (files[i] instanceof File) { + try { + importOptions = new ImportOptions(files[i]); + return app.project.importFile(importOptions); + } catch (importError) { + warn("Picture lock: import failed: " + importError.message); + return null; + } + } + } + return null; + } + // Mirrors the panel's addOCIOToLayer: effect added but disabled, so the // artist/farm chain matches exactly. function addOCIOToLayer(layer) { @@ -510,8 +573,31 @@ warn("NETFLIX_SLATE layer not found in template — slate fields not filled"); } + // Trim the client-facing comp back to the picture-lock edit (§9, + // picture lock): the render's frame range is editorial ± a fixed + // symmetric handle count by studio convention (the same constant + // pullPictureLock hardcodes), so no picture-lock file is needed for + // this — only ctx.handleFrames. Both movOutput and mp4Output queue + // against this same previewComp, so trimming it once fixes both. var shotLayer = previewComp.layer("SHOT"); - var targetOutPoint = shotLayer.inPoint + shotComp.duration; + var handleSeconds = (ctx.handleFrames || 0) / fps; + var visibleDuration = shotComp.duration; + var trimmedToPictureLock = false; + if (ctx.noPictureLock) { + result.piclockSkippedReason = "No Picture Lock was set for this export"; + } else if (handleSeconds <= 0) { + result.piclockSkippedReason = "No handle frame count configured"; + } else if (2 * handleSeconds >= shotComp.duration) { + warn("Picture lock: handle count (" + ctx.handleFrames + "fr) covers the whole render (" + + result.frameCount + "fr) — trim skipped"); + result.piclockSkippedReason = "handle count exceeds the render duration"; + } else { + shotLayer.startTime -= handleSeconds; + visibleDuration = shotComp.duration - (2 * handleSeconds); + trimmedToPictureLock = true; + } + + var targetOutPoint = shotLayer.inPoint + visibleDuration; if (targetOutPoint > previewComp.duration) { previewComp.duration = targetOutPoint; } @@ -519,13 +605,85 @@ previewComp.duration = targetOutPoint; result.previewComp = previewComp.name; + result.trimmedToPictureLock = trimmedToPictureLock; - // 6. Queue both outputs against the same preview comp. aerender will - // render the whole queue in one launch. app.project.renderQueue.showWindow(false); while (app.project.renderQueue.numItems > 0) { app.project.renderQueue.item(1).remove(); } + + // 5b. Picture-lock reference comp: the raw plate only — no OCIO, no + // show LUT, no overlay — spanning the FULL padded duration (handles + // included), so a supervisor can check back against it if a delivered + // edit is ever suspected to be off. Independent of the trim above: + // this one needs the actual picture-lock file and sequence timecodes, + // and is skipped gracefully (never fatal) if either is missing. + if (ctx.noPictureLock) { + if (!result.piclockSkippedReason) { + result.piclockSkippedReason = "No Picture Lock was set for this export"; + } + } else if (!ctx.seqTimecodeStart || !ctx.seqTimecodeEnd) { + warn("Picture lock: no sequence timecodes for this shot — reference render skipped"); + result.piclockSkippedReason = "no sequence timecodes for this shot"; + } else { + // A diagnostic aid, not a deliverable: any failure building or + // queuing it must warn and move on, never take down the mov/mp4 + // that already succeeded (queueOutput() throws on template errors). + try { + var piclockFootage = findPiclockFootage(ctx); + if (piclockFootage) { + var piclockStartTC = 0; + try { + if (piclockFootage.mainSource && typeof piclockFootage.mainSource.startTimecode === "number") { + piclockStartTC = piclockFootage.mainSource.startTimecode; + } + } catch (tcReadError) { + } + + var tcIn = parseTimecodeToSeconds(ctx.seqTimecodeStart, fps); + var tcOut = parseTimecodeToSeconds(ctx.seqTimecodeEnd, fps); + var refIn = tcIn - piclockStartTC - handleSeconds; + var refOut = tcOut - piclockStartTC + handleSeconds; + if (refIn < 0) { + refIn = 0; + } + if (refOut > piclockFootage.duration) { + refOut = piclockFootage.duration; + } + + var piclockRefComp = app.project.items.addComp( + ctx.shotCode + "_PICLOCK_REF", width, height, 1, refOut - refIn, fps); + if (previewsFolder) { + piclockRefComp.parentFolder = previewsFolder; + } + var piclockLayer = piclockRefComp.layers.add(piclockFootage); + var piclockScale = Math.max( + piclockRefComp.width / piclockFootage.width, + piclockRefComp.height / piclockFootage.height + ) * 100; + piclockLayer.property("Scale").setValue([piclockScale, piclockScale]); + piclockLayer.startTime = -refIn; + piclockLayer.inPoint = 0; + piclockLayer.outPoint = refOut - refIn; + + if (queueOutput(piclockRefComp, ctx.mp4Template, ctx.piclockRefOutput, "piclockRef")) { + result.piclockRefBuilt = true; + } else { + result.piclockSkippedReason = "no mp4 template or output path configured"; + } + } else if (!result.piclockSkippedReason) { + result.piclockSkippedReason = "picture-lock file not found"; + } + } catch (piclockError) { + warn("Picture lock: reference render skipped — " + + (piclockError && piclockError.toString ? piclockError.toString() : String(piclockError))); + result.piclockSkippedReason = "error building the reference render"; + result.piclockRefBuilt = false; + } + } + + // 6. Queue both outputs against the same preview comp. aerender will + // render the whole queue in one launch. queueOutput(previewComp, ctx.movTemplate, ctx.movOutput, "mov"); queueOutput(previewComp, ctx.mp4Template, ctx.mp4Output, "mp4"); diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index 8dea799..f2c856f 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -3962,6 +3962,9 @@ var PANEL_VERSION = "2.5.0"; if (uiState.skipPreviewCheckbox && uiState.skipPreviewCheckbox.value) { body.skipPreview = true; } + if (uiState.noPictureLockCheckbox && uiState.noPictureLockCheckbox.value) { + body.noPictureLock = true; + } if (CONFIG.artistEmail) { body.submittedByEmail = CONFIG.artistEmail; @@ -4531,6 +4534,13 @@ var PANEL_VERSION = "2.5.0"; "For deliverables framed differently from the review — unscaled or\n" + "un-punched-in EXRs — where a preview rebuilt from these frames\n" + "would be the wrong picture."; + uiState.noPictureLockCheckbox = pipelineRow1.add("checkbox", undefined, "No Picture Lock"); + uiState.noPictureLockCheckbox.helpTip = + "Skip picture-lock trimming and the picture-lock reference render\n" + + "for this export.\n\n" + + "Check if picture lock is missing or wrong for this shot — the\n" + + "preview build will fall back to the full rendered range instead\n" + + "of trimming against bad data."; var slateNoteGroup = pipelinePanel.add("group"); slateNoteGroup.orientation = "row"; diff --git a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx index ba54937..a42a363 100644 --- a/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx +++ b/app/(dashboard)/pipeline/exports/[exportId]/ExportDetailClient.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react"; +import { ArrowLeft, RotateCcw, XCircle, ShieldAlert, Clapperboard } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import { cn } from "@/lib/utils"; @@ -36,6 +36,9 @@ interface ExportDetail { vfxScope: string | null; submissionNote: string | null; submittedByName: string | null; + noPictureLock: boolean; + /** Full-duration, ungraded picture-lock reference MP4 — a diagnostic sidecar, not a Version. */ + piclockRefKey: string | null; shot: { id: string; shotCode: string; @@ -177,6 +180,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) { ["Colorspace", e.colorspace ?? "—"], ["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"], ["Submitted by", e.submittedByName ?? "—"], + ["Picture lock", e.noPictureLock ? "Skipped (No Picture Lock)" : "Trimmed to picture lock"], ]; return ( @@ -203,6 +207,13 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
+ {e.piclockRefKey && ( + + + + )} {FAILED_STATUSES.includes(e.status) && (