2e9fa45580
Deploy / deploy (push) Successful in 3m31s
Renders run 8 frames past the cut at each end. Directors were flagging glitches in unfixed handles as shot problems, so the player now marks them — without trimming or burning anything into the EXRs/MOV/MP4. - Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/ handleTail; migration backfills pipeline exports and their preview versions from the manifest's handleFrames (else 8) - AE panel: "Includes handles [8] frames each end" -> manifest.review; older manifests fall back to source.handleFrames - finalize copies handles onto the Version; worker 1.6.4 reports the head slate length (pictureStartSeconds), else the player infers it as MP4 frames - EXR frameCount - Player: hatched HANDLE / SLATE regions and cut marks on the timeline, "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge on notes, "Edit range only" toggle (default on in the client portal) - Staff Handles editor on the review page for manual uploads/corrections Also fixes two player bugs found while testing: timeToFrame floored float error (every seek landed a frame early), and J/reverse never ran (the keyboard effect's cleanup stopped it on the next render). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
703 lines
26 KiB
TypeScript
703 lines
26 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 { 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;
|
|
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,
|
|
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;
|
|
};
|
|
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) {
|
|
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 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,
|
|
};
|
|
|
|
// 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.
|
|
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 ? { 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,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 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 },
|
|
};
|
|
});
|
|
}
|