ae58dc0366
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot headlessly with the studio slate/overlay template to produce the delivery MOV and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage. Server: - New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus Project.deliveryConfig and per-submission slate fields (Export.vfxScope, Export.submissionNote, inherited from the shot's previous export). Both migrations are purely additive; no existing column is touched. - lib/render-pipeline: server-enforced state machine, transactional version increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by machine availability windows, and a lease reaper run from instrumentation.ts. - /api/ext/* endpoints for the panel and workers; session-auth mirrors under /api/render and /api/machines for the web UI. - Pipeline pages: render queue, export detail, machine monitoring, plus an Exports tab on shot detail. RenderWorker (.NET 8 Windows service, new): - Registration, heartbeat as cancel channel, claim loop, aerender runner with progress parsing and stall watchdog, crash recovery and disk-spooled reporting that survives server downtime. - Preview stage: headless AE assembles the preview comp into a throwaway AEP with both output modules queued, then a single aerender pass renders them. Preview jobs are not claimed while an interactive AE session is open, so an artist's project is never taken over. AE panel: Queue Export with live status polling, urgent flag, retry, and the VFX Scope / Submission Note fields. Every existing panel action is unchanged. Preview chaining ships disabled behind SystemConfig preview.enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
8.0 KiB
TypeScript
207 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useSession } from "next-auth/react";
|
|
import { ArrowLeft, Zap, Power, Cpu, HardDrive, MemoryStick } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface MachineRow {
|
|
id: string;
|
|
name: string;
|
|
hostname: string;
|
|
enabled: boolean;
|
|
status: "ONLINE" | "OFFLINE" | "DISABLED";
|
|
lastSeenAt: string | null;
|
|
workerVersion: string | null;
|
|
aeVersion: string | null;
|
|
availability: { mode?: string } | null;
|
|
renderNowUntil: string | null;
|
|
latestHeartbeat: {
|
|
createdAt: string;
|
|
cpuPercent: number | null;
|
|
memPercent: number | null;
|
|
diskFreeGb: number | null;
|
|
} | null;
|
|
currentJob: {
|
|
id: string;
|
|
exportId: string | null;
|
|
shotCode: string | null;
|
|
versionString: string | null;
|
|
progress: number;
|
|
etaSeconds: number | null;
|
|
} | null;
|
|
}
|
|
|
|
const STATUS_STYLES: Record<string, string> = {
|
|
ONLINE: "bg-green-500/15 text-green-400 border-green-500/30",
|
|
OFFLINE: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
|
DISABLED: "bg-red-500/15 text-red-400 border-red-500/30",
|
|
};
|
|
|
|
export function MachinesClient() {
|
|
const { data: session } = useSession();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["pipeline-machines"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/machines");
|
|
if (!res.ok) throw new Error("Failed to load machines");
|
|
return res.json() as Promise<{ machines: MachineRow[] }>;
|
|
},
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
async function patch(machineId: string, body: Record<string, unknown>, okMsg: string) {
|
|
const res = await fetch(`/api/machines/${machineId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const resBody = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
toast({ title: "Update failed", description: resBody.error ?? res.statusText, variant: "destructive" });
|
|
} else {
|
|
toast({ title: okMsg });
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ["pipeline-machines"] });
|
|
}
|
|
|
|
const machines = data?.machines ?? [];
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/pipeline">
|
|
<Button variant="ghost" size="icon-sm">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-white">Render Machines</h1>
|
|
<p className="text-sm text-zinc-400 mt-1">
|
|
Worker status, render windows and the Render Now override
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="text-zinc-500">Loading…</div>
|
|
) : machines.length === 0 ? (
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-10 text-center text-zinc-500">
|
|
No machines registered yet — install the RenderWorker service on a workstation and it will
|
|
appear here after its first registration.
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
{machines.map((m) => {
|
|
const renderNowActive = m.renderNowUntil && new Date(m.renderNowUntil) > new Date();
|
|
return (
|
|
<div key={m.id} className="rounded-lg border border-zinc-800 bg-zinc-900 p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-white font-medium">{m.name}</div>
|
|
<div className="text-xs text-zinc-500">{m.hostname}</div>
|
|
</div>
|
|
<span
|
|
className={cn("rounded-full border px-2 py-0.5 text-xs", STATUS_STYLES[m.status])}
|
|
>
|
|
{m.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-xs text-zinc-500 space-y-0.5">
|
|
<div>Worker {m.workerVersion ?? "?"} · AE {m.aeVersion ?? "?"}</div>
|
|
<div>
|
|
Availability: {m.availability?.mode ?? "ALWAYS"}
|
|
{renderNowActive && (
|
|
<span className="text-amber-400">
|
|
{" "}· Render Now until {new Date(m.renderNowUntil!).toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{m.lastSeenAt && <div>Last seen {new Date(m.lastSeenAt).toLocaleString()}</div>}
|
|
</div>
|
|
|
|
{m.latestHeartbeat && (
|
|
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
|
<span className="flex items-center gap-1">
|
|
<Cpu className="h-3.5 w-3.5" />
|
|
{m.latestHeartbeat.cpuPercent != null ? `${Math.round(m.latestHeartbeat.cpuPercent)}%` : "—"}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<MemoryStick className="h-3.5 w-3.5" />
|
|
{m.latestHeartbeat.memPercent != null ? `${Math.round(m.latestHeartbeat.memPercent)}%` : "—"}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<HardDrive className="h-3.5 w-3.5" />
|
|
{m.latestHeartbeat.diskFreeGb != null ? `${Math.round(m.latestHeartbeat.diskFreeGb)} GB free` : "—"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{m.currentJob ? (
|
|
<div className="rounded bg-zinc-800/60 p-2.5 space-y-1.5">
|
|
<div className="text-xs text-zinc-300">
|
|
Rendering{" "}
|
|
{m.currentJob.exportId ? (
|
|
<Link
|
|
href={`/pipeline/exports/${m.currentJob.exportId}`}
|
|
className="text-amber-400 hover:underline"
|
|
>
|
|
{m.currentJob.shotCode} {m.currentJob.versionString}
|
|
</Link>
|
|
) : (
|
|
"job"
|
|
)}
|
|
</div>
|
|
<Progress value={m.currentJob.progress * 100} className="h-1.5" />
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-zinc-600">Idle</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!m.enabled}
|
|
onClick={() =>
|
|
patch(
|
|
m.id,
|
|
{ renderNowHours: renderNowActive ? 0 : 4 },
|
|
renderNowActive ? "Render Now cleared" : "Render Now active for 4 h"
|
|
)
|
|
}
|
|
>
|
|
<Zap className={cn("h-4 w-4 mr-1.5", renderNowActive ? "text-amber-400" : "")} />
|
|
{renderNowActive ? "Stop Render Now" : "Render Now (4 h)"}
|
|
</Button>
|
|
{isAdmin && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
patch(m.id, { enabled: !m.enabled }, m.enabled ? "Machine disabled" : "Machine enabled")
|
|
}
|
|
>
|
|
<Power className={cn("h-4 w-4 mr-1.5", m.enabled ? "text-red-400" : "text-green-400")} />
|
|
{m.enabled ? "Disable" : "Enable"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|