Files
vfxreview/lib/render-pipeline/exports.ts
T
twotalesanimation 7caf41e17c
Deploy / deploy (push) Successful in 6m17s
Slates
2026-08-07 07:34:10 +02:00

494 lines
19 KiB
TypeScript

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<T extends { exrTotalBytes: bigint | null }>(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,
})),
};
}