import { Prisma, RenderJobStatus } from "@prisma/client"; import { z } from "zod"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; import { getConfigNumber } from "./config"; /** * Standalone Blender renders. * * Deliberately the simplest thing in the pipeline: a queue entry and nothing * more. No Export, no shot version, no validation, QC or delivery — the .blend * is already set up to render, and this is "Ctrl+F12, but on the farm". Every * lifecycle call it needs (claim, progress, fail, complete) already tolerates a * job with no Export behind it. */ export const blenderManifestSchema = z.object({ stage: z.literal("BLENDER").default("BLENDER"), blendFile: z.string().min(1), /** Blank renders the .blend's active scene, which is usually what is wanted. */ scene: z.string().max(200).optional().nullable(), frameStart: z.number().int(), frameEnd: z.number().int(), /** * Read from the .blend at submission and passed through only so the queue can * show where frames will land. Left unset, the file's own output path is used * — the file is expected to be ready to render. */ outputPath: z.string().max(1000).optional().nullable(), /** Overrides, all optional: an unset value leaves the .blend's own setting. */ engine: z.enum(["CYCLES", "BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "BLENDER_WORKBENCH"]).optional().nullable(), device: z.enum(["CPU", "GPU"]).optional().nullable(), samples: z.number().int().positive().max(100000).optional().nullable(), resolutionPercent: z.number().int().min(1).max(100).optional().nullable(), /** Blender version this was authored in, for diagnosing a farm-vs-desk difference. */ blenderVersion: z.string().max(50).optional().nullable(), }).refine((m) => m.frameEnd >= m.frameStart, { message: "frameEnd must be >= frameStart", path: ["frameEnd"], }); export type BlenderManifest = z.infer; export const createBlenderJobSchema = z.object({ projectCode: z.string().min(1), label: z.string().min(1).max(200), manifest: blenderManifestSchema, submittedByEmail: z.string().max(200).optional().nullable(), urgent: z.boolean().optional(), }); export type CreateBlenderJobInput = z.infer; export async function createBlenderJob(input: CreateBlenderJobInput) { const project = await db.project.findUnique({ where: { code: input.projectCode }, select: { id: true, code: true, name: true }, }); if (!project) throw new PipelineError(404, `Project not found: ${input.projectCode}`); const [maxAttempts, urgentThreshold] = await Promise.all([ getConfigNumber("render.maxAttempts"), getConfigNumber("render.urgentPriorityThreshold"), ]); const job = await db.renderJob.create({ data: { type: "BLENDER_RENDER", projectId: project.id, label: input.label.trim(), submittedBy: input.submittedByEmail?.trim() || null, attempt: 1, maxAttempts, priority: input.urgent ? urgentThreshold : 50, manifest: input.manifest as unknown as Prisma.InputJsonValue, }, }); return { job: { id: job.id, label: job.label, status: job.status, priority: job.priority, frames: input.manifest.frameEnd - input.manifest.frameStart + 1, }, project: { code: project.code, name: project.name }, }; } export interface BlenderJobFilter { projectId?: string; status?: RenderJobStatus; limit?: number; } export async function listBlenderJobs(filter: BlenderJobFilter = {}) { const jobs = await db.renderJob.findMany({ where: { type: "BLENDER_RENDER", ...(filter.projectId ? { projectId: filter.projectId } : {}), ...(filter.status ? { status: filter.status } : {}), }, orderBy: [{ createdAt: "desc" }], take: Math.min(200, Math.max(1, filter.limit ?? 50)), include: { machine: { select: { name: true } } }, }); const projectIds = [...new Set(jobs.map((j) => j.projectId).filter((id): id is string => !!id))]; const projects = projectIds.length ? await db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, code: true, name: true } }) : []; const byId = new Map(projects.map((p) => [p.id, p])); return { jobs: jobs.map((j) => { const m = j.manifest as unknown as Partial; return { id: j.id, label: j.label, status: j.status, attempt: j.attempt, maxAttempts: j.maxAttempts, priority: j.priority, progress: j.progress, currentFrame: j.currentFrame, totalFrames: j.totalFrames, etaSeconds: j.etaSeconds, errorMessage: j.errorMessage, machineName: j.machine?.name ?? null, submittedBy: j.submittedBy, createdAt: j.createdAt, startedAt: j.startedAt, finishedAt: j.finishedAt, projectCode: j.projectId ? byId.get(j.projectId)?.code ?? null : null, blendFile: m.blendFile ?? null, outputPath: m.outputPath ?? null, frameStart: m.frameStart ?? null, frameEnd: m.frameEnd ?? null, }; }), }; } /** Cancel a queued or running Blender job. The worker learns via its progress report. */ export async function cancelBlenderJob(jobId: string, actorId?: string) { const job = await db.renderJob.findUnique({ where: { id: jobId }, select: { id: true, type: true, status: true } }); if (!job) throw new PipelineError(404, "Render job not found"); if (job.type !== "BLENDER_RENDER") throw new PipelineError(422, "Not a Blender job"); if (!["QUEUED", "CLAIMED", "RUNNING"].includes(job.status)) { throw new PipelineError(409, `Job is ${job.status} — only a queued or running job can be cancelled`); } const updated = await db.renderJob.update({ where: { id: job.id }, data: { status: "CANCELLED", errorMessage: `Cancelled by user${actorId ? ` ${actorId}` : ""}`, // A claimed job keeps finishedAt null until the worker acknowledges, so a // render still winding down is not reported as already stopped. ...(job.status === "QUEUED" ? { finishedAt: new Date() } : {}), }, }); return { job: { id: updated.id, status: updated.status } }; } /** Requeue a failed Blender job as a fresh attempt, reusing its manifest. */ export async function retryBlenderJob(jobId: string) { const job = await db.renderJob.findUnique({ where: { id: jobId } }); if (!job) throw new PipelineError(404, "Render job not found"); if (job.type !== "BLENDER_RENDER") throw new PipelineError(422, "Not a Blender job"); if (!["FAILED", "CANCELLED", "EXPIRED"].includes(job.status)) { throw new PipelineError(409, `Job is ${job.status} — only a finished-unsuccessfully job can be retried`); } const next = await db.renderJob.create({ data: { type: "BLENDER_RENDER", projectId: job.projectId, label: job.label, submittedBy: job.submittedBy, attempt: job.attempt + 1, maxAttempts: job.maxAttempts, priority: job.priority, manifest: job.manifest as Prisma.InputJsonValue, }, }); return { job: { id: next.id, attempt: next.attempt, status: next.status } }; }