70cf45ead4
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>
404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|