import { Prisma } from "@prisma/client"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; import { applyTransition } from "./transitions"; import { getConfigNumber, getPreviewConfig } from "./config"; /** 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; 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, episode: true, scene: true, description: true, notes: true }, }); if (!shot) throw new PipelineError(404, "Shot not found for export"); const cfg = await getPreviewConfig(); const base = `${shot.shotCode}_cmp_TT_${exportRow.versionString}`; 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: cfg.templateAep, templateComp: cfg.templateComp, overlayComp: cfg.overlayComp, lutComp: cfg.lutComp, movTemplate: cfg.movTemplate, mp4Template: cfg.mp4Template, movOutput: `${exportRow.outputDir}/${base}.mov`, mp4Output: `${exportRow.outputDir}/${base}.mp4`, slateScopeProp: cfg.slateScopeProp, slateSubmissionProp: cfg.slateSubmissionProp, slate: { versionName: base, date: slateDate(), description: shot.description, notes: shot.notes, shotCode: shot.shotCode, episode: shot.episode, scene: shot.scene, vfxScope: exportRow.vfxScope, submissionNote: exportRow.submissionNote, }, }; } /** Creates the PREVIEW_ONLY job that follows a successful EXR render. */ export async function createPreviewJob( tx: Prisma.TransactionClient, exportRow: ExportForPreview, manifest: PreviewManifest, maxAttempts: number, priority: number ) { return tx.renderJob.create({ data: { type: "PREVIEW_ONLY", exportId: exportRow.id, attempt: 1, maxAttempts, priority, manifest: manifest as unknown as Prisma.InputJsonValue, }, }); } 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 }; } /** * 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 }, 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; if (artifacts.previewMovKey) { // 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 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); if (exportRow.taskId) { await tx.version.updateMany({ where: { taskId: exportRow.taskId }, data: { isLatest: false }, }); } const version = await tx.version.create({ data: { versionNumber, shotId: exportRow.shotId, taskId: exportRow.taskId, artistId: exportRow.submittedById, 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, notes: `Pipeline render ${exportRow.versionString} (internal only)`, isLatest: true, isClientVisible: false, // never shared to the client portal (§10.0) }, }); versionId = version.id; } const updated = await applyTransition( tx, exportRow, "READY_FOR_QC", { type: "WORKER", id: machineId, note: `Preview complete in ${body.renderStats?.previewSeconds ?? "?"}s${versionId ? " — review version created" : " — no preview media reported"}`, }, { ...(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 } : 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" }, }); const job = await tx.renderJob.create({ data: { type: "PREVIEW_ONLY", exportId, attempt: (last?.attempt ?? 0) + 1, maxAttempts, priority: last?.priority ?? 50, manifest: manifest as unknown as Prisma.InputJsonValue, }, }); 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 }, }; }); }