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>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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 },
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user