Files
twotalesanimation 70cf45ead4
Deploy / deploy (push) Successful in 7m2s
feat(pipeline): standalone Blender renders on the same queue
Blender work is not a shot submission: no Export, no version, no
validation, QC or delivery. It is "Ctrl+F12, but on the farm", so it is
modelled as what it is — a queue entry and nothing more.

That turned out to need very little. Claim, progress, fail and
complete-render already tolerated a job with no Export behind them, so a
Blender job reuses the whole lifecycle; RenderJob gains projectId, label
and submittedBy so a job with no shot code still has something to show.

Capability gating is new and was already missing: the claim now filters
job types by the machine's reported tools. Without it a workstation
without Blender would claim Blender work and fail it repeatedly, burning
the attempts while a machine that could have rendered it sat idle. The
worker asks for Blender jobs unconditionally and the server refuses them
— trusting the worker to self-select would have left the same hole.

The runner treats a clean exit with no saved frames as a failure:
Blender exits 0 when a frame range is empty or an output path is
unwritable, and a job that silently rendered nothing is the worst
outcome available.

Standalone jobs auto-requeue on a transient failure like any other work;
delivery builds are deliberately excluded, since a half-written package
wants a human first.

Adds the Blender add-on (BlenderPanel/) and its docs. The add-on refuses
to submit an unsaved file — the farm opens the .blend from disk, so
unsaved changes would render the previous state without warning. Its
payload is pinned by a contract test, since add-on and server only ever
meet over JSON.

Worker 1.5.0. Adds migration 20260815000000_blender_render_jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:53:19 +02:00

190 lines
7.1 KiB
TypeScript

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<typeof blenderManifestSchema>;
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<typeof createBlenderJobSchema>;
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<BlenderManifest>;
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 } };
}