Files
vfxreview/lib/render-pipeline/preview.ts
T
twotalesanimation cc89415a29 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00

296 lines
9.6 KiB
TypeScript

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