From 532ebd82d7df63e59374ed0a3840f171ce19cba8 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:04:41 +0200 Subject: [PATCH] feat(pipeline): tabbed render queue with paged exports The queue page loaded up to 200 exports in one table with the Blender renders appended underneath, so a Blender job sat below the whole export history. Exports and Blender renders are now separate tabs; the Blender tab label shows how many are active or failed so a render stays visible from the exports tab. Exports page 25 at a time. The summary cards now come from a server-side count per status (listExports.statusCounts) instead of counting the loaded rows, which was already wrong past 200, and clicking a card filters to it. The project filter moves to the header since it applies to both tabs, and the duplicated Deliveries button is gone. Adds .claude/launch.json for running the dev server from the preview pane. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + .../pipeline/PipelineQueueClient.tsx | 471 +++++++++++------- lib/render-pipeline/exports.ts | 15 +- 3 files changed, 307 insertions(+), 190 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..08f53b9 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx index 13111b4..af71b92 100644 --- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx +++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx @@ -4,9 +4,10 @@ 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, Package } from "lucide-react"; +import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, @@ -55,35 +56,60 @@ interface Props { initialStatus?: string; } +const PAGE_SIZE = 25; + +const BLENDER_ACTIVE = ["QUEUED", "CLAIMED", "RUNNING"]; + export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) { const { data: session } = useSession(); const { toast } = useToast(); const queryClient = useQueryClient(); const [projectId, setProjectId] = useState("all"); const [statusFilter, setStatusFilter] = useState(initialStatus); + const [page, setPage] = useState(1); const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? ""); const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: ["pipeline-queue", projectId, statusFilter], + queryKey: ["pipeline-queue", projectId, statusFilter, page], queryFn: async () => { - const params = new URLSearchParams({ limit: "200" }); + const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) }); 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 } }>; + return res.json() as Promise<{ + exports: QueueExport[]; + pagination: { page: number; total: number; pages: number }; + statusCounts: Record; + }>; + }, + refetchInterval: 5000, + // Keep the current page on screen while the next one loads, instead of + // flashing "Loading…" on every page turn. + placeholderData: (previous) => previous, + }); + + // Fetched here rather than in the tab so the tab label can say how many are + // running: a Blender render must not become invisible by sitting in a tab. + const { data: blenderData } = useQuery({ + queryKey: ["blender-jobs", projectId], + queryFn: async () => { + const qs = projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const res = await fetch(`/api/render/blender${qs}`); + if (!res.ok) throw new Error("Failed to load Blender jobs"); + return res.json() as Promise<{ jobs: BlenderJobRow[] }>; }, 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, - }; + const pagination = data?.pagination; + const statusCounts = data?.statusCounts ?? {}; + const countOf = (statuses: string[]) => statuses.reduce((n, s) => n + (statusCounts[s] ?? 0), 0); + const totalExports = Object.values(statusCounts).reduce((n, c) => n + c, 0); + const blenderJobs = blenderData?.jobs ?? []; + const blenderActive = blenderJobs.filter((j) => BLENDER_ACTIVE.includes(j.status)).length; + const blenderFailed = blenderJobs.filter((j) => ["FAILED", "EXPIRED"].includes(j.status)).length; async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") { const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" }); @@ -96,30 +122,56 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) 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" }, + const cards: { label: string; value: number; status?: string; className?: string }[] = [ + { label: "Queued", value: countOf(["QUEUED"]), status: "QUEUED" }, + { label: "Rendering", value: countOf(["RENDERING"]), status: "RENDERING", className: "text-blue-400" }, + // Several statuses count as failed and the filter takes one, so this card + // is a figure only. + { label: "Failed", value: countOf(FAILED_STATUSES), className: "text-red-400" }, + { label: "Ready for QC", value: countOf(["READY_FOR_QC"]), status: "READY_FOR_QC", className: "text-amber-400" }, + { + label: "Ready for Delivery", + value: countOf(["READY_FOR_DELIVERY"]), + status: "READY_FOR_DELIVERY", + className: "text-green-400", + }, ]; + const changeProject = (id: string) => { + setProjectId(id); + setPage(1); + }; + const changeStatus = (status: string) => { + setStatusFilter(status); + setPage(1); + }; + + const firstRow = pagination && pagination.total > 0 ? (pagination.page - 1) * PAGE_SIZE + 1 : 0; + const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0; + return (

Render Queue

- Exports queued from the AE panel — live status, retries and history + After Effects exports and Blender renders — live status, retries and history

-
- - - +
+
- {/* Summary cards */} -
- {cards.map((c) => ( -
-
{c.value}
-
{c.label}
-
- ))} -
- - {/* Filters */} -
- - -
- - {/* Table */} -
- - - - - - - - - - - - - - - {isLoading ? ( - - - - ) : exports.length === 0 ? ( - - - + + + + After Effects exports + {totalExports} + + + Blender renders + {blenderActive > 0 ? ( + + {blenderActive} active + + ) : blenderFailed > 0 ? ( + + {blenderFailed} failed + ) : ( - exports.map((e) => ( - - - - - - - - - - - )) + {blenderJobs.length} )} - -
ShotVersionStatusProgressMachineAttemptQueuedActions
- Loading… -
- No exports yet — queue one from the After Effects panel -
- - {e.shotCode} - -
- {e.projectCode} - {e.episode ? ` · ep ${e.episode}` : ""} -
-
{e.versionString} - - {statusLabel(e.status)} - - - {e.status === "RENDERING" && e.job ? ( -
- -
- {e.job.currentFrame != null && e.job.totalFrames != null - ? `frame ${e.job.currentFrame}/${e.job.totalFrames} · ` - : ""} - ETA {formatEta(e.job.etaSeconds)} -
-
- ) : ( - — - )} -
{e.job?.machineName ?? "—"} - {e.job ? `${e.job.attempt}` : "—"} - - {new Date(e.createdAt).toLocaleString()} - -
- {FAILED_STATUSES.includes(e.status) && ( - - )} - {ACTIVE_STATUSES.includes(e.status) && ( - - )} - {isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && ( - - )} -
-
-
+ + - + + {/* Summary cards — each one with a status filters the table to what it counts */} +
+ {cards.map((c) => { + const selected = c.status !== undefined && statusFilter === c.status; + const body = ( + <> +
{c.value}
+
{c.label}
+ + ); + return c.status ? ( + + ) : ( +
+ {body} +
+ ); + })} +
+ + + +
+ + + + + + + + + + + + + + + {isLoading ? ( + + + + ) : exports.length === 0 ? ( + + + + ) : ( + exports.map((e) => ( + + + + + + + + + + + )) + )} + +
ShotVersionStatusProgressMachineAttemptQueuedActions
+ Loading… +
+ {statusFilter === "all" + ? "No exports yet — queue one from the After Effects panel" + : `No exports are ${statusLabel(statusFilter).toLowerCase()}`} +
+ + {e.shotCode} + +
+ {e.projectCode} + {e.episode ? ` · ep ${e.episode}` : ""} +
+
{e.versionString} + + {statusLabel(e.status)} + + + {e.status === "RENDERING" && e.job ? ( +
+ +
+ {e.job.currentFrame != null && e.job.totalFrames != null + ? `frame ${e.job.currentFrame}/${e.job.totalFrames} · ` + : ""} + ETA {formatEta(e.job.etaSeconds)} +
+
+ ) : ( + — + )} +
{e.job?.machineName ?? "—"}{e.job ? `${e.job.attempt}` : "—"} + {new Date(e.createdAt).toLocaleString()} + +
+ {FAILED_STATUSES.includes(e.status) && ( + + )} + {ACTIVE_STATUSES.includes(e.status) && ( + + )} + {isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && ( + + )} +
+
+
+ + {pagination && pagination.pages > 1 && ( +
+ + {firstRow}–{lastRow} of {pagination.total} + +
+ + + Page {pagination.page} of {pagination.pages} + + +
+
+ )} +
+ + + + +
); } @@ -320,24 +426,13 @@ const BLENDER_STATUS_STYLES: Record = { /** * Standalone Blender renders. They have no Export behind them, so they cannot - * appear in the table above — but they share the queue, and a render nobody can - * see is a render nobody can retry. + * appear in the exports table — but they share the queue, and a render nobody + * can see is a render nobody can retry. */ -function BlenderQueue({ projectId }: { projectId: string }) { +function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) { const { toast } = useToast(); const queryClient = useQueryClient(); - const { data } = useQuery({ - queryKey: ["blender-jobs", projectId], - queryFn: async () => { - const qs = projectId && projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : ""; - const res = await fetch(`/api/render/blender${qs}`); - if (!res.ok) throw new Error("Failed to load Blender jobs"); - return res.json() as Promise<{ jobs: BlenderJobRow[] }>; - }, - refetchInterval: 5000, - }); - async function jobAction(id: string, verb: "cancel" | "retry") { const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" }); if (!res.ok) { @@ -347,14 +442,16 @@ function BlenderQueue({ projectId }: { projectId: string }) { queryClient.invalidateQueries({ queryKey: ["blender-jobs"] }); } - const jobs = data?.jobs ?? []; - if (jobs.length === 0) return null; + if (jobs.length === 0) { + return ( +
+ {loaded ? "No Blender renders yet — submit one from the Blender panel" : "Loading…"} +
+ ); + } return (
-
- Blender renders -
{jobs.map((j) => (
diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index 3062bb9..2eb636d 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -515,14 +515,20 @@ export interface ListExportsFilter { export async function listExports(filter: ListExportsFilter) { const page = Math.max(1, filter.page ?? 1); const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); - const where: Prisma.ExportWhereInput = { + const scope: Prisma.ExportWhereInput = { ...(filter.projectId ? { projectId: filter.projectId } : {}), - ...(filter.status ? { status: filter.status } : {}), ...(filter.shotId ? { shotId: filter.shotId } : {}), ...(filter.episode ? { shot: { episode: filter.episode } } : {}), }; - const [total, rows] = await Promise.all([ + const where: Prisma.ExportWhereInput = { + ...scope, + ...(filter.status ? { status: filter.status } : {}), + }; + const [total, byStatus, rows] = await Promise.all([ db.export.count({ where }), + // Counted across the whole scope, ignoring the status filter and paging: + // summary figures taken from the loaded page would count only that page. + db.export.groupBy({ by: ["status"], where: scope, _count: { _all: true } }), db.export.findMany({ where, orderBy: { createdAt: "desc" }, @@ -540,6 +546,9 @@ export async function listExports(filter: ListExportsFilter) { ]); return { pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + statusCounts: Object.fromEntries(byStatus.map((g) => [g.status, g._count._all])) as Partial< + Record + >, exports: rows.map((e) => ({ id: e.id, shotCode: e.shot.shotCode,