Files
vfxreview/lib/render-pipeline/preview.ts
T
twotalesanimation 67d8639861
Deploy / deploy (push) Successful in 6m17s
fix(pipeline): trim picture-lock handles at the footage comp, not the template's SHOT layer
Manipulating startTime/outPoint on previewComp's SHOT layer touched a
comp duplicated from the artist-authored template, whose slate/shot
timing relationship the build script doesn't own -- and it dropped the
slate frame in production. The trim now happens one level down, on the
private single-layer footage precomp this script fully controls;
previewComp's own sizing logic is untouched from before this feature
existed, since it already generalizes correctly to any shotComp
duration.

Also threads the picture-lock skip reason through to the export's
timeline, so a shot missing sequence timecodes or a matching picture
lock file is visible without digging into worker-machine logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:22:49 +02:00

627 lines
23 KiB
TypeScript

import { ExportStatus, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import { applyTransition } from "./transitions";
import { getConfigNumber, getPreviewConfig } from "./config";
import { resolveProjectTemplates } from "./project-templates";
/** Slate date format used by the panel's getDateString (YYYY/MM/DD). */
function slateDate(d = new Date()): string {
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}/${mm}/${dd}`;
}
export interface PreviewManifest {
stage: "PREVIEW";
exportId: string;
shotCode: string;
versionString: string;
outputDir: string;
outputPattern: string;
frameStart: number;
frameEnd: number;
fps: number;
templateAep: string;
templateComp: string;
overlayComp: string;
lutComp: string;
movTemplate: string;
mp4Template: string;
movOutput: string;
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;
description: string | null;
notes: string | null;
shotCode: string;
/** Production name, burned into the slate and the overlay. */
show: string | null;
episode: string | null;
scene: string | null;
vfxScope: string | null;
submissionNote: string | null;
};
}
type ExportForPreview = {
id: string;
shotId: string;
versionString: string;
outputDir: string;
outputPattern: string;
frameStart: number;
frameEnd: number;
fps: number;
vfxScope: string | null;
submissionNote: string | null;
noPictureLock: boolean;
};
/**
* Builds the PREVIEW_ONLY job manifest (RenderPipeline2 §9): everything the
* headless AE build script needs to rebuild the shot around the rendered EXRs
* and queue the delivery MOV + review MP4.
*
* The MOV and MP4 are written beside the EXR sequence, so every artifact for a
* version lives in one folder and Phase 5 can package them together.
*/
export async function buildPreviewManifest(exportRow: ExportForPreview): Promise<PreviewManifest> {
const shot = await db.shot.findUnique({
where: { id: exportRow.shotId },
select: {
shotCode: true,
episode: true,
scene: true,
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, 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,
shotCode: shot.shotCode,
versionString: exportRow.versionString,
outputDir: exportRow.outputDir,
outputPattern: exportRow.outputPattern,
frameStart: exportRow.frameStart,
frameEnd: exportRow.frameEnd,
fps: exportRow.fps,
templateAep: templates.templateAep,
templateComp: templates.previewComp,
overlayComp: templates.overlayComp,
lutComp: templates.lutComp,
movTemplate: templates.movTemplate,
mp4Template: templates.mp4Template,
movOutput: `${exportRow.outputDir}/${base}.mov`,
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(),
// The slate's own description is authored on the Shot's Slate tab and
// falls back to the general shot description — the same precedence the
// tab shows as its placeholder.
description: shot.slateDescription ?? shot.description,
notes: shot.notes,
shotCode: shot.shotCode,
// The production's own name, not its code: this is read off a slate.
show: shot.project?.name ?? shot.project?.code ?? null,
episode: shot.episode,
scene: shot.scene,
vfxScope: exportRow.vfxScope,
submissionNote: exportRow.submissionNote,
},
};
}
/**
* Carries the previous export's review media onto one that skipped its own
* preview build (§9).
*
* An export whose framing differs from the review framing — unscaled or
* un-punched-in EXRs — must not have a preview rebuilt from its own frames:
* the earlier, correctly-framed MOV *is* the right preview. Inheriting it also
* keeps the delivery package whole, since the builder takes its MOV from
* `deliveryMovPath`.
*
* Returns the fields to apply and a note describing what happened, so the
* export's timeline records where its media came from.
*/
export async function inheritPreviewMedia(
tx: Prisma.TransactionClient,
exportRow: { id: string; shotId: string }
): Promise<{ data: Prisma.ExportUpdateInput; note: string }> {
const donor = await tx.export.findFirst({
where: {
shotId: exportRow.shotId,
id: { not: exportRow.id },
OR: [{ previewMovKey: { not: null } }, { deliveryMovPath: { not: null } }],
},
orderBy: { versionNumber: "desc" },
select: {
versionString: true,
deliveryMovPath: true,
previewMovKey: true,
thumbnailKey: true,
piclockRefKey: true,
versionId: true,
},
});
if (!donor) {
return {
data: {},
note: "no preview built and no earlier export to inherit one from — this export has no review media",
};
}
return {
data: {
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,
},
note: `preview skipped — review media inherited from ${donor.versionString}`,
};
}
/**
* How a preview job is ranked against the render it follows.
*
* The preview is created when the render finishes, so with equal priority it
* sorts behind every render queued at the same time — a batch runs as all the
* renders, then all the previews, and nothing is reviewable until the end. The
* boost lets a finished shot complete instead.
*/
export function previewPriority(renderPriority: number, boost: number, urgentThreshold: number): number {
const boosted = renderPriority - Math.max(0, boost);
// A boost must never promote a job across the urgent line: that would let it
// claim outside the render window, which whoever queued it did not ask for.
if (renderPriority > urgentThreshold && boosted <= urgentThreshold) return urgentThreshold + 1;
return Math.max(0, boosted);
}
/**
* The priority an export's work runs at: the one its render was queued with.
*
* Every preview for an export derives from this rather than from a previous
* preview job, for two reasons. A preview job may not exist yet — an export
* that failed validation never had one, and reading a missing row used to
* silently drop an urgent export to normal. And deriving from an already-
* boosted preview would boost again on each retry, drifting the export up the
* queue a step at a time.
*/
export async function renderPriorityFor(
tx: Prisma.TransactionClient,
exportId: string
): Promise<number> {
const job = await tx.renderJob.findFirst({
where: { exportId, type: "AE_RENDER" },
orderBy: { attempt: "desc" },
select: { priority: true },
});
return job?.priority ?? 50;
}
/**
* Creates a PREVIEW_ONLY job. `renderPriority` is the export's *render*
* priority; the queue rank is derived from it here so every path — first
* build, retry, regenerate, validation override — ranks identically.
*/
export async function createPreviewJob(
tx: Prisma.TransactionClient,
exportRow: ExportForPreview,
manifest: PreviewManifest,
maxAttempts: number,
renderPriority: number,
attempt = 1
) {
const [boost, urgentThreshold] = await Promise.all([
getConfigNumber("render.previewPriorityBoost"),
getConfigNumber("render.urgentPriorityThreshold"),
]);
return tx.renderJob.create({
data: {
type: "PREVIEW_ONLY",
exportId: exportRow.id,
attempt,
maxAttempts,
priority: previewPriority(renderPriority, boost, urgentThreshold),
manifest: manifest as unknown as Prisma.InputJsonValue,
},
});
}
/**
* `UNG_102_019_010_cmp_TT_v004` + v005 → `UNG_102_019_010_cmp_TT_v005`.
* Mirrors `nextExrOutputBase` (exports.ts) so the task title, the EXR base and
* the preview filename all carry the same version suffix.
*/
export function nextTaskTitle(current: string, versionString: string): string {
if (/_v\d+$/i.test(current)) return current.replace(/_v\d+$/i, `_${versionString}`);
return `${current}_${versionString}`;
}
export interface FinalizeInput {
artifacts?: {
deliveryMovPath?: string;
previewMovKey?: string;
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 };
/** Why the picture-lock trim/reference was skipped, when it was — surfaced on the export's timeline. */
piclockSkippedReason?: string | null;
}
/**
* E13 — preview artifacts are done: register the review MP4 as an ordinary
* `Version` and move the Export to READY_FOR_QC.
*
* Pipeline mode (§6.4 / T12): the Version is **never client-visible** and
* **no task or shot status is touched**. Delivery QC runs on shots the client
* has usually already approved, so a post-approval technical render must not
* resurface in the review flow or the client portal.
*/
export async function finalizePreview(jobId: string, machineId: string, body: FinalizeInput) {
return db.$transaction(async (tx) => {
const job = await tx.renderJob.findUnique({
where: { id: jobId },
include: { export: true },
});
if (!job) throw new PipelineError(404, "Render job not found");
if (job.machineId !== machineId) {
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
}
const exportRow = job.export;
if (!exportRow) throw new PipelineError(422, "Job has no export to finalize");
if (exportRow.status === "READY_FOR_QC" && exportRow.versionId) {
return {
export: { id: exportRow.id, status: exportRow.status },
version: { id: exportRow.versionId },
task: null,
alreadyApplied: true,
};
}
const artifacts = body.artifacts ?? {};
const media = body.media ?? {};
await tx.renderJob.update({
where: { id: job.id },
data: {
status: "COMPLETED",
progress: 1,
finishedAt: new Date(),
exitCode: 0,
logFileKey: artifacts.logFileKey ?? job.logFileKey,
},
});
let versionId: string | null = null;
let versionReplaced = false;
let inheritedApproval: string | null = null;
if (artifacts.previewMovKey) {
const mediaFields = {
fileUrl: `/api/files/${artifacts.previewMovKey}`,
fileName: media.fileName ?? `${exportRow.versionString}.mp4`,
mimeType: "video/mp4",
thumbnailUrl: artifacts.thumbnailKey ? `/api/files/${artifacts.thumbnailKey}` : undefined,
fps: media.fps ?? exportRow.fps,
frameCount: media.frameCount ?? exportRow.frameEnd - exportRow.frameStart + 1,
width: media.width ?? exportRow.width,
height: media.height ?? exportRow.height,
};
// Regenerating a preview (T11b) re-delivers the *same* export version, so
// it replaces the media on the Version already registered rather than
// stacking a second row: the version number stays put and any notes or
// annotations reviewers left on it survive the reslate.
const existing = exportRow.versionId
? await tx.version.findUnique({ where: { id: exportRow.versionId }, select: { id: true } })
: null;
if (existing) {
await tx.version.update({
where: { id: existing.id },
data: { ...mediaFields, isLatest: true },
});
versionId = existing.id;
versionReplaced = true;
} else {
// Version numbering stays monotonic for the task (existing review flows
// rely on the newest version having the highest number) while matching
// the export version whenever it is ahead.
// A re-export does not change the picture — it re-renders EXRs for
// delivery from a shot that has usually already been approved. A fresh
// Version defaults to PENDING_REVIEW, which is what the dashboard, the
// shot page and the client portal all read, so an approved shot would
// silently reappear as awaiting review. The approval carries over from
// the version this one supersedes.
const previousLatest = exportRow.taskId
? await tx.version.findFirst({
where: { taskId: exportRow.taskId },
orderBy: { versionNumber: "desc" },
select: { approvalStatus: true, versionNumber: true },
})
: null;
const taskMax = exportRow.taskId
? await tx.version.aggregate({
where: { taskId: exportRow.taskId },
_max: { versionNumber: true },
})
: { _max: { versionNumber: null as number | null } };
const versionNumber = Math.max((taskMax._max.versionNumber ?? 0) + 1, exportRow.versionNumber);
const version = await tx.version.create({
data: {
...mediaFields,
versionNumber,
shotId: exportRow.shotId,
taskId: exportRow.taskId,
artistId: exportRow.submittedById,
// `notes` is the artist's message to reviewers and is shown in the
// client portal, so the pipeline stays out of it. What this version
// is and where it came from lives on the Export and its timeline,
// which is where someone would go looking anyway.
approvalStatus: previousLatest?.approvalStatus ?? "PENDING_REVIEW",
isLatest: true,
isClientVisible: false, // never shared to the client portal (§10.0)
},
});
versionId = version.id;
inheritedApproval = previousLatest?.approvalStatus ?? null;
}
if (exportRow.taskId) {
await tx.version.updateMany({
where: { taskId: exportRow.taskId, id: { not: versionId } },
data: { isLatest: false },
});
}
}
// The task title carries the version it is showing (house convention:
// "UNG_102_019_010_cmp_TT_v004"). The preview just uploaded *is* the task's
// new latest version, so the title has to follow the file — the same rename
// the manual batch upload does on "rename-and-upload".
let taskTitle: string | null = null;
let taskRenamed = false;
if (versionId && exportRow.taskId) {
const task = await tx.task.findUnique({
where: { id: exportRow.taskId },
select: { title: true },
});
if (task) {
taskTitle = nextTaskTitle(task.title, exportRow.versionString);
taskRenamed = taskTitle !== task.title;
if (taskRenamed) {
await tx.task.update({
where: { id: exportRow.taskId },
data: { title: taskTitle },
});
}
}
}
const updated = await applyTransition(
tx,
exportRow,
"READY_FOR_QC",
{
type: "WORKER",
id: machineId,
note:
`Preview complete in ${body.renderStats?.previewSeconds ?? "?"}s` +
(versionId
? versionReplaced
? " — review version media replaced"
: ` — review version created (${inheritedApproval ?? "PENDING_REVIEW"})`
: " — no preview media reported") +
(taskRenamed ? ` — task renamed to ${taskTitle}` : "") +
(artifacts.piclockRefKey
? " — picture-lock reference built"
: body.piclockSkippedReason
? ` — picture lock skipped (${body.piclockSkippedReason})`
: ""),
},
{
...(versionId ? { versionId } : {}),
...(artifacts.deliveryMovPath ? { deliveryMovPath: artifacts.deliveryMovPath } : {}),
...(artifacts.previewMovKey ? { previewMovKey: artifacts.previewMovKey } : {}),
...(artifacts.thumbnailKey ? { thumbnailKey: artifacts.thumbnailKey } : {}),
...(artifacts.metadataKey ? { metadataKey: artifacts.metadataKey } : {}),
...(artifacts.piclockRefKey ? { piclockRefKey: artifacts.piclockRefKey } : {}),
}
);
return {
export: { id: updated.id, status: updated.status },
version: versionId ? { id: versionId, replaced: versionReplaced } : null,
task: exportRow.taskId ? { id: exportRow.taskId, title: taskTitle, renamed: taskRenamed } : null,
alreadyApplied: false,
};
});
}
/** T11 — retry the preview stage only; validated EXRs are left alone. */
export async function retryPreview(exportId: string, actorId?: string) {
const maxAttempts = await getConfigNumber("render.maxAttempts");
return db.$transaction(async (tx) => {
const exportRow = await tx.export.findUnique({ where: { id: exportId } });
if (!exportRow) throw new PipelineError(404, "Export not found");
if (exportRow.status !== "PREVIEW_FAILED") {
throw new PipelineError(409, `Export is ${exportRow.status} — only PREVIEW_FAILED exports can retry the preview`);
}
const manifest = await buildPreviewManifest(exportRow);
const last = await tx.renderJob.findFirst({
where: { exportId, type: "PREVIEW_ONLY" },
orderBy: { attempt: "desc" },
select: { attempt: true },
});
const job = await createPreviewJob(
tx,
exportRow,
manifest,
maxAttempts,
await renderPriorityFor(tx, exportId),
(last?.attempt ?? 0) + 1
);
const updated = await applyTransition(tx, exportRow, "GENERATING_PREVIEW", {
type: "USER",
id: actorId,
note: `Retry preview (attempt ${job.attempt})`,
});
return {
export: { id: updated.id, status: updated.status },
renderJob: { id: job.id, attempt: job.attempt },
};
});
}
/** Statuses a preview can be regenerated from — every state where the EXRs are known good. */
const REGENERATABLE_STATUSES: ExportStatus[] = ["READY_FOR_QC", "QC_FAILED", "PREVIEW_FAILED"];
export interface RegeneratePreviewInput {
/** Per-export override; omitted → re-read from the shot's slate fields. */
vfxScope?: string | null;
submissionNote?: string | null;
actorId?: string;
note?: string;
}
/**
* T11b — rebuild the preview for an export that already delivered one, after a
* slate or burn-in mistake was spotted in QC.
*
* Re-runs the *whole* preview stage rather than patching anything: the manifest
* is rebuilt from scratch, so the shot code, episode/scene, frame count, slate
* date and burn-ins are all re-derived from current data exactly as they are on
* a first-time preview. The validated EXRs are reused and the version number is
* untouched — this re-delivers the same version, it does not make a new one.
*
* Slate text authored on the web app's Slate tab (description, VFX scope,
* submission note) is picked up here: `vfxScope` and `submissionNote` are
* frozen onto the Export at submission time, so regenerating refreshes that
* snapshot from the shot unless the caller passes an explicit override.
*/
export async function regeneratePreview(exportId: string, input: RegeneratePreviewInput = {}) {
const maxAttempts = await getConfigNumber("render.maxAttempts");
return db.$transaction(async (tx) => {
const exportRow = await tx.export.findUnique({ where: { id: exportId } });
if (!exportRow) throw new PipelineError(404, "Export not found");
if (!REGENERATABLE_STATUSES.includes(exportRow.status)) {
throw new PipelineError(
409,
`Export is ${exportRow.status} — a preview can only be regenerated from ${REGENERATABLE_STATUSES.join(", ")}`
);
}
const shot = await tx.shot.findUniqueOrThrow({
where: { id: exportRow.shotId },
select: { slateVfxScope: true, slateSubmissionNote: true },
});
// Same precedence as createExport: explicit override → shot's slate field.
const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (shot.slateVfxScope ?? null);
const submissionNote =
input.submissionNote !== undefined ? input.submissionNote : (shot.slateSubmissionNote ?? null);
const refreshed = { ...exportRow, vfxScope, submissionNote };
const manifest = await buildPreviewManifest(refreshed);
const last = await tx.renderJob.findFirst({
where: { exportId, type: "PREVIEW_ONLY" },
orderBy: { attempt: "desc" },
select: { attempt: true },
});
const job = await createPreviewJob(
tx,
refreshed,
manifest,
maxAttempts,
await renderPriorityFor(tx, exportId),
(last?.attempt ?? 0) + 1
);
const updated = await applyTransition(
tx,
exportRow,
"GENERATING_PREVIEW",
{
type: "USER",
id: input.actorId,
note: input.note ?? `Regenerate preview (attempt ${job.attempt})`,
},
{ vfxScope, submissionNote }
);
return {
export: { id: updated.id, status: updated.status },
renderJob: { id: job.id, attempt: job.attempt },
slate: { vfxScope, submissionNote, description: manifest.slate.description },
};
});
}