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>
257 lines
9.1 KiB
TypeScript
257 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|