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:
twotalesanimation
2026-08-02 14:34:34 +02:00
parent 0d0f3e1a33
commit ae58dc0366
71 changed files with 11767 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
import { db } from "@/lib/db";
/**
* Pipeline-wide settings live in the existing SystemConfig key/value table
* (RenderPipeline2 §14.1) so the fleet can be tuned without redeploys.
*/
export const PIPELINE_CONFIG_DEFAULTS = {
"render.pollSeconds": 10,
"render.heartbeatSeconds": 30,
"render.leaseSeconds": 300,
"render.maxAttempts": 3,
"render.stallTimeoutSeconds": 600,
"render.urgentPriorityThreshold": 20,
"render.outputRoot": "//SAN/renders",
"validation.sampleEvery": 25,
"preview.maxWidth": 1920,
// Preview stage (§9): headless AE rebuild of the render with slate/burn-ins
"preview.enabled": "true",
"preview.templateAep": "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep",
"preview.templateComp": "UNG_EXPORT_TEMPLATE",
"preview.overlayComp": "UNG_VFX_OVERLAY",
"preview.lutComp": "_SHOW LUT",
"preview.movTemplate": "4444 Tri",
"preview.mp4Template": "REVIEW_PREVIEW",
// Essential Property names on the NETFLIX_SLATE layer for the per-submission
// fields. If a name is wrong the build logs the template's actual property
// names as a warning, so it is a config fix rather than a code change.
"preview.slateScopeProp": "Scope",
"preview.slateSubmissionProp": "Notes",
} as const;
export type PipelineConfigKey = keyof typeof PIPELINE_CONFIG_DEFAULTS;
export async function getConfigValue(key: PipelineConfigKey): Promise<string> {
const row = await db.systemConfig.findUnique({ where: { key } });
return row?.value ?? String(PIPELINE_CONFIG_DEFAULTS[key]);
}
export async function getConfigNumber(key: PipelineConfigKey): Promise<number> {
const raw = await getConfigValue(key);
const n = Number(raw);
return Number.isFinite(n) ? n : Number(PIPELINE_CONFIG_DEFAULTS[key]);
}
export async function getConfigBoolean(key: PipelineConfigKey): Promise<boolean> {
const raw = (await getConfigValue(key)).trim().toLowerCase();
return raw === "true" || raw === "1" || raw === "yes";
}
/** Preview-stage settings handed to the worker inside the PREVIEW_ONLY manifest. */
export async function getPreviewConfig() {
const [
enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template,
slateScopeProp, slateSubmissionProp,
] = await Promise.all([
getConfigBoolean("preview.enabled"),
getConfigValue("preview.templateAep"),
getConfigValue("preview.templateComp"),
getConfigValue("preview.overlayComp"),
getConfigValue("preview.lutComp"),
getConfigValue("preview.movTemplate"),
getConfigValue("preview.mp4Template"),
getConfigValue("preview.slateScopeProp"),
getConfigValue("preview.slateSubmissionProp"),
]);
return {
enabled, templateAep, templateComp, overlayComp, lutComp, movTemplate, mp4Template,
slateScopeProp, slateSubmissionProp,
};
}
/** Worker-facing config bundle returned by E6 registration. */
export async function getWorkerConfig() {
const [pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds] =
await Promise.all([
getConfigNumber("render.pollSeconds"),
getConfigNumber("render.heartbeatSeconds"),
getConfigNumber("render.leaseSeconds"),
getConfigNumber("render.maxAttempts"),
getConfigNumber("render.stallTimeoutSeconds"),
]);
return { pollSeconds, heartbeatSeconds, leaseSeconds, maxAttempts, stallTimeoutSeconds };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Typed service-layer error carrying an HTTP status so both auth fronts
* (ext API-key routes and internal session routes) map errors identically.
*/
export class PipelineError extends Error {
status: number;
details?: unknown;
constructor(status: number, message: string, details?: unknown) {
super(message);
this.name = "PipelineError";
this.status = status;
this.details = details;
}
}
export function toErrorResponse(err: unknown): { body: { error: string; details?: unknown }; status: number } {
if (err instanceof PipelineError) {
return {
body: { error: err.message, ...(err.details !== undefined ? { details: err.details } : {}) },
status: err.status,
};
}
console.error("[render-pipeline]", err);
return { body: { error: "Internal server error" }, status: 500 };
}
+504
View File
@@ -0,0 +1,504 @@
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 },
});
// Slate fields carry forward from the shot's previous submission
// unless the caller supplies new ones.
const previous = await tx.export.findFirst({
where: { shotId: shot.id },
orderBy: { versionNumber: "desc" },
select: { vfxScope: true, submissionNote: true },
});
const vfxScope = input.vfxScope !== undefined ? input.vfxScope : (previous?.vfxScope ?? null);
const submissionNote =
input.submissionNote !== undefined ? input.submissionNote : (previous?.submissionNote ?? null);
const versionNumber =
Math.max(latest._max.versionNumber ?? 0, parseVersionString(freshShot.shotVersion)) + 1;
const versionString = 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)" : ""}`,
},
});
// Mirror the legacy panel PATCH: Shot stays the canonical "current version"
const updatedShot = await tx.shot.update({
where: { id: shot.id },
data: { shotVersion: versionString, exrOutput: exrBase },
select: { id: true, shotVersion: true, exrOutput: true },
});
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: updatedShot,
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,
})),
};
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
/**
* Shared API-key auth for /api/ext/* pipeline routes — same contract as the
* existing ext endpoints (Authorization: Bearer <key> or X-Api-Key header).
* One shared API_SECRET_KEY for now (studio decision 18.1-Q7).
*/
export function isExtAuthorized(req: NextRequest): boolean {
const apiKey = process.env.API_SECRET_KEY;
if (!apiKey) return false;
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey;
return (req.headers.get("x-api-key") ?? "") === apiKey;
}
+317
View File
@@ -0,0 +1,317 @@
import { Prisma, RenderJobType } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import { applyTransition } from "./transitions";
import { getConfigBoolean, getConfigNumber } from "./config";
import { machineCanClaim } from "./machines";
import { reapExpiredLeases } from "./reaper";
import { buildPreviewManifest, createPreviewJob } from "./preview";
const JOB_TYPES: RenderJobType[] = ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"];
/**
* E8 — atomically claim the next queued job (§6.4). `FOR UPDATE SKIP LOCKED`
* makes double-claims impossible under concurrent workers. All scheduling
* policy (availability windows, Render Now, urgent priority) is enforced
* here server-side — workers poll dumbly (§7.10).
*/
export async function claimNextJob(machineId: string, types?: string[]) {
const machine = await db.machine.findUnique({ where: { id: machineId } });
if (!machine) throw new PipelineError(404, "Machine not found — register first (POST /api/ext/workers/register)");
// Defensive sweep on each claim (studio decision 18.1-Q8)
await reapExpiredLeases().catch((err) => console.error("[render-pipeline] claim-time reap failed", err));
const claimTypes = (types?.length ? types : ["AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD"]).filter(
(t): t is RenderJobType => (JOB_TYPES as string[]).includes(t)
);
if (claimTypes.length === 0) throw new PipelineError(422, "No valid job types requested");
const gate = machineCanClaim(machine, new Date());
if (gate === "none") return null;
const [leaseSeconds, urgentThreshold] = await Promise.all([
getConfigNumber("render.leaseSeconds"),
getConfigNumber("render.urgentPriorityThreshold"),
]);
const urgentFilter =
gate === "urgent-only" ? Prisma.sql`AND priority <= ${urgentThreshold}` : Prisma.empty;
const claimed = await db.$queryRaw<{ id: string }[]>(Prisma.sql`
UPDATE "render_jobs"
SET status = 'CLAIMED',
"machineId" = ${machineId},
"claimedAt" = now(),
"leaseExpiresAt" = now() + (${leaseSeconds}::int * interval '1 second'),
"updatedAt" = now()
WHERE id = (
SELECT id FROM "render_jobs"
WHERE status = 'QUEUED'
AND type::text = ANY(${claimTypes}::text[])
${urgentFilter}
ORDER BY priority ASC, "createdAt" ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id
`);
if (claimed.length === 0) return null;
const job = await db.renderJob.findUniqueOrThrow({
where: { id: claimed[0].id },
include: { export: { select: { id: true, status: true, versionString: true } } },
});
// T2: QUEUED → RENDERING
if (job.export && job.export.status === "QUEUED") {
await db.$transaction(async (tx) => {
await applyTransition(tx, job.export!, "RENDERING", {
type: "WORKER",
id: machineId,
note: `Claimed by ${machine.name} (attempt ${job.attempt})`,
});
});
}
return {
job: {
id: job.id,
type: job.type,
attempt: job.attempt,
maxAttempts: job.maxAttempts,
priority: job.priority,
exportId: job.exportId,
deliveryId: job.deliveryId,
leaseExpiresAt: job.leaseExpiresAt,
manifest: job.manifest,
},
};
}
async function getJobForReport(jobId: string, machineId: string) {
const job = await db.renderJob.findUnique({
where: { id: jobId },
include: { export: { select: { id: true, status: true } } },
});
if (!job) throw new PipelineError(404, "Render job not found");
// Zombie-worker guard (§7.4): reports from a machine that doesn't hold the lease are rejected
if (job.machineId !== machineId) {
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
}
return job;
}
/** E9 — progress report; renews the lease. */
export async function reportProgress(
jobId: string,
machineId: string,
body: { progress?: number; currentFrame?: number; totalFrames?: number; etaSeconds?: number; logTail?: string }
) {
const job = await getJobForReport(jobId, machineId);
if (job.status === "CANCELLED") {
return { ok: true, cancelRequested: true, leaseExpiresAt: job.leaseExpiresAt };
}
if (["COMPLETED", "FAILED", "EXPIRED"].includes(job.status)) {
return { ok: true, alreadyApplied: true, cancelRequested: false, leaseExpiresAt: job.leaseExpiresAt };
}
const leaseSeconds = await getConfigNumber("render.leaseSeconds");
const leaseExpiresAt = new Date(Date.now() + leaseSeconds * 1000);
await db.renderJob.update({
where: { id: job.id },
data: {
status: "RUNNING",
startedAt: job.startedAt ?? new Date(),
leaseExpiresAt,
...(body.progress != null ? { progress: Math.min(1, Math.max(0, body.progress)) } : {}),
...(body.currentFrame != null ? { currentFrame: body.currentFrame } : {}),
...(body.totalFrames != null ? { totalFrames: body.totalFrames } : {}),
...(body.etaSeconds != null ? { etaSeconds: body.etaSeconds } : {}),
...(body.logTail != null ? { logTail: body.logTail.slice(-20000) } : {}),
},
});
return { ok: true, leaseExpiresAt, cancelRequested: false };
}
/** E10 — failure report. Auto-requeues while attempts remain and the error is retryable. */
export async function reportFail(
jobId: string,
machineId: string,
body: {
stage?: string;
exitCode?: number;
errorMessage?: string;
logTail?: string;
logFileKey?: string;
retryable?: boolean;
}
) {
return db.$transaction(async (tx) => {
const job = await tx.renderJob.findUnique({
where: { id: jobId },
include: { export: { select: { id: true, status: 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?)");
if (job.status === "FAILED") {
return { job: { id: job.id, status: job.status }, export: job.export ? { id: job.export.id, status: job.export.status } : null, autoRequeued: false, alreadyApplied: true };
}
if (job.status === "CANCELLED") {
// worker acknowledging a cancel — record the wind-down, stay CANCELLED
await tx.renderJob.update({ where: { id: job.id }, data: { finishedAt: job.finishedAt ?? new Date(), logTail: body.logTail?.slice(-20000) ?? job.logTail } });
return { job: { id: job.id, status: "CANCELLED" }, export: job.export ? { id: job.export.id, status: job.export.status } : null, autoRequeued: false, alreadyApplied: true };
}
if (job.status === "COMPLETED") {
throw new PipelineError(409, "Job already reported complete — contradictory fail report rejected");
}
await tx.renderJob.update({
where: { id: job.id },
data: {
status: "FAILED",
finishedAt: new Date(),
exitCode: body.exitCode ?? null,
errorMessage: body.errorMessage?.slice(0, 2000) ?? null,
logTail: body.logTail?.slice(-20000) ?? job.logTail,
logFileKey: body.logFileKey ?? job.logFileKey,
},
});
if (!job.export) {
return { job: { id: job.id, status: "FAILED" }, export: null, autoRequeued: false };
}
const retryable = body.retryable !== false;
const autoRequeue = retryable && job.attempt < job.maxAttempts;
const isPreview = job.type === "PREVIEW_ONLY";
if (autoRequeue) {
const next = await tx.renderJob.create({
data: {
type: job.type,
exportId: job.exportId,
attempt: job.attempt + 1,
maxAttempts: job.maxAttempts,
priority: job.priority,
manifest: job.manifest as Prisma.InputJsonValue,
},
});
const note = `${body.stage ?? "RENDER"} failed (${body.errorMessage ?? "no message"}) — auto-requeued as attempt ${next.attempt}`;
if (isPreview) {
// The export stays in GENERATING_PREVIEW across preview attempts —
// the EXRs are untouched, only the preview build is repeated.
await tx.exportEvent.create({
data: {
exportId: job.export.id,
fromStatus: job.export.status,
toStatus: job.export.status,
actorType: "SYSTEM",
actorId: machineId,
note,
},
});
return { job: { id: job.id, status: "FAILED" }, export: { id: job.export.id, status: job.export.status }, autoRequeued: true, nextJob: { id: next.id, attempt: next.attempt } };
}
const exp = await applyTransition(tx, job.export, "QUEUED", { type: "SYSTEM", id: machineId, note });
return { job: { id: job.id, status: "FAILED" }, export: { id: exp.id, status: exp.status }, autoRequeued: true, nextJob: { id: next.id, attempt: next.attempt } };
}
const exp = await applyTransition(tx, job.export, isPreview ? "PREVIEW_FAILED" : "RENDER_FAILED", {
type: "WORKER",
id: machineId,
note: `${body.stage ?? "RENDER"} failed: ${body.errorMessage ?? "no message"}${retryable ? " (max attempts reached)" : " (not retryable)"}`,
});
return { job: { id: job.id, status: "FAILED" }, export: { id: exp.id, status: exp.status }, autoRequeued: false };
});
}
/**
* E11 — aerender finished OK.
* Phase 2 interim (§17.3): transitions straight to a provisional READY_FOR_QC;
* Phase 3 will insert VALIDATING between (see transitions.ts note).
*/
export async function completeRender(
jobId: string,
machineId: string,
body: { renderSeconds?: number; logFileKey?: string; logTail?: string; exrFileCount?: number; exrTotalBytes?: number }
) {
return db.$transaction(async (tx) => {
const job = await tx.renderJob.findUnique({
where: { id: jobId },
include: { export: { select: { id: true, status: 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?)");
if (job.status === "COMPLETED") {
return { job: { id: job.id, status: job.status }, export: job.export ? { id: job.export.id, status: job.export.status } : null, alreadyApplied: true };
}
if (["FAILED", "CANCELLED", "EXPIRED"].includes(job.status)) {
throw new PipelineError(409, `Job is ${job.status} — contradictory complete report rejected`);
}
await tx.renderJob.update({
where: { id: job.id },
data: {
status: "COMPLETED",
progress: 1,
finishedAt: new Date(),
exitCode: 0,
logFileKey: body.logFileKey ?? job.logFileKey,
logTail: body.logTail?.slice(-20000) ?? job.logTail,
},
});
let exportResult: { id: string; status: string } | null = null;
let previewJob: { id: string; type: string } | null = null;
if (job.export) {
const exportRow = await tx.export.findUniqueOrThrow({ where: { id: job.export.id } });
const stats = {
...(body.exrFileCount != null ? { exrFileCount: body.exrFileCount } : {}),
...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
};
// The EXR render is only the first stage: hand off to the preview stage
// (headless AE rebuild → delivery MOV + review MP4) when it is enabled.
const previewEnabled = await getConfigBoolean("preview.enabled");
if (previewEnabled) {
const manifest = await buildPreviewManifest(exportRow);
const created = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority);
previewJob = { id: created.id, type: created.type };
const exp = await applyTransition(tx, job.export, "GENERATING_PREVIEW", {
type: "WORKER",
id: machineId,
note: `Render complete in ${body.renderSeconds ?? "?"}s — queued preview build`,
}, stats);
exportResult = { id: exp.id, status: exp.status };
} else {
const exp = await applyTransition(tx, job.export, "READY_FOR_QC", {
type: "WORKER",
id: machineId,
note: `Render complete in ${body.renderSeconds ?? "?"}s (preview stage disabled)`,
}, stats);
exportResult = { id: exp.id, status: exp.status };
}
}
return { job: { id: job.id, status: "COMPLETED" }, export: exportResult, previewJob, alreadyApplied: false };
});
}
/** E15 — cancel a single job from the web UI (delegates to export-level cancel when linked). */
export async function getJobDetail(jobId: string) {
const job = await db.renderJob.findUnique({
where: { id: jobId },
include: {
machine: { select: { id: true, name: true } },
export: { include: { shot: { select: { shotCode: true } } } },
},
});
if (!job) throw new PipelineError(404, "Render job not found");
return job;
}
+240
View File
@@ -0,0 +1,240 @@
import { Machine, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import { getWorkerConfig } from "./config";
/**
* Machine availability (§7.10):
* ALWAYS — claims whenever idle (default when availability is null)
* SCHEDULE — claims only inside configured windows
* MANUAL — never claims unless explicitly triggered
* Overrides, in priority order: enabled=false beats everything; renderNowUntil
* allows normal claims until it expires; urgent jobs may claim anytime on
* machines with allowUrgentAnytime.
*/
export interface AvailabilityWindow {
days: string[]; // ["mon", ..., "sun"]
from: string; // "19:00"
to: string; // "08:00" — from > to spans midnight
}
export interface MachineAvailability {
mode?: "ALWAYS" | "SCHEDULE" | "MANUAL";
windows?: AvailabilityWindow[];
allowUrgentAnytime?: boolean;
}
const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
function parseHm(hm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hm);
if (!m) return null;
const mins = parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
return mins >= 0 && mins <= 24 * 60 ? mins : null;
}
export function isInWindow(windows: AvailabilityWindow[], now: Date): boolean {
const day = DAY_NAMES[now.getDay()];
const prevDay = DAY_NAMES[(now.getDay() + 6) % 7];
const t = now.getHours() * 60 + now.getMinutes();
for (const w of windows) {
const from = parseHm(w.from);
const to = parseHm(w.to);
if (from == null || to == null || !Array.isArray(w.days)) continue;
const days = w.days.map((d) => d.toLowerCase().slice(0, 3));
if (from < to) {
if (days.includes(day) && t >= from && t < to) return true;
} else if (from > to) {
// overnight window, e.g. 19:00 → 08:00
if (days.includes(day) && t >= from) return true;
if (days.includes(prevDay) && t < to) return true;
} else {
// from === to → full 24 h on listed days
if (days.includes(day)) return true;
}
}
return false;
}
export type ClaimGate = "normal" | "urgent-only" | "none";
export function machineCanClaim(machine: Machine, now: Date): ClaimGate {
if (!machine.enabled) return "none";
if (machine.renderNowUntil && machine.renderNowUntil > now) return "normal";
const availability = (machine.availability ?? null) as MachineAvailability | null;
const mode = availability?.mode ?? "ALWAYS";
const urgent = availability?.allowUrgentAnytime ? ("urgent-only" as const) : ("none" as const);
switch (mode) {
case "ALWAYS":
return "normal";
case "SCHEDULE":
return isInWindow(availability?.windows ?? [], now) ? "normal" : urgent;
case "MANUAL":
return urgent;
default:
return "normal";
}
}
/** E6 — idempotent register/upsert on machine name. */
export async function registerMachine(body: {
name?: string;
hostname?: string;
workerVersion?: string;
aeVersion?: string;
capabilities?: unknown;
}) {
const name = body.name?.trim();
if (!name) throw new PipelineError(422, "name is required");
const hostname = body.hostname?.trim() || name;
const machine = await db.machine.upsert({
where: { name },
create: {
name,
hostname,
status: "ONLINE",
lastSeenAt: new Date(),
workerVersion: body.workerVersion ?? null,
aeVersion: body.aeVersion ?? null,
capabilities: (body.capabilities ?? undefined) as Prisma.InputJsonValue | undefined,
},
update: {
hostname,
status: "ONLINE",
lastSeenAt: new Date(),
workerVersion: body.workerVersion ?? undefined,
aeVersion: body.aeVersion ?? undefined,
capabilities: (body.capabilities ?? undefined) as Prisma.InputJsonValue | undefined,
},
});
return {
machine: { id: machine.id, name: machine.name, enabled: machine.enabled },
config: await getWorkerConfig(),
};
}
/** E7 — heartbeat; also the server→worker command channel (cancel signals). */
export async function recordHeartbeat(
machineId: string,
body: { cpuPercent?: number; memPercent?: number; diskFreeGb?: number; currentJobId?: string | null }
) {
const machine = await db.machine.findUnique({ where: { id: machineId } });
if (!machine) throw new PipelineError(404, "Machine not found");
await db.$transaction([
db.machine.update({
where: { id: machineId },
data: { lastSeenAt: new Date(), status: machine.enabled ? "ONLINE" : "DISABLED" },
}),
db.workerHeartbeat.create({
data: {
machineId,
cpuPercent: body.cpuPercent ?? null,
memPercent: body.memPercent ?? null,
diskFreeGb: body.diskFreeGb ?? null,
currentJobId: body.currentJobId ?? null,
},
}),
]);
// Cancelled-but-unacknowledged jobs held by this machine → CANCEL_JOB commands
const cancelled = await db.renderJob.findMany({
where: { machineId, status: "CANCELLED", finishedAt: null },
select: { id: true },
});
return {
ok: true,
commands: cancelled.map((j) => ({ type: "CANCEL_JOB" as const, jobId: j.id })),
};
}
/** Machine list for the monitoring page, with latest heartbeat + current job. */
export async function listMachines() {
const heartbeatSeconds = 30;
const machines = await db.machine.findMany({
orderBy: { name: "asc" },
include: {
heartbeats: { orderBy: { createdAt: "desc" }, take: 1 },
renderJobs: {
where: { status: { in: ["CLAIMED", "RUNNING"] } },
take: 1,
include: { export: { select: { id: true, versionString: true, shot: { select: { shotCode: true } } } } },
},
},
});
const now = Date.now();
return machines.map((m) => ({
id: m.id,
name: m.name,
hostname: m.hostname,
enabled: m.enabled,
// ONLINE = heartbeat within 3× the heartbeat interval (§5.6)
status: !m.enabled
? "DISABLED"
: m.lastSeenAt && now - m.lastSeenAt.getTime() < heartbeatSeconds * 3 * 1000
? "ONLINE"
: "OFFLINE",
lastSeenAt: m.lastSeenAt,
workerVersion: m.workerVersion,
aeVersion: m.aeVersion,
capabilities: m.capabilities,
availability: m.availability,
renderNowUntil: m.renderNowUntil,
latestHeartbeat: m.heartbeats[0]
? {
createdAt: m.heartbeats[0].createdAt,
cpuPercent: m.heartbeats[0].cpuPercent,
memPercent: m.heartbeats[0].memPercent,
diskFreeGb: m.heartbeats[0].diskFreeGb,
}
: null,
currentJob: m.renderJobs[0]
? {
id: m.renderJobs[0].id,
exportId: m.renderJobs[0].exportId,
shotCode: m.renderJobs[0].export?.shot.shotCode ?? null,
versionString: m.renderJobs[0].export?.versionString ?? null,
progress: m.renderJobs[0].progress,
etaSeconds: m.renderJobs[0].etaSeconds,
}
: null,
}));
}
export async function updateMachine(
machineId: string,
patch: { enabled?: boolean; availability?: MachineAvailability | null; renderNowHours?: number | null }
) {
const machine = await db.machine.findUnique({ where: { id: machineId } });
if (!machine) throw new PipelineError(404, "Machine not found");
const data: Prisma.MachineUpdateInput = {};
if (patch.enabled !== undefined) {
data.enabled = patch.enabled;
data.status = patch.enabled ? machine.status === "DISABLED" ? "OFFLINE" : machine.status : "DISABLED";
}
if (patch.availability !== undefined) {
data.availability = patch.availability === null ? Prisma.DbNull : (patch.availability as unknown as Prisma.InputJsonValue);
}
if (patch.renderNowHours !== undefined) {
data.renderNowUntil =
patch.renderNowHours === null || patch.renderNowHours <= 0
? null
: new Date(Date.now() + patch.renderNowHours * 3600 * 1000);
}
const updated = await db.machine.update({ where: { id: machineId }, data });
return {
machine: {
id: updated.id,
name: updated.name,
enabled: updated.enabled,
availability: updated.availability,
renderNowUntil: updated.renderNowUntil,
},
};
}
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod";
/**
* The Render Manifest (RenderPipeline2 §6.2).
* Uploaded by the AE panel on Queue Export, snapshotted verbatim into
* RenderJob.manifest, handed to the worker on claim.
*
* `outputDir` / `outputPattern` may be the literal string "auto" — the server
* generates them from the shot's exrOutput convention and the NEW version
* number it decides (§6.4 E1).
*/
export const renderManifestSchema = z
.object({
manifestVersion: z.number().int().min(1).default(1),
projectCode: z.string().min(1),
shotCode: z.string().regex(/^[A-Za-z0-9_-]+$/, "shotCode contains invalid characters"),
shotId: z.string().min(1),
aepPath: z.string().min(1),
compName: z.string().min(1),
rendererType: z.string().default("aerender"),
aeVersionHint: z.string().optional(),
outputDir: z.string().min(1),
outputPattern: z.string().min(1),
outputModuleTemplate: z.string().optional(),
renderSettingsTemplate: z.string().optional(),
frameStart: z.number().int(),
frameEnd: z.number().int(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
expected: z
.object({
colorspace: z.string().optional(),
bitDepth: z.string().optional(),
exrCompression: z.string().optional(),
alpha: z.boolean().optional(),
timecodeStart: z.string().optional(),
})
.optional(),
preview: z
.object({
template: z.string().optional(),
burnins: z.boolean().optional(),
})
.optional(),
})
.refine((m) => m.frameEnd >= m.frameStart, {
message: "frameEnd must be >= frameStart",
path: ["frameEnd"],
});
export type RenderManifest = z.infer<typeof renderManifestSchema>;
+295
View File
@@ -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 },
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import { Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { applyTransition } from "./transitions";
import { getConfigNumber } from "./config";
/**
* Server-side reaper (RenderPipeline2 §7.7) — the only background job the web
* app gains. Covers worker power loss, BSOD and network partition with no
* worker cooperation:
* 1. expired leases → job EXPIRED; requeue (T5) or Export → RENDER_FAILED (T4)
* 2. silent machines → OFFLINE
* 3. prune heartbeats older than 7 days
*/
export async function reapExpiredLeases() {
const expired = await db.renderJob.findMany({
where: { status: { in: ["CLAIMED", "RUNNING"] }, leaseExpiresAt: { lt: new Date() } },
include: { export: { select: { id: true, status: true } } },
});
for (const job of expired) {
try {
await db.$transaction(async (tx) => {
// Re-check inside the transaction — the worker may have reported in between
const fresh = await tx.renderJob.findUnique({ where: { id: job.id }, select: { status: true, leaseExpiresAt: true } });
if (!fresh || !["CLAIMED", "RUNNING"].includes(fresh.status) || !fresh.leaseExpiresAt || fresh.leaseExpiresAt >= new Date()) {
return;
}
await tx.renderJob.update({
where: { id: job.id },
data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" },
});
if (!job.export) return;
if (job.attempt < job.maxAttempts) {
const next = await tx.renderJob.create({
data: {
type: job.type,
exportId: job.exportId,
attempt: job.attempt + 1,
maxAttempts: job.maxAttempts,
priority: job.priority,
manifest: job.manifest as Prisma.InputJsonValue,
},
});
if (job.export.status === "RENDERING") {
await applyTransition(tx, job.export, "QUEUED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt} — requeued as attempt ${next.attempt}`,
});
}
} else if (job.export.status === "RENDERING") {
await applyTransition(tx, job.export, "RENDER_FAILED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} — no retries remain`,
});
}
});
} catch (err) {
console.error(`[render-pipeline] reaper failed for job ${job.id}`, err);
}
}
return expired.length;
}
export async function reaperSweep() {
const heartbeatSeconds = await getConfigNumber("render.heartbeatSeconds");
await reapExpiredLeases();
await db.machine.updateMany({
where: {
status: "ONLINE",
OR: [{ lastSeenAt: null }, { lastSeenAt: { lt: new Date(Date.now() - heartbeatSeconds * 3 * 1000) } }],
},
data: { status: "OFFLINE" },
});
await db.workerHeartbeat.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - 7 * 24 * 3600 * 1000) } },
});
}
const globalForReaper = globalThis as unknown as { renderPipelineReaper?: ReturnType<typeof setInterval> };
/** Started once from instrumentation.ts (Next.js server startup hook). */
export function startReaper(intervalMs = 60_000) {
if (globalForReaper.renderPipelineReaper) return;
globalForReaper.renderPipelineReaper = setInterval(() => {
reaperSweep().catch((err) => console.error("[render-pipeline] reaper sweep failed", err));
}, intervalMs);
// Don't hold the process open on shutdown
globalForReaper.renderPipelineReaper.unref?.();
console.log("[render-pipeline] reaper started (interval", intervalMs, "ms)");
}
+22
View File
@@ -0,0 +1,22 @@
import { auth } from "@/auth";
import { PipelineError } from "./errors";
export type SessionUser = { id: string; role: string; name?: string | null; email?: string | null };
/** Any signed-in studio user (clients never see pipeline pages). */
export async function requirePipelineUser(): Promise<SessionUser> {
const session = await auth();
const user = session?.user as (SessionUser & { role?: string }) | undefined;
if (!user?.id) throw new PipelineError(401, "Unauthorized");
if (user.role === "CLIENT") throw new PipelineError(403, "Forbidden");
return user as SessionUser;
}
/** Admin-level pipeline actions (machine kill-switch, manual mark-done). */
export async function requirePipelineAdmin(): Promise<SessionUser> {
const user = await requirePipelineUser();
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(user.role)) {
throw new PipelineError(403, "Forbidden — requires admin/producer/supervisor role");
}
return user;
}
+77
View File
@@ -0,0 +1,77 @@
import { ExportStatus, Prisma } from "@prisma/client";
import { PipelineError } from "./errors";
/**
* Export lifecycle state machine (RenderPipeline2 §4).
* The server is the sole authority on state; workers *request* transitions.
*
* NOTE (Phase 2 interim): RENDERING → READY_FOR_QC is temporarily legal.
* Phase 3 inserts VALIDATING between them (§17.3) — remove READY_FOR_QC from
* RENDERING's list when the validation engine ships.
*/
export const EXPORT_TRANSITIONS: Record<ExportStatus, ExportStatus[]> = {
QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only
RENDERING: [
"VALIDATING",
"GENERATING_PREVIEW", // interim: preview stage runs before validation exists
"RENDER_FAILED",
"QUEUED", // lease expiry / retryable fail with attempts remaining (T5)
"CANCELLED",
"READY_FOR_QC", // preview disabled / manual mark-done
],
RENDER_FAILED: ["QUEUED", "SUPERSEDED"],
VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW"],
VALIDATION_FAILED: ["QUEUED", "SUPERSEDED"],
GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"],
PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
READY_FOR_QC: ["QC_FAILED", "READY_FOR_DELIVERY", "SUPERSEDED"],
QC_FAILED: ["SUPERSEDED"],
READY_FOR_DELIVERY: ["PACKAGED", "SUPERSEDED"],
PACKAGED: ["DELIVERED"],
DELIVERED: ["ARCHIVED"],
SUPERSEDED: [],
ARCHIVED: [],
CANCELLED: [],
};
export type TransitionActor = {
type: "WORKER" | "USER" | "SYSTEM";
id?: string | null;
note?: string | null;
};
export function isTransitionAllowed(from: ExportStatus, to: ExportStatus): boolean {
return EXPORT_TRANSITIONS[from]?.includes(to) ?? false;
}
/**
* Applies a state transition inside an existing transaction: validates
* legality, stamps statusChangedAt, appends the ExportEvent audit row.
* Throws PipelineError(409) on an illegal transition (§4.2 rules).
*/
export async function applyTransition(
tx: Prisma.TransactionClient,
exportRow: { id: string; status: ExportStatus },
to: ExportStatus,
actor: TransitionActor,
extraData: Prisma.ExportUpdateInput = {}
) {
if (!isTransitionAllowed(exportRow.status, to)) {
throw new PipelineError(409, `Invalid transition ${exportRow.status}${to}`);
}
const updated = await tx.export.update({
where: { id: exportRow.id },
data: { status: to, statusChangedAt: new Date(), ...extraData },
});
await tx.exportEvent.create({
data: {
exportId: exportRow.id,
fromStatus: exportRow.status,
toStatus: to,
actorType: actor.type,
actorId: actor.id ?? null,
note: actor.note ?? null,
},
});
return updated;
}