"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; // --------------------------------------------------------------------------- // Running a build, and watching it per clip. // // BuildChain's step boxes are the right model for the chain -- preflight, // resolve, build, verify -- but the build step alone is twenty minutes of work // on nineteen clips, and "step 3 of 4, running" is not progress. So the build // step also renders a grid: one box per timeline entry, lit by the NDJSON events // the pipeline emits (fetch / snap / segment / entry-failed). // // Polling, not streaming, like every other job here. // --------------------------------------------------------------------------- type StepView = { label: string; argv: string[]; cwd: string; timeoutMs: number | null }; type Ev = Record & { ev: string; id?: string }; type JobView = { id: string; state: "running" | "done" | "failed"; stepIndex: number; steps: StepView[]; error: string | null; log: string[]; next: number; events: Ev[]; nextEvent: number; }; type Preset = { id: string; label: string }; const CLIP_STATE = { pending: "border-[var(--color-line)] text-[var(--color-dim)]", fetching: "border-[var(--color-meter)] text-[var(--color-meter)]", cutting: "border-[var(--color-sel)] text-[var(--color-sel)]", done: "border-[var(--color-good)] text-[var(--color-good)]", failed: "border-[var(--color-bad)] text-[var(--color-bad)]", } as const; type ClipState = keyof typeof CLIP_STATE; export default function ReportBuildChain({ project, entries, }: { project: string; entries: { id: string; kind: string }[]; }) { const [presets, setPresets] = useState([]); const [variants, setVariants] = useState([]); const [preset, setPreset] = useState("fast"); const [only, setOnly] = useState(""); // The options the pipeline has and the driver used to hide. The dry-run // argv panel is the proof of what each one does. const [variant, setVariant] = useState(""); const [noXfade, setNoXfade] = useState(false); const [chaptersOnly, setChaptersOnly] = useState(false); const [previewOn, setPreviewOn] = useState(false); const [previewAt, setPreviewAt] = useState("0"); const [previewDur, setPreviewDur] = useState("20"); const [dry, setDry] = useState(null); const [job, setJob] = useState(null); const [events, setEvents] = useState([]); const [error, setError] = useState(null); const [needsReplace, setNeedsReplace] = useState(false); const [busy, setBusy] = useState(false); const since = useRef(0); const sinceEvent = useRef(0); useEffect(() => { void fetch("/api/report/build", { cache: "no-store" }) .then((r) => r.json()) .then((j) => { setPresets(j.presets ?? []); setVariants(j.variants ?? []); // Adopt a build already running, so a reload does not lose it. if (j.running) { setJob(j.running as JobView); setEvents((j.running as JobView).events ?? []); } }) .catch(() => {}); }, []); useEffect(() => { if (!job || job.state !== "running") return; const t = setInterval(async () => { const r = await fetch( `/api/report/build?job=${job.id}&since=${since.current}&sinceEvent=${sinceEvent.current}`, { cache: "no-store" }, ); if (!r.ok) return; const j = (await r.json()) as JobView; since.current = j.next; sinceEvent.current = j.nextEvent; setEvents((prev) => [...prev, ...(j.events ?? [])]); setJob((prev) => (prev ? { ...j, log: [...prev.log, ...j.log] } : j)); }, 800); return () => clearInterval(t); }, [job]); const post = useCallback( async (qs: string, extra: Record = {}) => { setBusy(true); setError(null); const options: Record = {}; if (variant) options.variant = variant; if (noXfade) options.xfade = false; if (chaptersOnly) options.chaptersOnly = true; if (previewOn) options.preview = { at: Number(previewAt), dur: Number(previewDur) }; const r = await fetch(`/api/report/build${qs}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, preset, only: only || null, options, ...extra }), }); const j = (await r.json()) as Record; setBusy(false); if (!r.ok) { setError(String(j.error ?? r.status)); setNeedsReplace(!!j.needsReplace); return null; } setNeedsReplace(false); return j; }, [project, preset, only, variant, noXfade, chaptersOnly, previewOn, previewAt, previewDur], ); const clipStates = new Map(); for (const e of events) { const id = e.id as string | undefined; if (!id) continue; if (e.ev === "fetch") clipStates.set(id, e.cached ? "cutting" : "fetching"); else if (e.ev === "clip" || e.ev === "card") clipStates.set(id, "cutting"); else if (e.ev === "snap") clipStates.set(id, "cutting"); else if (e.ev === "segment") clipStates.set(id, "done"); else if (e.ev === "entry-failed") clipStates.set(id, "failed"); } const failed = events.filter((e) => e.ev === "entry-failed"); return (
build {preset === "preview" && ( )} {job?.state === "running" && ( )}
{/* The options row. Each maps to one flag of build-video.mjs; "show the command" is where to see it land. */}
{previewOn && ( at setPreviewAt(e.target.value)} data-option="preview-at" className="w-16 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1 py-0.5 text-[11px]" /> s for setPreviewDur(e.target.value)} data-option="preview-dur" className="w-16 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1 py-0.5 text-[11px]" /> s )}
{error && (

{error} {needsReplace && ( <> {" "} )}

)} {dry && !job && (
          {dry.map((s) => `# ${s.label}\n${s.argv.join(" ")}\n`).join("\n")}
        
)} {job && (
    {job.steps.map((s, i) => (
  1. {s.label}
  2. ))}
{/* One box per entry, lit by the pipeline's own events. "Step 3 of 4, running" is not progress when step 3 is twenty minutes long. */}
{entries.map((e) => { const st = clipStates.get(e.id) ?? "pending"; return ( {e.id} ); })}
{failed.length > 0 && (

{failed.length} entr{failed.length === 1 ? "y" : "ies"} failed ( {failed.map((f) => String(f.id)).join(", ")}). Everything buildable was built and the segments are kept — but the timeline was NOT concatenated, because a finished file quietly missing a citation looks complete.

)}
            {job.log.slice(-120).join("\n")}
          
{job.state} {job.error ? ` — ${job.error}` : ""}
)}
); }