feat(pipeline): standalone Blender renders on the same queue
Deploy / deploy (push) Successful in 7m2s

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>
This commit is contained in:
twotalesanimation
2026-08-15 18:53:19 +02:00
parent bada122f42
commit 70cf45ead4
24 changed files with 1347 additions and 9 deletions
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { cancelBlenderJob, retryBlenderJob } from "@/lib/render-pipeline/blender";
// ── POST /api/render/blender/{jobId}?action=cancel|retry ─────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
try {
const user = await requirePipelineUser();
const { jobId } = await params;
const action = new URL(req.url).searchParams.get("action");
if (action === "retry") {
return NextResponse.json(await retryBlenderJob(jobId));
}
if (action === "cancel") {
return NextResponse.json(await cancelBlenderJob(jobId, user.id));
}
return NextResponse.json({ error: "action must be cancel or retry" }, { status: 400 });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { RenderJobStatus } from "@prisma/client";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { listBlenderJobs } from "@/lib/render-pipeline/blender";
// ── GET /api/render/blender?projectId=&status= ───────────────────────────────
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const status = searchParams.get("status");
const result = await listBlenderJobs({
projectId: searchParams.get("projectId") ?? undefined,
status: status ? (status as RenderJobStatus) : undefined,
limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined,
});
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}