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:
@@ -21,6 +21,7 @@ import {
|
||||
CloudUpload,
|
||||
Clapperboard,
|
||||
LayoutGrid,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
@@ -32,6 +33,7 @@ const navItems = [
|
||||
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
|
||||
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
|
||||
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
||||
{ href: '/pipeline', label: 'Pipeline', icon: Server, hideForClient: true },
|
||||
{ href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true },
|
||||
{ href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user