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 { 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 { 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 }, }; }); }