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
+85
View File
@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Layers, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import {
EXPORT_STATUS_STYLES,
statusLabel,
} from "@/app/(dashboard)/pipeline/status-colors";
interface ExportRow {
id: string;
versionString: string;
status: string;
statusChangedAt: string;
createdAt: string;
outputDir: string;
job: {
attempt: number;
progress: number;
machineName: string | null;
errorMessage: string | null;
} | null;
}
/**
* "Exports" tab on the shot detail page (RenderPipeline2 §13) — the render
* pipeline history for this shot, linking into the Export detail page.
*/
export function ShotExportsTab({ shotId }: { shotId: string }) {
const { data, isLoading } = useQuery({
queryKey: ["shot-exports", shotId],
queryFn: async () => {
const res = await fetch(`/api/render/queue?shotId=${shotId}&limit=100`);
if (!res.ok) throw new Error("Failed to load exports");
return res.json() as Promise<{ exports: ExportRow[] }>;
},
refetchInterval: 10000,
});
const exports = data?.exports ?? [];
if (isLoading) {
return <div className="py-10 text-center text-sm text-muted-foreground">Loading exports</div>;
}
if (exports.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<Layers className="h-8 w-8 opacity-30" />
<p className="text-sm">No pipeline exports yet queue one from the After Effects panel.</p>
</div>
);
}
return (
<div className="space-y-2">
{exports.map((e) => (
<Link
key={e.id}
href={`/pipeline/exports/${e.id}`}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 hover:border-zinc-600 transition-colors"
>
<span className="font-mono text-sm text-white w-14">{e.versionString}</span>
<span
className={cn(
"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"
)}
>
{statusLabel(e.status)}
</span>
<span className="flex-1 min-w-0 truncate text-xs text-muted-foreground">
{e.job?.machineName ? `${e.job.machineName} · attempt ${e.job.attempt} · ` : ""}
{e.outputDir}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(e.createdAt).toLocaleDateString()}
</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</Link>
))}
</div>
);
}