Files
vfxreview/lib/render-pipeline/preview.ts
T
twotalesanimation 9660cdc21c
Deploy / deploy (push) Successful in 3m23s
feat(review): High quality playback switch and per-version ProRes download
Worker 1.7.0 (preview.highQuality, default on) now, after the AE render:
- encodes a High quality MP4 from the delivery MOV (full-res H.264 High,
  CRF 14 capped at 100 Mbps) -> Version.hqFileUrl
- uploads the MOV itself -> Version.highResKey/Filename/Size
Both best-effort (the preview never fails over them), with a lease
keep-alive during the long encode/uploads; the export timeline records
what was attached.

Player: HQ switch when a High quality source exists; keeps frame, play
state, speed and zoom, remembered per browser, falls back to Standard if
the file won't load. ProRes itself stays download-only (browser support).

Downloads: the client portal's High Res button serves the version's
ProRes (client-code filename), falling back to the shot's manual file;
staff get a ProRes button on the review page.

Housekeeping (lib/render-pipeline/high-res.ts): HQ MP4 + ProRes are kept
while a version is latest, client-shared, approved or younger than
preview.highResRetentionDays (14); otherwise deleted from storage. Runs
per task after finalize and hourly from the reaper; deleting a version or
a reslate removes its files. The Standard MP4 is never touched.

Also: ProRes/Handles top-bar buttons go icon-only below lg so they don't
overlap the version selector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:32:18 +02:00

760 lines
29 KiB
TypeScript

import { ApprovalStatus, 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, autoShotThumbnailEnabled } from "./project-templates";
import { clientShotCode } from "@/lib/shot-code";
import { deleteStorageKeys, highResStorageKeys, pruneHighResForTask } from "./high-res";
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
import { renderOutputBase } from "./exports";
/** 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;
/** Also encode the High quality MP4 and upload the MOV (worker >= 1.7.0). */
highQuality: boolean;
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;
};
/**
* 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,
clientCode: true,
episode: true,
scene: true,
description: true,
notes: true,
slateDescription: 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();
// 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);
// Shared with createExport, which names the shot's comp task after this
// basename when it has to create one: the task and the file it holds must
// not drift apart.
const base = renderOutputBase(shot.shotCode, exportRow.versionString);
// Client code, when set, replaces shotCode in everything the client sees —
// slate identity and (via ctx.slate.versionName) the overlay burn-in — but
// never in internal paths: movOutput/mp4Output below stay on `base`. Just
// their own naming with our version appended, no "_cmp_TT_" studio jargon.
const clientBase = shot.clientCode ? `${shot.clientCode}_${exportRow.versionString}` : base;
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,
highQuality: cfg.highQuality,
slate: {
versionName: clientBase,
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: clientShotCode(shot),
// 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,
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,
// 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,
},
});
}
/**
* What approval a pipeline-created Version inherits from the one it supersedes.
*
* Only APPROVED carries. A re-export re-renders EXRs for delivery from a shot
* that has usually already been signed off, and defaulting to PENDING_REVIEW
* would make an approved shot silently reappear as awaiting review.
*
* The rejecting verdicts are the opposite case: NEEDS_CHANGES and REJECTED
* describe the version someone asked to be fixed, and the render coming through
* here is the fix. Carrying them forward left every re-render wearing the note
* that prompted it, so nothing in the app registered that the work was redone.
*/
export function carryApproval(previous: ApprovalStatus | null | undefined): ApprovalStatus {
return previous === "APPROVED" ? "APPROVED" : "PENDING_REVIEW";
}
/**
* `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;
/** High-bitrate full-res MP4 encoded from the MOV (worker >= 1.7.0). */
hqPreviewKey?: string | null;
/** The delivery MOV itself, uploaded as the version's High Res file. */
highResKey?: string | null;
highResFileName?: string | null;
highResSize?: number | null;
};
renderStats?: { renderSeconds?: number; previewSeconds?: number };
media?: {
width?: number;
height?: number;
frameCount?: number;
fps?: number;
fileName?: string;
/** Where the picture starts after the head slate (worker ≥ 1.6.4). */
pictureStartSeconds?: number | 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) {
const { replacedHighRes, housekeepTaskId, ...outcome } = await finalizePreviewTx(jobId, machineId, body);
// Storage is only touched once the transaction has committed: a rollback
// must never leave a Version pointing at a deleted file.
await deleteStorageKeys(replacedHighRes);
if (housekeepTaskId) {
await pruneHighResForTask(housekeepTaskId).catch((err) =>
console.error("[high-res] housekeeping after finalize failed", err)
);
}
return outcome;
}
async function finalizePreviewTx(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,
replacedHighRes: [] as string[],
housekeepTaskId: null as string | null,
};
}
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 replacedHighRes: string[] = [];
let versionReplaced = false;
let versionCreated = false;
let inheritedApproval: string | null = null;
if (artifacts.previewMovKey) {
const fps = media.fps ?? exportRow.fps;
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,
frameCount: media.frameCount ?? exportRow.frameEnd - exportRow.frameStart + 1,
width: media.width ?? exportRow.width,
height: media.height ?? exportRow.height,
// Review metadata for the player's handle markers. The slate length is
// measured by the build when the worker reports it; left null the
// player infers it from the MP4 length vs frameCount.
handleHead: exportRow.handleHead,
handleTail: exportRow.handleTail,
pictureStartFrame:
typeof media.pictureStartSeconds === "number" && media.pictureStartSeconds >= 0
? Math.round(media.pictureStartSeconds * fps)
: null,
// High quality + ProRes: whatever this build produced. Absent means
// the worker skipped or failed them (never fatal) — Standard only.
hqFileUrl: artifacts.hqPreviewKey ? `/api/files/${artifacts.hqPreviewKey}` : null,
highResKey: artifacts.highResKey ?? null,
highResFilename: artifacts.highResKey ? (artifacts.highResFileName ?? null) : null,
highResSize:
artifacts.highResKey && typeof artifacts.highResSize === "number"
? BigInt(Math.round(artifacts.highResSize))
: null,
highResPrunedAt: null,
};
// 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, hqFileUrl: true, highResKey: true },
})
: null;
if (existing) {
// The reslate replaces the high-quality files too; the old ones would
// otherwise sit in storage with nothing pointing at them.
replacedHighRes = highResStorageKeys(existing).filter(
(k) => k !== artifacts.highResKey && k !== artifacts.hqPreviewKey
);
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.
const previousLatest = exportRow.taskId
? await tx.version.findFirst({
where: { taskId: exportRow.taskId },
orderBy: { versionNumber: "desc" },
select: { approvalStatus: true, versionNumber: true },
})
: null;
const carriedApproval = carryApproval(previousLatest?.approvalStatus);
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: carriedApproval,
isLatest: true,
isClientVisible: false, // never shared to the client portal (§10.0)
},
});
versionId = version.id;
versionCreated = true;
inheritedApproval = carriedApproval;
}
if (exportRow.taskId) {
await tx.version.updateMany({
where: { taskId: exportRow.taskId, id: { not: versionId } },
data: { isLatest: false },
});
}
}
// Promote the render frame onto the shot, for shows that opted in.
//
// Only `generatedThumbnailUrl` is written — `thumbnailUrl` holds the
// reference frame that came with the brief and the pipeline never touches
// it. Shows that supply stills therefore look exactly as they did; shows
// that supply none get their empty grids filled from the renders instead.
//
// Every outcome is recorded on the export timeline, including the ones that
// did nothing: silence here is indistinguishable from a broken worker, a
// project whose flag never saved, and a feature working as configured.
let thumbnailNote: string;
if (!artifacts.thumbnailKey) {
thumbnailNote =
" — no thumbnail in this render, so the shot's was left alone " +
"(the worker reported no thumbnail: check ffmpegPath in its config.json)";
} else {
const shot = await tx.shot.findUnique({
where: { id: exportRow.shotId },
select: { project: { select: { pipelineConfig: true } } },
});
if (!shot) {
thumbnailNote = " — shot not found, thumbnail not applied";
} else if (!autoShotThumbnailEnabled(shot.project)) {
thumbnailNote =
" — shot thumbnail left alone (this project has \"Use render frames as " +
"shot thumbnails\" turned off)";
} else {
await tx.shot.update({
where: { id: exportRow.shotId },
data: {
generatedThumbnailUrl: `/api/files/${artifacts.thumbnailKey}`,
generatedThumbnailFrom: exportRow.versionString,
},
});
thumbnailNote = " — shot thumbnail updated from this render";
}
}
// 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 },
});
}
}
}
// A genuinely new version is new work awaiting review, so the task moves the
// same way a manual upload moves it (actions/versions.ts, batch-upload) —
// otherwise a task left on CHANGES by a review or a QC fail stays there
// through every re-render, and the shot stays on REVISIONS with no way out
// but a manual kanban drag.
//
// A *replaced* version is deliberately excluded: regeneratePreview
// re-delivers the same version after a slate fix, and that is not a new
// submission. Nor is an already-approved carry-over, which would drag a
// signed-off shot back into review.
let taskStatusMoved = false;
let approvalCleared = false;
if (versionCreated && exportRow.taskId && inheritedApproval !== "APPROVED") {
const task = await tx.task.findUnique({
where: { id: exportRow.taskId },
select: { status: true },
});
if (task && task.status !== "INTERNAL_REVIEW") {
await tx.task.update({
where: { id: exportRow.taskId },
data: { status: "INTERNAL_REVIEW" },
});
taskStatusMoved = true;
}
approvalCleared = await clearStaleShotApproval(tx, exportRow.shotId);
}
// Shot.status is derived from task states (lib/shot-status.ts); nothing in
// the pipeline recalculated it, so shot boards went stale the moment a
// render changed a task.
if (taskStatusMoved || approvalCleared) {
await recalcShotStatus(exportRow.shotId, tx);
}
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}` : "") +
(taskStatusMoved ? " — task moved to INTERNAL_REVIEW" : "") +
thumbnailNote +
(versionId ? highResNote(artifacts) : ""),
},
{
...(versionId ? { versionId } : {}),
...(artifacts.deliveryMovPath ? { deliveryMovPath: artifacts.deliveryMovPath } : {}),
...(artifacts.previewMovKey ? { previewMovKey: artifacts.previewMovKey } : {}),
...(artifacts.thumbnailKey ? { thumbnailKey: artifacts.thumbnailKey } : {}),
...(artifacts.metadataKey ? { metadataKey: artifacts.metadataKey } : {}),
}
);
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,
replacedHighRes,
housekeepTaskId: versionId ? exportRow.taskId : null,
};
});
}
/** Timeline wording for what high-quality media this build attached. */
function highResNote(artifacts: NonNullable<FinalizeInput["artifacts"]>): string {
const hq = !!artifacts.hqPreviewKey;
const mov = !!artifacts.highResKey;
if (hq && mov) return " — High quality MP4 and ProRes attached";
if (hq) return " — High quality MP4 attached (ProRes upload failed or skipped)";
if (mov) return " — ProRes attached (High quality encode failed or skipped)";
return " — no High quality/ProRes (disabled, worker < 1.7.0, or both failed — see the worker log)";
}
/** 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 },
};
});
}