import { ExportStatus, Prisma } from "@prisma/client"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; import { renderManifestSchema, type RenderManifest } from "./manifest"; import { applyTransition, type TransitionActor } from "./transitions"; import { getConfigNumber, getConfigValue } from "./config"; /** Statuses in which a shot has a live export in flight (§6.4 E1 409 rule). */ const ACTIVE_STATUSES: ExportStatus[] = [ "QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW", ]; /** Non-terminal statuses auto-superseded when a newer Export is queued (T1/T17). */ const SUPERSEDABLE_STATUSES: ExportStatus[] = [ "QUEUED", "RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "READY_FOR_QC", "QC_FAILED", "READY_FOR_DELIVERY", ]; const OPEN_JOB_STATUSES = ["QUEUED", "CLAIMED", "RUNNING"] as const; export function parseVersionString(v: string | null | undefined): number { const m = /^v(\d+)$/i.exec(v?.trim() ?? ""); return m ? parseInt(m[1], 10) : 0; } export function formatVersionString(n: number): string { return `v${String(n).padStart(3, "0")}`; } /** `UNG_..._cmp_TT_v003` + 4 → `UNG_..._cmp_TT_v004`; falls back to house convention. */ function nextExrOutputBase(current: string | null, shotCode: string, versionString: string): string { if (current && /_v\d+$/i.test(current)) return current.replace(/_v\d+$/i, `_${versionString}`); if (current) return `${current}_${versionString}`; return renderOutputBase(shotCode, versionString); } /** * Rendered EXR naming for the pipeline — matches the panel's * "Queue EXR (Review)" convention (`{shotCode}_cmp_TT_{version}`), which is * shot-code based and deliberately excludes the source clip name that * `Shot.exrOutput` carries from the EDL import. */ function renderOutputBase(shotCode: string, versionString: string): string { return `${shotCode}_cmp_TT_${versionString}`; } export interface CreateExportInput { manifest: unknown; submittedByEmail?: string | null; priority?: number; force?: boolean; /** * Per-submission slate fields. `undefined` inherits the previous export's * value for this shot; `null` or "" explicitly clears it. */ vfxScope?: string | null; submissionNote?: string | null; } /** * E1 — Queue Export (§6.4). Transactionally: decides the new version number * (server-side, race-free), updates Shot.shotVersion/exrOutput exactly as the * panel's legacy PATCH did, supersedes older non-terminal exports, creates * Export(QUEUED) + RenderJob attempt 1 with the manifest snapshot. */ export async function createExport(input: CreateExportInput) { const parsed = renderManifestSchema.safeParse(input.manifest); if (!parsed.success) { throw new PipelineError(422, "Manifest validation error", parsed.error.issues); } const manifest = parsed.data; const shot = await db.shot.findUnique({ where: { id: manifest.shotId }, include: { project: { select: { id: true, code: true, showId: true } }, tasks: { where: { type: "COMP" }, orderBy: { sortOrder: "asc" }, take: 1, select: { id: true } }, }, }); if (!shot) throw new PipelineError(404, `Shot not found: ${manifest.shotId}`); // Cross-checks (§6.2): manifest vs DB truth if (shot.shotCode !== manifest.shotCode) { throw new PipelineError(422, `Manifest shotCode "${manifest.shotCode}" does not match shot ${shot.id} ("${shot.shotCode}")`); } if (shot.project.code !== manifest.projectCode) { throw new PipelineError(422, `Manifest projectCode "${manifest.projectCode}" does not match shot's project ("${shot.project.code}")`); } // A 0/0 shot range means "never imported", not a real single-frame shot at 0 const shotRangeKnown = shot.frameStart != null && shot.frameEnd != null && !(shot.frameStart === 0 && shot.frameEnd === 0); if ( !input.force && shotRangeKnown && (shot.frameStart !== manifest.frameStart || shot.frameEnd !== manifest.frameEnd) ) { throw new PipelineError(422, `Manifest frame range ${manifest.frameStart}-${manifest.frameEnd} does not match shot record ${shot.frameStart}-${shot.frameEnd} (pass force to override)`); } const activeExport = await db.export.findFirst({ where: { shotId: shot.id, status: { in: ACTIVE_STATUSES } }, select: { id: true, status: true, versionString: true }, }); if (activeExport && !input.force) { throw new PipelineError( 409, `An active export (${activeExport.versionString}, ${activeExport.status}) already exists for this shot — pass force to supersede it`, { activeExportId: activeExport.id } ); } const submittedBy = input.submittedByEmail ? await db.user.findUnique({ where: { email: input.submittedByEmail }, select: { id: true, name: true } }) : null; const [maxAttempts, outputRoot] = await Promise.all([ getConfigNumber("render.maxAttempts"), getConfigValue("render.outputRoot"), ]); // Retry loop: @@unique([shotId, versionNumber]) guards the version-increment // race between two concurrent E1 calls; loser recomputes and retries. for (let tryNo = 0; tryNo < 3; tryNo++) { try { return await db.$transaction(async (tx) => { const latest = await tx.export.aggregate({ where: { shotId: shot.id }, _max: { versionNumber: true }, }); const freshShot = await tx.shot.findUniqueOrThrow({ where: { id: shot.id }, select: { shotVersion: true, exrOutput: true, slateVfxScope: true, slateSubmissionNote: true }, }); // vfxScope: caller override → shot's slate field const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (freshShot.slateVfxScope ?? null); // submissionNote: caller override (per-export) → shot's slate default const submissionNote = input.submissionNote !== undefined ? input.submissionNote : (freshShot.slateSubmissionNote ?? null); // versionNumber is an internal sequence for DB uniqueness only; output files // use the shot's current (manually-set) shotVersion. const versionNumber = (latest._max.versionNumber ?? 0) + 1; const versionString = freshShot.shotVersion ?? formatVersionString(versionNumber); const exrBase = nextExrOutputBase(freshShot.exrOutput, shot.shotCode, versionString); const outputDir = manifest.outputDir === "auto" ? [outputRoot, shot.project.code, ...(shot.episode ? [shot.episode] : []), shot.shotCode, versionString].join("/") : manifest.outputDir; const outputPattern = manifest.outputPattern === "auto" ? `${renderOutputBase(shot.shotCode, versionString)}.[#####].exr` : manifest.outputPattern; const resolvedManifest: RenderManifest = { ...manifest, outputDir, outputPattern }; // Supersede older non-terminal exports (T1/T17); cancel their open jobs. const toSupersede = await tx.export.findMany({ where: { shotId: shot.id, status: { in: input.force ? [...SUPERSEDABLE_STATUSES, ...ACTIVE_STATUSES] : SUPERSEDABLE_STATUSES }, }, select: { id: true, status: true }, }); const created = await tx.export.create({ data: { shotId: shot.id, projectId: shot.project.id, taskId: shot.tasks[0]?.id ?? null, versionNumber, versionString, status: "QUEUED", aepPath: manifest.aepPath, compName: manifest.compName, rendererType: manifest.rendererType, outputDir, outputPattern, frameStart: manifest.frameStart, frameEnd: manifest.frameEnd, fps: manifest.fps, width: manifest.width, height: manifest.height, colorspace: manifest.expected?.colorspace ?? null, vfxScope, submissionNote, submittedById: submittedBy?.id ?? null, submittedByName: submittedBy?.name ?? input.submittedByEmail ?? null, }, }); for (const old of toSupersede) { await tx.renderJob.updateMany({ where: { exportId: old.id, status: { in: [...OPEN_JOB_STATUSES] } }, data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: `Superseded by export ${created.id} (${versionString})` }, }); await applyTransition( tx, old, "SUPERSEDED", { type: "SYSTEM", note: `Superseded by ${versionString}` }, { supersededById: created.id } ); } const renderJob = await tx.renderJob.create({ data: { type: "AE_RENDER", exportId: created.id, attempt: 1, maxAttempts, priority: input.priority ?? 50, manifest: resolvedManifest as unknown as Prisma.InputJsonValue, }, }); await tx.exportEvent.create({ data: { exportId: created.id, fromStatus: null, toStatus: "QUEUED", actorType: "USER", actorId: submittedBy?.id ?? null, note: `Queue Export ${versionString}${input.force ? " (force)" : ""}`, }, }); return { export: { id: created.id, shotCode: shot.shotCode, versionNumber, versionString, status: created.status, outputDir, outputPattern, vfxScope, submissionNote, }, renderJob: { id: renderJob.id, attempt: renderJob.attempt, priority: renderJob.priority }, shot: { id: shot.id, shotVersion: versionString, exrOutput: exrBase }, superseded: toSupersede.map((s) => s.id), }; }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" && tryNo < 2) { continue; // concurrent E1 won the version — recompute } throw err; } } throw new PipelineError(409, "Could not allocate a version number after retries"); } function serializeExport(e: T) { return { ...e, exrTotalBytes: e.exrTotalBytes == null ? null : Number(e.exrTotalBytes) }; } /** E2 — latest export + status for the AE panel header (§6.4). */ export async function getLatestExport(shotCode: string, projectCode?: string | null) { const shot = await db.shot.findFirst({ where: { shotCode, ...(projectCode ? { project: { code: projectCode } } : {}) }, select: { id: true }, }); if (!shot) throw new PipelineError(404, "Shot not found"); const exp = await db.export.findFirst({ where: { shotId: shot.id }, orderBy: { versionNumber: "desc" }, include: { renderJobs: { orderBy: { attempt: "desc" }, take: 1, include: { machine: { select: { name: true } } }, }, }, }); if (!exp) return { export: null }; const job = exp.renderJobs[0] ?? null; return { export: { id: exp.id, versionNumber: exp.versionNumber, versionString: exp.versionString, status: exp.status, statusChangedAt: exp.statusChangedAt, outputDir: exp.outputDir, outputPattern: exp.outputPattern, // Panel prefills its slate fields from these vfxScope: exp.vfxScope, submissionNote: exp.submissionNote, renderJob: job ? { id: job.id, attempt: job.attempt, status: job.status, progress: job.progress, currentFrame: job.currentFrame, totalFrames: job.totalFrames, etaSeconds: job.etaSeconds, errorMessage: job.errorMessage, machineName: job.machine?.name ?? null, } : null, previewUrl: exp.previewMovKey ? `/api/files/${exp.previewMovKey}` : null, thumbnailUrl: exp.thumbnailKey ? `/api/files/${exp.thumbnailKey}` : null, }, }; } /** E3 — export detail incl. attempts and audit events. */ export async function getExportDetail(exportId: string) { const exp = await db.export.findUnique({ where: { id: exportId }, include: { shot: { select: { id: true, shotCode: true, episode: true, project: { select: { id: true, name: true, code: true } } } }, renderJobs: { orderBy: { attempt: "asc" }, include: { machine: { select: { id: true, name: true } } }, }, events: { orderBy: { createdAt: "asc" } }, }, }); if (!exp) throw new PipelineError(404, "Export not found"); return { export: serializeExport(exp) }; } /** * E14 / E15 — retry a failed export: new RenderJob attempt, back to QUEUED. * PREVIEW_FAILED is routed to the preview-only retry (T11) so validated EXRs * are never re-rendered just to rebuild a slate. */ export async function retryExport(exportId: string, actor: TransitionActor) { const current = await db.export.findUnique({ where: { id: exportId }, select: { status: true } }); if (!current) throw new PipelineError(404, "Export not found"); if (current.status === "PREVIEW_FAILED") { const { retryPreview } = await import("./preview"); return retryPreview(exportId, actor.id ?? undefined); } return db.$transaction(async (tx) => { const exp = await tx.export.findUnique({ where: { id: exportId }, include: { renderJobs: { orderBy: { attempt: "desc" }, take: 1 } }, }); if (!exp) throw new PipelineError(404, "Export not found"); if (!["RENDER_FAILED", "VALIDATION_FAILED"].includes(exp.status)) { throw new PipelineError(409, `Export is ${exp.status} — only RENDER_FAILED, VALIDATION_FAILED or PREVIEW_FAILED exports can be retried`); } const last = exp.renderJobs[0]; if (!last) throw new PipelineError(500, "Export has no render jobs"); const job = await tx.renderJob.create({ data: { type: last.type, exportId: exp.id, attempt: last.attempt + 1, maxAttempts: last.maxAttempts, priority: last.priority, manifest: last.manifest as Prisma.InputJsonValue, }, }); const updated = await applyTransition(tx, exp, "QUEUED", { ...actor, note: actor.note ?? `Retry (attempt ${job.attempt})`, }); return { export: { id: updated.id, status: updated.status }, renderJob: { id: job.id, attempt: job.attempt } }; }); } /** E15 / T18 — cancel a queued or rendering export. Worker learns via heartbeat/progress. */ export async function cancelExport(exportId: string, actor: TransitionActor) { return db.$transaction(async (tx) => { const exp = await tx.export.findUnique({ where: { id: exportId }, select: { id: true, status: true } }); if (!exp) throw new PipelineError(404, "Export not found"); if (exp.status === "CANCELLED") return { export: exp, alreadyApplied: true }; if (!["QUEUED", "RENDERING"].includes(exp.status)) { throw new PipelineError(409, `Export is ${exp.status} — only QUEUED or RENDERING exports can be cancelled`); } await tx.renderJob.updateMany({ where: { exportId: exp.id, status: "QUEUED" }, data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Cancelled by user" }, }); // Claimed/running jobs stay marked CANCELLED but keep finishedAt null until // the worker acknowledges (heartbeat CANCEL_JOB command / progress cancelRequested) await tx.renderJob.updateMany({ where: { exportId: exp.id, status: { in: ["CLAIMED", "RUNNING"] } }, data: { status: "CANCELLED", errorMessage: "Cancelled by user" }, }); const updated = await applyTransition(tx, exp, "CANCELLED", actor); return { export: { id: updated.id, status: updated.status }, alreadyApplied: false }; }); } /** * Phase 1 stopgap (§17.1): admin "mark as done manually" so the studio can * keep using the old render path while the queue is validated. Removed once * workers render for real. */ export async function markExportDoneManually(exportId: string, userId: string, note?: string) { return db.$transaction(async (tx) => { const exp = await tx.export.findUnique({ where: { id: exportId }, select: { id: true, status: true } }); if (!exp) throw new PipelineError(404, "Export not found"); if (!["QUEUED", "RENDERING"].includes(exp.status)) { throw new PipelineError(409, `Export is ${exp.status} — only QUEUED or RENDERING exports can be marked done`); } await tx.renderJob.updateMany({ where: { exportId: exp.id, status: { in: [...OPEN_JOB_STATUSES] } }, data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Manually marked done (legacy render path)" }, }); const updated = await applyTransition(tx, exp, "READY_FOR_QC", { type: "USER", id: userId, note: note ?? "Manually marked done (rendered outside the pipeline)", }); return { export: { id: updated.id, status: updated.status } }; }); } export interface ListExportsFilter { projectId?: string; episode?: string; status?: ExportStatus; shotId?: string; page?: number; limit?: number; } /** E21 — paginated export list for the web queue/monitoring pages. */ export async function listExports(filter: ListExportsFilter) { const page = Math.max(1, filter.page ?? 1); const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); const where: Prisma.ExportWhereInput = { ...(filter.projectId ? { projectId: filter.projectId } : {}), ...(filter.status ? { status: filter.status } : {}), ...(filter.shotId ? { shotId: filter.shotId } : {}), ...(filter.episode ? { shot: { episode: filter.episode } } : {}), }; const [total, rows] = await Promise.all([ db.export.count({ where }), db.export.findMany({ where, orderBy: { createdAt: "desc" }, skip: (page - 1) * limit, take: limit, include: { shot: { select: { shotCode: true, episode: true, project: { select: { name: true, code: true } } } }, renderJobs: { orderBy: { attempt: "desc" }, take: 1, include: { machine: { select: { name: true } } }, }, }, }), ]); return { pagination: { page, limit, total, pages: Math.ceil(total / limit) }, exports: rows.map((e) => ({ id: e.id, shotCode: e.shot.shotCode, episode: e.shot.episode, projectName: e.shot.project.name, projectCode: e.shot.project.code, versionString: e.versionString, status: e.status, statusChangedAt: e.statusChangedAt, createdAt: e.createdAt, outputDir: e.outputDir, job: e.renderJobs[0] ? { id: e.renderJobs[0].id, attempt: e.renderJobs[0].attempt, status: e.renderJobs[0].status, progress: e.renderJobs[0].progress, currentFrame: e.renderJobs[0].currentFrame, totalFrames: e.renderJobs[0].totalFrames, etaSeconds: e.renderJobs[0].etaSeconds, priority: e.renderJobs[0].priority, machineName: e.renderJobs[0].machine?.name ?? null, errorMessage: e.renderJobs[0].errorMessage, } : null, })), }; }