Files
vfxreview/app/(dashboard)/pipeline/PipelineQueueClient.tsx
T
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

404 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
FAILED_STATUSES,
ACTIVE_STATUSES,
statusLabel,
formatEta,
} from "./status-colors";
interface QueueExport {
id: string;
shotCode: string;
episode: string | null;
projectName: string;
projectCode: string;
versionString: string;
status: string;
statusChangedAt: string;
createdAt: string;
outputDir: string;
job: {
id: string;
attempt: number;
status: string;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
priority: number;
machineName: string | null;
errorMessage: string | null;
} | null;
}
interface Props {
projects: { id: string; name: string; code: string }[];
}
export function PipelineQueueClient({ projects }: Props) {
const { data: session } = useSession();
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["pipeline-queue", projectId, statusFilter],
queryFn: async () => {
const params = new URLSearchParams({ limit: "200" });
if (projectId !== "all") params.set("projectId", projectId);
if (statusFilter !== "all") params.set("status", statusFilter);
const res = await fetch(`/api/render/queue?${params}`);
if (!res.ok) throw new Error("Failed to load queue");
return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>;
},
refetchInterval: 5000,
});
const exports = data?.exports ?? [];
const counts = {
queued: exports.filter((e) => e.status === "QUEUED").length,
rendering: exports.filter((e) => e.status === "RENDERING").length,
failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length,
readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length,
readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length,
};
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
}
const cards: { label: string; value: number; className?: string }[] = [
{ label: "Queued", value: counts.queued },
{ label: "Rendering", value: counts.rendering, className: "text-blue-400" },
{ label: "Failed", value: counts.failed, className: "text-red-400" },
{ label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" },
{ label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" },
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
<p className="text-sm text-zinc-400 mt-1">
Exports queued from the AE panel — live status, retries and history
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/deliveries">
<Button variant="outline" size="sm">
<Package className="h-4 w-4 mr-2" />
Deliveries
</Button>
</Link>
<Link href="/pipeline/machines">
<Button variant="outline" size="sm">
<Server className="h-4 w-4 mr-2" />
Machines
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{cards.map((c) => (
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
<th className="px-4 py-2.5 font-medium">Shot</th>
<th className="px-4 py-2.5 font-medium">Version</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
<th className="px-4 py-2.5 font-medium">Machine</th>
<th className="px-4 py-2.5 font-medium">Attempt</th>
<th className="px-4 py-2.5 font-medium">Queued</th>
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
Loading…
</td>
</tr>
) : exports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
No exports yet — queue one from the After Effects panel
</td>
</tr>
) : (
exports.map((e) => (
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
<td className="px-4 py-2.5">
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
{e.shotCode}
</Link>
<div className="text-xs text-zinc-500">
{e.projectCode}
{e.episode ? ` · ep ${e.episode}` : ""}
</div>
</td>
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
<td className="px-4 py-2.5">
<span
className={cn(
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
)}
title={e.job?.errorMessage ?? undefined}
>
{statusLabel(e.status)}
</span>
</td>
<td className="px-4 py-2.5">
{e.status === "RENDERING" && e.job ? (
<div className="space-y-1">
<Progress value={e.job.progress * 100} className="h-1.5" />
<div className="text-xs text-zinc-500">
{e.job.currentFrame != null && e.job.totalFrames != null
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
: ""}
ETA {formatEta(e.job.etaSeconds)}
</div>
</div>
) : (
<span className="text-zinc-600 text-xs">—</span>
)}
</td>
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
<td className="px-4 py-2.5 text-zinc-400">
{e.job ? `${e.job.attempt}` : "—"}
</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
<Button
variant="ghost"
size="icon-sm"
title="Mark done manually (rendered outside the pipeline)"
onClick={() => action(e.id, "mark-done")}
>
<CheckCircle2 className="h-4 w-4 text-green-400" />
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<BlenderQueue projectId={projectId} />
</div>
);
}
interface BlenderJobRow {
id: string;
label: string | null;
status: string;
attempt: number;
maxAttempts: number;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
etaSeconds: number | null;
errorMessage: string | null;
machineName: string | null;
submittedBy: string | null;
projectCode: string | null;
blendFile: string | null;
frameStart: number | null;
frameEnd: number | null;
createdAt: string;
}
const BLENDER_STATUS_STYLES: Record<string, string> = {
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
CLAIMED: "bg-blue-500/15 text-blue-400 border-blue-500/30",
RUNNING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
COMPLETED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
EXPIRED: "bg-red-500/15 text-red-400 border-red-500/30",
};
/**
* Standalone Blender renders. They have no Export behind them, so they cannot
* appear in the table above — but they share the queue, and a render nobody can
* see is a render nobody can retry.
*/
function BlenderQueue({ projectId }: { projectId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["blender-jobs", projectId],
queryFn: async () => {
const qs = projectId && projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await fetch(`/api/render/blender${qs}`);
if (!res.ok) throw new Error("Failed to load Blender jobs");
return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
},
refetchInterval: 5000,
});
async function jobAction(id: string, verb: "cancel" | "retry") {
const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
}
const jobs = data?.jobs ?? [];
if (jobs.length === 0) return null;
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Blender renders
</div>
<div className="divide-y divide-zinc-800/60">
{jobs.map((j) => (
<div key={j.id} className="px-4 py-3 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-zinc-200">{j.label ?? "(untitled)"}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", BLENDER_STATUS_STYLES[j.status] ?? "")}>
{j.status.toLowerCase()}
</span>
{j.projectCode && <span className="text-xs text-zinc-500">{j.projectCode}</span>}
{j.frameStart != null && (
<span className="text-xs text-zinc-500">
frames {j.frameStart}–{j.frameEnd}
</span>
)}
{j.status === "RUNNING" && (
<span className="text-xs text-blue-400">
{Math.round(j.progress * 100)}%
{j.currentFrame != null ? ` · frame ${j.currentFrame}` : ""}
{j.etaSeconds ? ` · ETA ${formatEta(j.etaSeconds)}` : ""}
</span>
)}
{j.machineName && <span className="text-xs text-zinc-500">{j.machineName}</span>}
{j.attempt > 1 && (
<span className="text-xs text-zinc-500">
attempt {j.attempt}/{j.maxAttempts}
</span>
)}
<span className="ml-auto flex items-center gap-1">
{["QUEUED", "CLAIMED", "RUNNING"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => jobAction(j.id, "cancel")}>
<XCircle className="h-4 w-4 text-red-400" />
</Button>
)}
{["FAILED", "CANCELLED", "EXPIRED"].includes(j.status) && (
<Button variant="ghost" size="icon-sm" title="Requeue" onClick={() => jobAction(j.id, "retry")}>
<RotateCcw className="h-4 w-4 text-amber-400" />
</Button>
)}
</span>
</div>
{j.blendFile && <div className="mt-1 text-xs text-zinc-600 break-all">{j.blendFile}</div>}
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
</div>
))}
</div>
</div>
);
}