feat(pipeline): render queue, worker service and automated preview generation

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>
This commit is contained in:
twotalesanimation
2026-08-02 14:34:34 +02:00
parent 0d0f3e1a33
commit ae58dc0366
71 changed files with 11767 additions and 1 deletions
@@ -0,0 +1,273 @@
"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 } 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/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>
</div>
);
}
@@ -0,0 +1,256 @@
"use client";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
FAILED_STATUSES,
ACTIVE_STATUSES,
statusLabel,
} from "../../status-colors";
interface ExportDetail {
export: {
id: string;
status: string;
versionString: string;
statusChangedAt: string;
createdAt: string;
aepPath: string;
compName: string;
outputDir: string;
outputPattern: string;
frameStart: number;
frameEnd: number;
fps: number;
width: number;
height: number;
colorspace: string | null;
exrFileCount: number | null;
exrTotalBytes: number | null;
vfxScope: string | null;
submissionNote: string | null;
submittedByName: string | null;
shot: {
id: string;
shotCode: string;
episode: string | null;
project: { id: string; name: string; code: string };
};
renderJobs: {
id: string;
attempt: number;
status: string;
priority: number;
progress: number;
currentFrame: number | null;
totalFrames: number | null;
errorMessage: string | null;
logTail: string | null;
exitCode: number | null;
claimedAt: string | null;
startedAt: string | null;
finishedAt: string | null;
machine: { id: string; name: string } | null;
}[];
events: {
id: string;
fromStatus: string | null;
toStatus: string;
actorType: string;
actorId: string | null;
note: string | null;
createdAt: string;
}[];
};
}
export function ExportDetailClient({ exportId }: { exportId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ["pipeline-export", exportId],
queryFn: async () => {
const res = await fetch(`/api/render/exports/${exportId}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load export");
return res.json() as Promise<ExportDetail>;
},
refetchInterval: 5000,
});
async function action(verb: "retry" | "cancel") {
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" });
}
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
}
if (isLoading) return <div className="p-6 text-zinc-500">Loading</div>;
if (error || !data) {
return (
<div className="p-6 text-red-400">
{(error as Error)?.message ?? "Export not found"}
</div>
);
}
const e = data.export;
const manifestRows: [string, string][] = [
["Project", `${e.shot.project.name} (${e.shot.project.code})`],
["Shot", e.shot.shotCode],
["Comp", e.compName],
["AEP", e.aepPath],
["Output dir", e.outputDir],
["Pattern", e.outputPattern],
["Frames", `${e.frameStart}${e.frameEnd} @ ${e.fps} fps`],
["Resolution", `${e.width}×${e.height}`],
["Colorspace", e.colorspace ?? "—"],
["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"],
["Submitted by", e.submittedByName ?? "—"],
];
return (
<div className="p-6 space-y-6 max-w-5xl">
<div className="flex items-center justify-between gap-4 flex-wrap">
<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-xl font-semibold text-white">
{e.shot.shotCode} · {e.versionString}
</h1>
<span
className={cn(
"inline-block mt-1 rounded-full border px-2 py-0.5 text-xs",
EXPORT_STATUS_STYLES[e.status] ?? ""
)}
>
{statusLabel(e.status)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{FAILED_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("retry")}>
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
</Button>
)}
</div>
</div>
{/* Manifest */}
<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">Manifest</div>
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-2 p-4 text-sm">
{manifestRows.map(([k, v]) => (
<div key={k} className="flex gap-3">
<dt className="w-28 shrink-0 text-zinc-500">{k}</dt>
<dd className="text-zinc-300 break-all">{v}</dd>
</div>
))}
</dl>
</div>
{/* Per-submission slate fields */}
{(e.vfxScope || e.submissionNote) && (
<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">
Submission
</div>
<dl className="p-4 space-y-3 text-sm">
{e.vfxScope && (
<div>
<dt className="text-zinc-500 mb-0.5">VFX Scope</dt>
<dd className="text-zinc-300 whitespace-pre-wrap">{e.vfxScope}</dd>
</div>
)}
{e.submissionNote && (
<div>
<dt className="text-zinc-500 mb-0.5">Submission Note</dt>
<dd className="text-zinc-300 whitespace-pre-wrap">{e.submissionNote}</dd>
</div>
)}
</dl>
</div>
)}
{/* Render attempts */}
<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">
Render attempts
</div>
<div className="divide-y divide-zinc-800/60">
{e.renderJobs.map((j) => (
<div key={j.id} className="p-4 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-white font-medium">Attempt {j.attempt}</span>
<span className="text-xs text-zinc-400">{j.status}</span>
<span className="text-xs text-zinc-500">{j.machine?.name ?? "unclaimed"}</span>
{j.exitCode != null && <span className="text-xs text-zinc-500">exit {j.exitCode}</span>}
{j.startedAt && (
<span className="text-xs text-zinc-500">
started {new Date(j.startedAt).toLocaleString()}
</span>
)}
{j.finishedAt && (
<span className="text-xs text-zinc-500">
finished {new Date(j.finishedAt).toLocaleString()}
</span>
)}
</div>
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
{j.logTail && (
<details className="mt-2">
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
Log tail
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-3 text-xs text-zinc-400 whitespace-pre-wrap">
{j.logTail}
</pre>
</details>
)}
</div>
))}
{e.renderJobs.length === 0 && (
<div className="p-4 text-sm text-zinc-500">No attempts yet</div>
)}
</div>
</div>
{/* Event timeline */}
<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">Timeline</div>
<ol className="p-4 space-y-2">
{e.events.map((ev) => (
<li key={ev.id} className="flex items-start gap-3 text-sm">
<span className="text-xs text-zinc-500 w-40 shrink-0 whitespace-nowrap">
{new Date(ev.createdAt).toLocaleString()}
</span>
<span className="text-zinc-300">
{ev.fromStatus ? `${statusLabel(ev.fromStatus)}` : ""}
{statusLabel(ev.toStatus)}
<span className="text-zinc-500"> · {ev.actorType.toLowerCase()}</span>
{ev.note && <span className="block text-xs text-zinc-500">{ev.note}</span>}
</span>
</li>
))}
</ol>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ExportDetailClient } from "./ExportDetailClient";
export const dynamic = "force-dynamic";
export default async function ExportDetailPage({
params,
}: {
params: Promise<{ exportId: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { exportId } = await params;
return <ExportDetailClient exportId={exportId} />;
}
@@ -0,0 +1,206 @@
"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>
);
}
@@ -0,0 +1,12 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { MachinesClient } from "./MachinesClient";
export const dynamic = "force-dynamic";
export default async function MachinesPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
return <MachinesClient />;
}
+20
View File
@@ -0,0 +1,20 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { PipelineQueueClient } from "./PipelineQueueClient";
export const dynamic = "force-dynamic";
export default async function PipelinePage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const projects = await db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
});
return <PipelineQueueClient projects={projects} />;
}
+33
View File
@@ -0,0 +1,33 @@
/** Shared status→style maps for the pipeline pages. */
export const EXPORT_STATUS_STYLES: Record<string, string> = {
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
RENDERING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
RENDER_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
VALIDATING: "bg-sky-500/15 text-sky-400 border-sky-500/30",
VALIDATION_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
GENERATING_PREVIEW: "bg-indigo-500/15 text-indigo-400 border-indigo-500/30",
PREVIEW_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
READY_FOR_QC: "bg-amber-500/15 text-amber-400 border-amber-500/30",
QC_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
READY_FOR_DELIVERY: "bg-green-500/15 text-green-400 border-green-500/30",
PACKAGED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
SUPERSEDED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
ARCHIVED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
};
export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
export function statusLabel(status: string): string {
return status.replaceAll("_", " ");
}
export function formatEta(seconds: number | null | undefined): string {
if (seconds == null || seconds <= 0) return "—";
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
@@ -34,6 +34,7 @@ import {
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
@@ -89,7 +90,7 @@ export default function ShotDetailPage() {
const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false);
@@ -543,6 +544,18 @@ export default function ShotDetailPage() {
<Video className="h-4 w-4" />
Footage
</button>
<button
onClick={() => setActiveTab("exports")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "exports"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Film className="h-4 w-4" />
Exports
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
@@ -658,6 +671,8 @@ export default function ShotDetailPage() {
/>
)}
{activeTab === "exports" && <ShotExportsTab shotId={shot.id} />}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { retryExport } from "@/lib/render-pipeline/exports";
// ── POST /api/ext/exports/{exportId}/retry (E14) ─────────────────────────────
//
// Retry a *_FAILED export from the AE panel: new RenderJob attempt, back to QUEUED.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
let note: string | undefined;
try {
const body = await req.json();
if (typeof body?.note === "string") note = body.note;
} catch {
// empty body is fine
}
try {
const result = await retryExport(exportId, { type: "USER", note: note ?? "Retry from AE panel" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { getExportDetail } from "@/lib/render-pipeline/exports";
// ── GET /api/ext/exports/{exportId} (E3) ─────────────────────────────────────
//
// Export detail incl. render attempts and audit events (validations/QC join in
// later phases).
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { exportId } = await params;
try {
const result = await getExportDetail(exportId);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { getLatestExport } from "@/lib/render-pipeline/exports";
// ── GET /api/ext/exports/latest?shotCode=&projectCode= (E2) ──────────────────
//
// Latest export + status for the AE panel status header. Returns
// { "export": null } when the shot has never been exported.
export async function GET(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const shotCode = searchParams.get("shotCode")?.trim();
const projectCode = searchParams.get("projectCode")?.trim();
if (!shotCode) {
return NextResponse.json({ error: "shotCode query param is required" }, { status: 400 });
}
try {
const result = await getLatestExport(shotCode, projectCode);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { createExport } from "@/lib/render-pipeline/exports";
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
//
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force? }
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
export async function POST(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
manifest?: unknown;
submittedByEmail?: string;
priority?: number;
force?: boolean;
vfxScope?: string | null;
submissionNote?: string | null;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.manifest) {
return NextResponse.json({ error: "manifest is required" }, { status: 400 });
}
try {
const result = await createExport({
manifest: body.manifest,
submittedByEmail: body.submittedByEmail ?? null,
priority: typeof body.priority === "number" ? body.priority : undefined,
force: body.force === true,
// undefined inherits the shot's previous export values
vfxScope: body.vfxScope,
submissionNote: body.submissionNote,
});
return NextResponse.json(result, { status: 201 });
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { db } from "@/lib/db";
import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage";
// ── POST /api/ext/render/jobs/{jobId}/artifact-presign (E20) ─────────────────
//
// Presigned upload URL for worker artifacts. Kinds map to the existing object
// storage folder layout: preview → videos/, thumbnail → image/,
// metadata/log → renders/.
const KIND_FOLDERS: Record<string, string> = {
preview: "videos",
thumbnail: "image",
metadata: "renders",
log: "renders",
};
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: { machineId?: string; kind?: string; fileName?: string; contentType?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
try {
if (!body.machineId) throw new PipelineError(400, "machineId is required");
if (!body.fileName) throw new PipelineError(400, "fileName is required");
if (!body.contentType) throw new PipelineError(400, "contentType is required");
const folder = KIND_FOLDERS[body.kind ?? ""];
if (!folder) {
throw new PipelineError(422, `kind must be one of: ${Object.keys(KIND_FOLDERS).join(", ")}`);
}
const job = await db.renderJob.findUnique({ where: { id: jobId }, select: { machineId: true } });
if (!job) throw new PipelineError(404, "Render job not found");
if (job.machineId !== body.machineId) {
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
}
const key = `${folder}/${randomUUID()}-${sanitizeFileName(body.fileName)}`;
const presignedUrl = await generateHetznerPresignedUploadUrl(key, body.contentType);
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { completeRender } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
//
// aerender finished with exit 0. Phase 2 interim: Export goes to a provisional
// READY_FOR_QC; Phase 3 inserts VALIDATING between (§17.3).
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: {
machineId?: string;
renderSeconds?: number;
logFileKey?: string;
logTail?: string;
exrFileCount?: number;
exrTotalBytes?: number;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await completeRender(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { reportFail } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/{jobId}/fail (E10) ─────────────────────────────
//
// Failure report. If retryable and attempts remain the server auto-creates the
// next attempt and returns autoRequeued: true.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: {
machineId?: string;
stage?: string;
exitCode?: number;
errorMessage?: string;
logTail?: string;
logFileKey?: string;
retryable?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await reportFail(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { finalizePreview, type FinalizeInput } from "@/lib/render-pipeline/preview";
// ── POST /api/ext/render/jobs/{jobId}/finalize (E13) ─────────────────────────
//
// Preview artifacts uploaded → register the review MP4 as an internal-only
// Version and move the Export to READY_FOR_QC. Never shares to the client
// portal and never changes task/shot status (§10.0).
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: FinalizeInput & { machineId?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await finalizePreview(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { reportProgress } from "@/lib/render-pipeline/jobs";
// ── PATCH /api/ext/render/jobs/{jobId}/progress (E9) ─────────────────────────
//
// Progress/ETA report; renews the job lease. Response carries cancelRequested
// so a worker learns about cancellation without any server→worker connection.
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { jobId } = await params;
let body: {
machineId?: string;
progress?: number;
currentFrame?: number;
totalFrames?: number;
etaSeconds?: number;
logTail?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await reportProgress(jobId, body.machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { claimNextJob } from "@/lib/render-pipeline/jobs";
// ── POST /api/ext/render/jobs/claim (E8) ─────────────────────────────────────
//
// Atomically claim the next queued job (FOR UPDATE SKIP LOCKED). Enforces
// machine availability windows / Render Now / urgent priority server-side
// (§7.10). 204 when nothing is claimable.
export async function POST(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { machineId?: string; types?: string[] };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.machineId) {
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
}
try {
const result = await claimNextJob(body.machineId, body.types);
if (!result) return new NextResponse(null, { status: 204 });
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { recordHeartbeat } from "@/lib/render-pipeline/machines";
// ── POST /api/ext/workers/{machineId}/heartbeat (E7) ─────────────────────────
//
// Heartbeat + the server→worker command channel: cancellation piggybacks on
// the response (`commands: [{ type: "CANCEL_JOB", jobId }]`) so no server→
// worker connection is ever needed.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { machineId } = await params;
let body: { cpuPercent?: number; memPercent?: number; diskFreeGb?: number; currentJobId?: string | null } = {};
try {
body = await req.json();
} catch {
// heartbeat with empty body is fine
}
try {
const result = await recordHeartbeat(machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { registerMachine } from "@/lib/render-pipeline/machines";
// ── POST /api/ext/workers/register (E6) ──────────────────────────────────────
//
// Idempotent register/upsert on machine name. Returns the server-supplied
// worker config (SystemConfig) so fleet tuning never touches worker installs.
export async function POST(req: NextRequest) {
if (!isExtAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
name?: string;
hostname?: string;
workerVersion?: string;
aeVersion?: string;
capabilities?: unknown;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
try {
const result = await registerMachine(body);
return NextResponse.json(result);
} catch (err) {
const { body: errBody, status } = toErrorResponse(err);
return NextResponse.json(errBody, { status });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { updateMachine, type MachineAvailability } from "@/lib/render-pipeline/machines";
// ── PATCH /api/machines/{machineId} ──────────────────────────────────────────
//
// Body (all optional):
// enabled: boolean — admin kill-switch (admin/producer/supervisor)
// availability: {...}|null — §7.10 schedule config (admin/producer/supervisor)
// renderNowHours: number|0 — "Render Now" override (any studio user);
// 0 or null clears it
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
try {
const { machineId } = await params;
let body: { enabled?: boolean; availability?: MachineAvailability | null; renderNowHours?: number | null };
try {
body = await req.json();
} catch {
throw new PipelineError(400, "Invalid JSON");
}
// Render Now is available to every studio user (§7.10); enable/disable and
// availability windows are admin-level controls.
if (body.enabled !== undefined || body.availability !== undefined) {
await requirePipelineAdmin();
} else {
await requirePipelineUser();
}
const result = await updateMachine(machineId, body);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { listMachines } from "@/lib/render-pipeline/machines";
// ── GET /api/machines (E21) ──────────────────────────────────────────────────
//
// Machine list for the Machine Monitoring page: derived ONLINE/OFFLINE status,
// latest heartbeat stats, current job.
export async function GET() {
try {
await requirePipelineUser();
const machines = await listMachines();
return NextResponse.json({ machines });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { cancelExport } from "@/lib/render-pipeline/exports";
// ── POST /api/render/exports/{exportId}/cancel (E15 / T18) ───────────────────
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
try {
const user = await requirePipelineUser();
const { exportId } = await params;
const result = await cancelExport(exportId, { type: "USER", id: user.id, note: "Cancelled from web UI" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { markExportDoneManually } from "@/lib/render-pipeline/exports";
// ── POST /api/render/exports/{exportId}/mark-done ────────────────────────────
//
// Phase 1 stopgap (§17.1): lets the studio keep using the old manual render
// path while the queue is validated. Remove once workers render for real.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { exportId } = await params;
let note: string | undefined;
try {
const body = await req.json();
if (typeof body?.note === "string") note = body.note;
} catch {
// empty body is fine
}
const result = await markExportDoneManually(exportId, user.id, note);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { retryExport } from "@/lib/render-pipeline/exports";
// ── POST /api/render/exports/{exportId}/retry (E15) ──────────────────────────
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
try {
const user = await requirePipelineUser();
const { exportId } = await params;
const result = await retryExport(exportId, { type: "USER", id: user.id, note: "Retry from web UI" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getExportDetail } from "@/lib/render-pipeline/exports";
// ── GET /api/render/exports/{exportId} ───────────────────────────────────────
//
// Export detail for the web Export page (internal mirror of E3).
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
try {
await requirePipelineUser();
const { exportId } = await params;
const result = await getExportDetail(exportId);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getJobDetail } from "@/lib/render-pipeline/jobs";
import { cancelExport } from "@/lib/render-pipeline/exports";
// ── POST /api/render/jobs/{jobId}/cancel (E15 / T18) ─────────────────────────
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
try {
const user = await requirePipelineUser();
const { jobId } = await params;
const job = await getJobDetail(jobId);
if (!job.exportId) throw new PipelineError(422, "Job has no export to cancel");
const result = await cancelExport(job.exportId, { type: "USER", id: user.id, note: "Cancelled from web UI" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getJobDetail } from "@/lib/render-pipeline/jobs";
import { retryExport } from "@/lib/render-pipeline/exports";
// ── POST /api/render/jobs/{jobId}/retry (E15) ────────────────────────────────
//
// Job-level retry resolves to its Export (each retry is a fresh attempt row).
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
try {
const user = await requirePipelineUser();
const { jobId } = await params;
const job = await getJobDetail(jobId);
if (!job.exportId) throw new PipelineError(422, "Job has no export to retry");
const result = await retryExport(job.exportId, { type: "USER", id: user.id, note: "Retry from web UI" });
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { ExportStatus } from "@prisma/client";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { listExports } from "@/lib/render-pipeline/exports";
// ── GET /api/render/queue (E21) ──────────────────────────────────────────────
//
// Exports list for the Render Queue page. Optional filters: projectId,
// episode, status; standard pagination. Defaults to all statuses so the queue
// page can show history too — the UI filters live states client-side.
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const status = searchParams.get("status") ?? undefined;
const result = await listExports({
projectId: searchParams.get("projectId") ?? undefined,
episode: searchParams.get("episode") ?? undefined,
shotId: searchParams.get("shotId") ?? undefined,
status:
status && (Object.values(ExportStatus) as string[]).includes(status)
? (status as ExportStatus)
: undefined,
page: Number(searchParams.get("page") ?? "1") || 1,
limit: Number(searchParams.get("limit") ?? "50") || 50,
});
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}