Files
vfxreview/lib/render-pipeline/exports.ts
twotalesanimation 6678721fb1
Deploy / deploy (push) Successful in 6m48s
fix(pipeline): create the comp task an export needs when a shot has none
A shot with no COMP task rendered and previewed fine and then showed
nothing in the app. createExport set Export.taskId from the shot's first
COMP task or null, and finalizePreview registered the review Version with
that same null taskId — so the MP4 was uploaded and stored, but the
Version hung off no task and none of the review flows had anywhere to
draw it. From the artist's side the export completed and produced
nothing.

E1 now resolves the shot's comp task inside the version-allocation
transaction and creates one when there isn't any, titled after the
preview file the export will produce: {shotCode}_cmp_TT_{version}, the
same convention the manual batch upload's create-task path already uses.

renderOutputBase() is exported and buildPreviewManifest takes its `base`
from it, so the task title and the MOV/MP4 name come from one function
rather than two copies of the same template. The title therefore already
carries the version, which makes finalizePreview's rename-to-the-
delivered-version a no-op on that first export rather than an immediate
rename of a task created moments earlier.

The lookup and the create sit inside the transaction so the P2002
version-collision retry rolls a losing attempt's task back with
everything else, instead of leaving a duplicate behind.

recalcShotStatus runs only when a task was actually created: a shot with
no tasks derives WAITING and now has one. Approval-driven statuses still
take precedence, so an already-approved shot stays COMPLETE.

Both the queue-time ExportEvent note and the E1 response record what
happened, so a task appearing under a shot on its own is traceable to the
export that made it.

Exports already in flight with a null taskId keep their orphaned
versions; backfilling those is a separate migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:09:01 +02:00

574 lines
22 KiB
TypeScript

import { ExportStatus, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import { describePlateProvenance, renderManifestSchema, type RenderManifest } from "./manifest";
import { applyTransition, type TransitionActor } from "./transitions";
import { getConfigNumber, getConfigValue } from "./config";
import { recalcShotStatus } from "@/lib/shot-status";
/** 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.
*/
export 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;
/**
* Render the EXRs but build no preview, inheriting the previous export's
* review media. For deliverables framed differently from the review — an
* unscaled or un-punched-in export — rebuilding the preview from these
* frames would produce the wrong MOV.
*/
skipPreview?: boolean;
}
/**
* 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, projectType: 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);
// Every Version the pipeline registers hangs off a Task, so a shot with
// no comp task renders fine and then has its preview land nowhere the
// app shows it — the export completes and the artist sees nothing.
// Give the export the task it needs, named after the preview file it
// will produce (the same name the manual batch upload picks, and the
// one finalizePreview's rename keeps in step from here on).
//
// The lookup is repeated inside the transaction because `shot` was read
// before it opened, and because the P2002 retry above re-runs this
// block: a rolled-back attempt takes its task with it.
const existingTask = await tx.task.findFirst({
where: { shotId: shot.id, type: "COMP" },
orderBy: { sortOrder: "asc" },
select: { id: true },
});
let taskId = existingTask?.id ?? null;
let taskCreated = false;
if (!taskId) {
const lastTask = await tx.task.findFirst({
where: { shotId: shot.id },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const task = await tx.task.create({
data: {
title: renderOutputBase(shot.shotCode, versionString),
type: "COMP",
shotId: shot.id,
projectId: shot.project.id,
createdById: submittedBy?.id ?? null,
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
},
});
taskId = task.id;
taskCreated = true;
}
// {root}/{showId}/[{episode} for episodic]/{shotCode}/{version}
const showSegment = shot.project.showId || shot.project.code;
const episodeSegment =
shot.project.projectType === "EPISODIC" && shot.episode?.trim() ? shot.episode.trim() : null;
const outputDir =
manifest.outputDir === "auto"
? [outputRoot, showSegment, episodeSegment, shot.shotCode, versionString]
.filter(Boolean)
.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,
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,
skipPreview: input.skipPreview === true,
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,
},
});
// Plate provenance is recorded at queue time so a suspect export is
// visible in the queue before it renders (§8.1a).
const provenance = describePlateProvenance(resolvedManifest);
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)" : ""}` +
`${input.skipPreview ? " (skip preview)" : ""} — ` +
`${provenance.ok ? "" : "WARNING: "}${provenance.note}` +
(taskCreated
? ` — shot had no comp task, created "${renderOutputBase(shot.shotCode, versionString)}" for the review version`
: ""),
},
});
if (taskCreated) {
await recalcShotStatus(shot.id, tx);
}
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 },
task: { id: taskId, created: taskCreated },
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" } },
validations: { orderBy: { createdAt: "asc" } },
qcReviews: { orderBy: { createdAt: "desc" } },
deliveryItems: {
include: {
package: { select: { id: true, name: true, episode: true, status: true, packageNumber: true } },
},
},
},
});
if (!exp) throw new PipelineError(404, "Export not found");
return {
export: {
...serializeExport(exp),
deliveryItems: exp.deliveryItems.map((i) => ({ ...i, totalBytes: i.totalBytes == null ? null : Number(i.totalBytes) })),
},
};
}
/**
* 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,
// Lets the shot's Exports tab offer this export's frame as the shot
// thumbnail (§9.1a) without a second round trip per row.
thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null,
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,
})),
};
}