feat(pipeline): automate picture-lock trimming in the preview build
Deploy / deploy (push) Successful in 10m37s

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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-22 12:59:05 +02:00
parent 82dfaee56f
commit 86962eb4bd
14 changed files with 328 additions and 14 deletions
+17
View File
@@ -157,6 +157,19 @@ public sealed class PreviewManifest
public string? SlateSubmissionProp { get; set; }
public SlateInfo Slate { get; set; } = new();
/// <summary>Skip picture-lock trimming and the reference render entirely.</summary>
public bool NoPictureLock { get; set; }
/// <summary>"{footageRoot}/{showId}/_PICLOCKS" — empty when not configured.</summary>
public string? PiclockDir { get; set; }
/// <summary>e.g. "UNG_106" — the picture-lock file glob prefix.</summary>
public string? PiclockGlobPrefix { get; set; }
public string? SeqTimecodeStart { get; set; }
public string? SeqTimecodeEnd { get; set; }
/// <summary>Studio-standard head/tail handle count, applied symmetrically.</summary>
public int HandleFrames { get; set; }
/// <summary>Full-duration (handles included), ungraded picture-lock reference render.</summary>
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; }
/// <summary>Whether the picture-lock reference comp was found, positioned and queued.</summary>
public bool PiclockRefBuilt { get; set; }
/// <summary>Why the picture-lock reference/trim was skipped, when it was (non-fatal).</summary>
public string? PiclockSkippedReason { get; set; }
}
/// <summary>The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.</summary>
@@ -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<string, object?>
{
["machineId"] = machineId,
@@ -446,6 +459,7 @@ public sealed class JobExecutor
["deliveryMovPath"] = manifest.MovOutput,
["previewMovKey"] = previewKey,
["thumbnailKey"] = thumbKey,
["piclockRefKey"] = piclockRefKey,
},
["renderStats"] = new Dictionary<string, object?>
{
+31 -4
View File
@@ -14,7 +14,8 @@ public sealed record PreviewStageResult(
string? ThumbnailPath,
string? TempAep,
PreviewBuildResult? Build,
double Seconds);
double Seconds,
string? PiclockRefPath = null);
/// <summary>
/// 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<int> RunAfterFxAsync(
+162 -4
View File
@@ -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");
+10
View File
@@ -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";
@@ -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 }) {
</div>
</div>
<div className="flex items-center gap-2">
{e.piclockRefKey && (
<a href={`/api/files/${e.piclockRefKey}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm">
<Clapperboard className="h-4 w-4 mr-2 text-zinc-400" /> Picture Lock Reference
</Button>
</a>
)}
{FAILED_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("retry")}>
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
+3 -1
View File
@@ -5,7 +5,7 @@ import { createExport } from "@/lib/render-pipeline/exports";
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
//
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview? }
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview?, noPictureLock? }
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
@@ -21,6 +21,7 @@ export async function POST(req: NextRequest) {
vfxScope?: string | null;
submissionNote?: string | null;
skipPreview?: boolean;
noPictureLock?: boolean;
};
try {
body = await req.json();
@@ -40,6 +41,7 @@ export async function POST(req: NextRequest) {
vfxScope: body.vfxScope,
submissionNote: body.submissionNote,
skipPreview: body.skipPreview === true,
noPictureLock: body.noPictureLock === true,
});
return NextResponse.json(result, { status: 201 });
} catch (err) {
@@ -16,6 +16,7 @@ const KIND_FOLDERS: Record<string, string> = {
thumbnail: "image",
metadata: "renders",
log: "renders",
piclockRef: "videos",
};
export async function POST(
+10 -1
View File
@@ -26,7 +26,11 @@ export const PIPELINE_CONFIG_DEFAULTS = {
"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). */
/**
* Frames of head/tail handle the studio pads every render with. Used for the
* API timecode fallback (§8.1a) and, symmetrically, to trim the preview
* build's client-facing comp back to the picture-lock edit (§9).
*/
"validation.handleFrames": 8,
/**
* Studio-wide delivery defaults (§11.2). A project's own `deliveryConfig`
@@ -43,6 +47,11 @@ export const PIPELINE_CONFIG_DEFAULTS = {
"preview.lutComp": "_SHOW LUT",
"preview.movTemplate": "4444 Tri",
"preview.mp4Template": "REVIEW_PREVIEW",
// Bare footage root the preview build reads picture-lock plates from, at
// `{footageRoot}/{showId}/_PICLOCKS` — same convention as the panel's own
// "Footage Root" setting (VFXReviewConnector.md). Per-project override via
// pipelineConfig.footageRoot for a show on different storage.
"preview.footageRoot": "V:/_FOOTAGE",
// Essential Property names on the NETFLIX_SLATE layer for the per-submission
// fields. If a name is wrong the build logs the template's actual property
// names as a warning, so it is a config fix rather than a code change.
+8 -1
View File
@@ -70,6 +70,11 @@ export interface CreateExportInput {
* frames would produce the wrong MOV.
*/
skipPreview?: boolean;
/**
* Skip picture-lock trimming and the picture-lock reference render for this
* export — set when picture lock is missing or wrong for the shot.
*/
noPictureLock?: boolean;
}
/**
@@ -205,6 +210,7 @@ export async function createExport(input: CreateExportInput) {
vfxScope,
submissionNote,
skipPreview: input.skipPreview === true,
noPictureLock: input.noPictureLock === true,
submittedById: submittedBy?.id ?? null,
submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null,
},
@@ -247,7 +253,8 @@ export async function createExport(input: CreateExportInput) {
actorId: submittedBy?.id ?? null,
note:
`Queue Export ${versionString}${input.force ? " (force)" : ""}` +
`${input.skipPreview ? " (skip preview)" : ""} — ` +
`${input.skipPreview ? " (skip preview)" : ""}` +
`${input.noPictureLock ? " (no picture lock)" : ""} — ` +
`${provenance.ok ? "" : "WARNING: "}${provenance.note}`,
},
});
+35 -1
View File
@@ -32,6 +32,18 @@ export interface PreviewManifest {
mp4Output: string;
slateScopeProp: string;
slateSubmissionProp: string;
/** Skip picture-lock trimming and the reference render entirely (§9, picture lock). */
noPictureLock: boolean;
/** `{footageRoot}/{showId}/_PICLOCKS`, mirroring the panel's getShowFootageRoot(). */
piclockDir: string;
/** e.g. "UNG_106" — mirrors the panel's getEpisodePrefix(); the picture-lock glob prefix. */
piclockGlobPrefix: string;
seqTimecodeStart: string | null;
seqTimecodeEnd: string | null;
/** Studio-standard head/tail handle count, applied symmetrically. */
handleFrames: number;
/** Full-duration (handles included), ungraded picture-lock reference render. */
piclockRefOutput: string;
slate: {
versionName: string;
date: string;
@@ -58,6 +70,7 @@ type ExportForPreview = {
fps: number;
vfxScope: string | null;
submissionNote: string | null;
noPictureLock: boolean;
};
/**
@@ -78,18 +91,27 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
description: true,
notes: true,
slateDescription: true,
seqTimecodeStart: true,
seqTimecodeEnd: true,
project: { select: { name: true, code: true, showId: true, pipelineConfig: true } },
},
});
if (!shot) throw new PipelineError(404, "Shot not found for export");
const cfg = await getPreviewConfig();
const [cfg, handleFrames] = await Promise.all([getPreviewConfig(), getConfigNumber("validation.handleFrames")]);
// Per-production templates: each show has its own slate/overlay AEP, and
// above all its own show LUT. Falling back to another show's would render a
// preview that is quietly the wrong colour.
const templates = await resolveProjectTemplates(shot.project);
const base = `${shot.shotCode}_cmp_TT_${exportRow.versionString}`;
// Same convention as the panel's getShowFootageRoot()/getEpisodePrefix(): the
// picture-lock plate lives at {footageRoot}/{showId}/_PICLOCKS, glob-matched
// by "{showId}[_{episode}]_*".
const showId = shot.project.showId?.trim() || shot.project.code;
const piclockDir = templates.footageRoot ? `${templates.footageRoot}/${showId}/_PICLOCKS` : "";
const piclockGlobPrefix = shot.episode?.trim() ? `${showId}_${shot.episode.trim()}` : showId;
return {
stage: "PREVIEW",
exportId: exportRow.id,
@@ -110,6 +132,13 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
mp4Output: `${exportRow.outputDir}/${base}.mp4`,
slateScopeProp: cfg.slateScopeProp,
slateSubmissionProp: cfg.slateSubmissionProp,
noPictureLock: exportRow.noPictureLock,
piclockDir,
piclockGlobPrefix,
seqTimecodeStart: shot.seqTimecodeStart,
seqTimecodeEnd: shot.seqTimecodeEnd,
handleFrames,
piclockRefOutput: `${exportRow.outputDir}/${base}_PICLOCK_REF.mp4`,
slate: {
versionName: base,
date: slateDate(),
@@ -158,6 +187,7 @@ export async function inheritPreviewMedia(
deliveryMovPath: true,
previewMovKey: true,
thumbnailKey: true,
piclockRefKey: true,
versionId: true,
},
});
@@ -174,6 +204,7 @@ export async function inheritPreviewMedia(
deliveryMovPath: donor.deliveryMovPath,
previewMovKey: donor.previewMovKey,
thumbnailKey: donor.thumbnailKey,
piclockRefKey: donor.piclockRefKey,
// The Version row stays attached to the export that actually produced the
// media; this one only points at the same files.
versionId: donor.versionId,
@@ -267,6 +298,8 @@ export interface FinalizeInput {
thumbnailKey?: string;
metadataKey?: string;
logFileKey?: string;
/** Full-duration, ungraded picture-lock reference MP4 — a diagnostic sidecar, not a Version. */
piclockRefKey?: string;
};
renderStats?: { renderSeconds?: number; previewSeconds?: number };
media?: { width?: number; height?: number; frameCount?: number; fps?: number; fileName?: string };
@@ -446,6 +479,7 @@ export async function finalizePreview(jobId: string, machineId: string, body: Fi
...(artifacts.previewMovKey ? { previewMovKey: artifacts.previewMovKey } : {}),
...(artifacts.thumbnailKey ? { thumbnailKey: artifacts.thumbnailKey } : {}),
...(artifacts.metadataKey ? { metadataKey: artifacts.metadataKey } : {}),
...(artifacts.piclockRefKey ? { piclockRefKey: artifacts.piclockRefKey } : {}),
}
);
+8 -1
View File
@@ -20,6 +20,10 @@ export const pipelineConfigSchema = z.object({
templateAep: z.string().max(1000).optional().nullable(),
/** Where per-shot pre-renders live, imported by Import Renders. */
rendersRoot: z.string().max(1000).optional().nullable(),
/** Bare footage root (e.g. "V:/_FOOTAGE") the panel's Pull Picture Lock reads
* from at `{footageRoot}/{showId}/_PICLOCKS` — same convention as the panel's
* own `getShowFootageRoot()`. */
footageRoot: z.string().max(1000).optional().nullable(),
/** Overrides for templates that depart from the {showId}_* convention. */
overlayComp: z.string().max(200).optional().nullable(),
previewComp: z.string().max(200).optional().nullable(),
@@ -34,6 +38,7 @@ export type PipelineConfigInput = z.infer<typeof pipelineConfigSchema>;
export interface ResolvedTemplates {
templateAep: string;
rendersRoot: string;
footageRoot: string;
overlayComp: string;
previewComp: string;
lutComp: string;
@@ -78,10 +83,11 @@ export async function resolveProjectTemplates(project: {
return fallback;
};
const [defaultAep, defaultMov, defaultMp4] = await Promise.all([
const [defaultAep, defaultMov, defaultMp4, defaultFootageRoot] = await Promise.all([
getConfigValue("preview.templateAep"),
getConfigValue("preview.movTemplate"),
getConfigValue("preview.mp4Template"),
getConfigValue("preview.footageRoot"),
]);
return {
@@ -89,6 +95,7 @@ export async function resolveProjectTemplates(project: {
// No sensible studio-wide default: a renders root that points at another
// show's folder is worse than an empty one the panel can report.
rendersRoot: pick("rendersRoot", ""),
footageRoot: pick("footageRoot", defaultFootageRoot),
overlayComp: pick("overlayComp", convention.overlayComp),
previewComp: pick("previewComp", convention.previewComp),
lutComp: pick("lutComp", convention.lutComp),
@@ -0,0 +1,6 @@
-- Automated picture-lock handling in the preview build stage: a per-export
-- override to skip it, and a key for the full-duration ungraded reference MP4.
-- AlterTable
ALTER TABLE "exports" ADD COLUMN "noPictureLock" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "exports" ADD COLUMN "piclockRefKey" TEXT;
+11
View File
@@ -880,6 +880,11 @@ model Export {
exrTotalBytes BigInt?
checksum String? // sequence-level digest (xxHash of per-file hashes)
/// Full-duration (handles included), ungraded MP4 of just the picture-lock
/// plate — a diagnostic reference to check back against, not a reviewable
/// deliverable, so it lives here rather than as a Version.
piclockRefKey String?
// Slate fields authored per submission (not per shot): they change from one
// delivery to the next, so each Export carries its own and new exports
// default to the previous export's values.
@@ -892,6 +897,12 @@ model Export {
/// correct preview, and rebuilding it from these EXRs would be wrong.
skipPreview Boolean @default(false)
/// Skip picture-lock trimming and the picture-lock reference render for this
/// export — set when picture lock is missing or wrong for the shot, so the
/// preview build falls back to the untrimmed full render range instead of
/// trimming against bad data.
noPictureLock Boolean @default(false)
submittedById String? // resolved User, else null
submittedByName String? // free text from panel config as fallback
supersededById String? // newer Export that replaced this one