diff --git a/bin/burnlist.mjs b/bin/burnlist.mjs index 976df75..37aa9bf 100755 --- a/bin/burnlist.mjs +++ b/bin/burnlist.mjs @@ -98,7 +98,7 @@ Usage: burnlist differential-testing sdk burnlist streaming-diff ... burnlist hooks [install|uninstall|status] [--agent codex,claude] [--untracked] (bare defaults to status) - burnlist oven ... + burnlist oven ... burnlist new [--repo ] burnlist show [#] [--repo ] burnlist ready [--repo ] diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2d84fd1..f01d4ec 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,16 +1,19 @@ import { useMemo, useState } from "react"; import { ListChecks } from "lucide-react"; -import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, ModelLabPage, NewOvenPage, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; +import { AppHeader, BurnlistTable, ChecklistOvenView, CustomOvenView, DashboardError, DifferentialTestingOvenPage, EmptyState, FILTERS, Filters, LensSwitcher, ModelLabPage, NewOvenPage, OvenCatalog, OvenDefinition, OvenExplainer, PerformanceTracingOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components"; import { useDashboardData } from "@hooks"; -import { currentSection, filterFromUrl, selectedBurnlist } from "@lib"; +import { checklistOvenRepoKey, currentSection, filterFromUrl, ovenRepoKey, selectedBurnlist } from "@lib"; import type { Filter } from "@lib"; +import { Button } from "@layout"; export function App() { const section = currentSection(); const selected = useMemo(selectedBurnlist, [window.location.pathname, window.location.search]); + const repoKey = ovenRepoKey(); const [filter, setFilter] = useState(() => filterFromUrl(FILTERS)); - const dashboardSection = section === "streaming-diff" ? "burnlists" : section; + const dashboardSection = ["landing", "burnlist", "streaming-diff"].includes(section) ? "burnlists" : section; const { projects, progress, error, loading } = useDashboardData({ section: dashboardSection, selected }); + const checklistRepoKey = checklistOvenRepoKey(progress, selected); const visibleBurnlistCount = projects.reduce((total, project) => total + project.entries.filter((entry) => filter === "all" || entry.status === filter).length, 0); const visibleProjectCount = projects.filter((project) => project.entries.some((entry) => filter === "all" || entry.status === filter)).length; @@ -29,9 +32,9 @@ export function App() {
- {section === "differential-testing" ? : section === "model-lab" ? : section === "performance-tracing" ? : section === "streaming-diff" ? : section === "visual-parity" ? : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : selected ? ( + {section === "differential-testing" ? {(ir) => } : section === "model-lab" ? {(ir) => } : section === "performance-tracing" ? {(ir) => } : section === "streaming-diff" ? {(ir) => } : section === "visual-parity" ? {(ir) => } : section === "custom-oven" ? : section === "new-oven" ? : section === "run-burn" ? : section === "ovens-catalog" ? : section === "oven-explainer" ? : selected ? ( error ? : loading && !progress ? : progress ? ( - + <>{(ir) => } ) : ) : (
@@ -40,7 +43,10 @@ export function App() {

Burnlists

{visibleBurnlistCount} Burnlists in {visibleProjectCount} {visibleProjectCount === 1 ? "project" : "projects"}

- +
+ + +
{error ? : projects.length && visibleBurnlistCount ? (
{projects.map((project) => )}
diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx index 156ccb7..65e8dfb 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistOvenView.tsx @@ -1,15 +1,15 @@ import { useEffect, useMemo } from "react"; +import type { ResolvedOvenIr } from "@hooks"; import type { ChecklistProgressData } from "@lib"; import { adaptChecklist } from "@lib/checklist-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; -import ovenIr from "../../../../ovens/checklist/checklist.ir.json"; import "./ChecklistDashboard.css"; -export function ChecklistOvenView({ data }: { data: ChecklistProgressData }) { +export function ChecklistOvenView({ data, ir }: { data: ChecklistProgressData; ir: ResolvedOvenIr }) { const payload = useMemo(() => adaptChecklist(data), [data]); useEffect(() => { document.body.classList.add("driving-parity-view", "checklist-detail-view"); return () => document.body.classList.remove("driving-parity-view", "checklist-detail-view"); }, []); - return ; + return ; } diff --git a/dashboard/src/components/CopyButton/CopyButton.tsx b/dashboard/src/components/CopyButton/CopyButton.tsx new file mode 100644 index 0000000..b3e1001 --- /dev/null +++ b/dashboard/src/components/CopyButton/CopyButton.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; + +export function CopyButton({ text }: { text: string }) { + const [isCopied, setIsCopied] = useState(false); + const resetTimer = useRef>(); + + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const copy = async () => { + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard; + if (!clipboard?.writeText) return; + try { + await clipboard.writeText(text); + setIsCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setIsCopied(false), 1500); + } catch { + // Clipboard access may be unavailable outside a secure browser context. + } + }; + + return ( + + ); +} diff --git a/dashboard/src/components/CopyButton/index.ts b/dashboard/src/components/CopyButton/index.ts new file mode 100644 index 0000000..10bb060 --- /dev/null +++ b/dashboard/src/components/CopyButton/index.ts @@ -0,0 +1 @@ +export { CopyButton } from "./CopyButton"; diff --git a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx index 2b4e2a5..d67448c 100644 --- a/dashboard/src/components/CustomOvenView/CustomOvenView.tsx +++ b/dashboard/src/components/CustomOvenView/CustomOvenView.tsx @@ -1,8 +1,11 @@ import { useEffect, useState, type ComponentProps } from "react"; import { customOvenSelection } from "@lib"; +import { adaptChecklist } from "@lib/checklist-adapter"; +import type { ChecklistProgressData } from "@lib"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; import { DashboardError } from "../DashboardError"; import { EmptyState } from "../EmptyState"; +import { LensSwitcher } from "../LensSwitcher"; type OvenIr = ComponentProps["ir"]; type LoadedOven = { ir: OvenIr; payload: unknown }; @@ -11,6 +14,13 @@ function unwrapPayload(raw: unknown) { return raw && typeof raw === "object" && "payload" in raw ? (raw as { payload: unknown }).payload : raw; } +export function CustomOvenRuntime({ burnlistId, loaded }: { burnlistId?: string; loaded: LoadedOven }) { + if (burnlistId) { + return <>; + } + return ; +} + export function CustomOvenView() { const selection = customOvenSelection(); const [loaded, setLoaded] = useState(null); @@ -30,13 +40,15 @@ export function CustomOvenView() { try { const [ovenResponse, dataResponse] = await Promise.all([ fetch(`/api/ovens/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), - fetch(`/api/oven-data/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), + fetch(selection.burnlistId + ? `/api/progress?repoKey=${encodeURIComponent(selection.repoKey)}&id=${encodeURIComponent(selection.burnlistId)}` + : `/api/oven-data/${selection.id}${query}`, { cache: "no-store", signal: controller.signal }), ]); if (!ovenResponse.ok) throw new Error(`Could not load Oven (${ovenResponse.status}).`); - if (!dataResponse.ok) throw new Error(`Could not load Oven data (${dataResponse.status}).`); + if (!dataResponse.ok) throw new Error(`Could not load ${selection.burnlistId ? "Burnlist progress" : "Oven data"} (${dataResponse.status}).`); const [ovenBody, dataBody] = await Promise.all([ovenResponse.json(), dataResponse.json()]); if (controller.signal.aborted) return; - setLoaded({ ir: ovenBody.oven.ir, payload: dataBody.payload }); + setLoaded({ ir: ovenBody.oven.ir, payload: selection.burnlistId ? adaptChecklist(dataBody as ChecklistProgressData) : dataBody.payload }); } catch (cause) { if (controller.signal.aborted) return; setError(cause instanceof Error ? cause.message : "Could not load the custom Oven."); @@ -44,9 +56,9 @@ export function CustomOvenView() { }; void load(); return () => controller.abort(); - }, [selection?.id, selection?.repoKey]); + }, [selection?.burnlistId, selection?.id, selection?.repoKey]); if (error) return ; if (!loaded) return ; - return ; + return ; } diff --git a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs index 4772656..d240bcf 100644 --- a/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs +++ b/dashboard/src/components/CustomOvenView/custom-oven-render.test.mjs @@ -2,27 +2,26 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; -import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { build } from "esbuild"; import { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; -const runtimePath = new URL("../../oven/runtime/OvenRuntime.tsx", import.meta.url).pathname; +const componentPath = new URL("./CustomOvenView.tsx", import.meta.url).pathname; const sourceDir = new URL("../../", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; const ovenPath = new URL("../../oven", import.meta.url).pathname; -const ovenSource = ` +const ovenSource = ` `; -test("a custom Oven runtime renders author-shaped data values", { timeout: 20_000 }, async () => { +test("custom Oven runtime modes preserve live standalone polling and controlled Burnlist data", { timeout: 20_000 }, async () => { const outputDir = await mkdtemp(join(process.cwd(), ".custom-oven-render-test-")); try { - const runtimeOutput = join(outputDir, "OvenRuntime.mjs"); + const runtimeOutput = join(outputDir, "CustomOvenView.mjs"); await build({ - entryPoints: [runtimePath], + entryPoints: [componentPath], bundle: true, format: "esm", outfile: runtimeOutput, @@ -32,17 +31,30 @@ test("a custom Oven runtime renders author-shaped data values", { timeout: 20_00 packages: "external", target: "node18", }); - const { OvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); + const { CustomOvenRuntime } = await import(`${new URL(`file://${runtimeOutput}`).href}?test=${Date.now()}`); const compiled = compileOven(ovenSource, { file: "widget-oven.oven" }); assert.equal(compiled.ok, true, compiled.ok ? "" : JSON.stringify(compiled.diagnostics)); if (!compiled.ok) return; - const markup = renderToStaticMarkup(createElement(OvenRuntime, { - ir: compiled.ir, - payload: { widget: { name: "Sprockets", count: 42 } }, - })); + const payload = { widget: { name: "Sprockets", count: 42 } }; + const ir = { ...compiled.ir, refreshSeconds: 7 }; + const standalone = CustomOvenRuntime({ loaded: { ir, payload } }); + assert.equal(standalone.props.ir, ir); + assert.equal(standalone.props.initialPayload, payload); + assert.equal("payload" in standalone.props, false); + assert.equal(standalone.props.ir.refreshSeconds, 7); + assert.equal(typeof standalone.props.adapt, "function"); + + const markup = renderToStaticMarkup(standalone); assert.match(markup, /Sprockets/u); assert.match(markup, />42 ({ namespace: "test-oven-ir", path: resolve(dirname(args.importer), args.path) })); - context.onLoad({ filter: /\.ir\.json$/, namespace: "test-oven-ir" }, async (args) => { - const sourcePath = args.path.replace(/\.ir\.json$/u, ".oven"); - const compiled = compileOven(await readFile(sourcePath, "utf8"), { file: sourcePath }); - if (!compiled.ok) throw new Error(JSON.stringify(compiled.diagnostics)); - return { contents: JSON.stringify(compiled.ir), loader: "json" }; - }); - }, -}; +async function ovenIr(id) { + const path = new URL(`../../../../ovens/${id}/${id}.oven`, import.meta.url); + const compiled = compileOven(await readFile(path, "utf8"), { file: path.pathname }); + if (!compiled.ok) throw new Error(JSON.stringify(compiled.diagnostics)); + return compiled.ir; +} test("live Differential Testing pages retain the scoped shell required by their dashboard CSS", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".differential-testing-oven-test-")); @@ -37,12 +31,11 @@ test("live Differential Testing pages retain the scoped shell required by their alias: { "@": sourcePath, "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", - plugins: [ovenIrPlugin], target: "node18", }); const { DifferentialTestingOvenPage, PerformanceTracingOvenPage } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); - const differential = renderToStaticMarkup(createElement(DifferentialTestingOvenPage)); - const performance = renderToStaticMarkup(createElement(PerformanceTracingOvenPage)); + const differential = renderToStaticMarkup(createElement(DifferentialTestingOvenPage, { ir: await ovenIr("differential-testing") })); + const performance = renderToStaticMarkup(createElement(PerformanceTracingOvenPage, { ir: await ovenIr("performance-tracing") })); assert.match(differential, /^
Loading…<\/div><\/div>$/u); assert.match(performance, /^
Loading…<\/div><\/div>$/u); diff --git a/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx b/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx index f511e41..930ed8c 100644 --- a/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx +++ b/dashboard/src/components/DifferentialTestingOven/DifferentialTestingOven.tsx @@ -1,10 +1,9 @@ import { useEffect, type ReactNode } from "react"; +import type { ResolvedOvenIr } from "@hooks"; import { adaptPerformanceTracingReport } from "@lib"; import { adaptDifferentialTesting, type DifferentialTestingData } from "../../lib/differential-testing-adapter"; import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; import { withDifferentialTestingEnvelope } from "@/oven/runtime/oven-payload-metadata"; -import differentialTestingIr from "../../../../ovens/differential-testing/differential-testing.ir.json"; -import performanceTracingIr from "../../../../ovens/performance-tracing/performance-tracing.ir.json"; type ResponseEnvelope = { payload: unknown; fieldPage?: unknown; frameDeltaMetrics?: unknown }; @@ -33,10 +32,10 @@ function DifferentialTestingShell({ children, performanceTracing = false }: { ch return
{children}
; } -export function DifferentialTestingOvenPage() { - return ; +export function DifferentialTestingOvenPage({ ir }: { ir: ResolvedOvenIr }) { + return ; } -export function PerformanceTracingOvenPage() { - return ; +export function PerformanceTracingOvenPage({ ir }: { ir: ResolvedOvenIr }) { + return ; } diff --git a/dashboard/src/components/LensSwitcher/LensSwitcher.css b/dashboard/src/components/LensSwitcher/LensSwitcher.css new file mode 100644 index 0000000..e98f117 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/LensSwitcher.css @@ -0,0 +1,20 @@ +.lens-switcher { + display: flex; + gap: 0.35rem; + margin: 0 0 1rem; +} + +.lens-switcher-link { + border: 1px solid var(--border, #d1d5db); + border-radius: 999px; + color: inherit; + font-size: 0.8125rem; + padding: 0.35rem 0.65rem; + text-decoration: none; +} + +.lens-switcher-link.is-active { + background: var(--accent, #2563eb); + border-color: var(--accent, #2563eb); + color: #fff; +} diff --git a/dashboard/src/components/LensSwitcher/LensSwitcher.tsx b/dashboard/src/components/LensSwitcher/LensSwitcher.tsx new file mode 100644 index 0000000..91be153 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/LensSwitcher.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { BURNLIST_DATA_CONTRACT, burnlistLensContext, burnlistOvenHref, fittingOvens } from "@lib"; +import type { OvenSummary } from "@lib"; +import "./LensSwitcher.css"; + +export function LensSwitcher() { + const context = burnlistLensContext(); + const [ovens, setOvens] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!context) return; + const controller = new AbortController(); + const load = async () => { + setOvens(null); + setFailed(false); + try { + const response = await fetch("/api/ovens", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("Could not load Ovens."); + const payload = await response.json() as { ovens?: OvenSummary[] }; + if (!controller.signal.aborted) setOvens(Array.isArray(payload.ovens) ? payload.ovens : []); + } catch { + if (!controller.signal.aborted) setFailed(true); + } + }; + void load(); + return () => controller.abort(); + }, [context?.repoKey, context?.burnlistId]); + + if (!context || !ovens || failed) return null; + const lenses = fittingOvens(ovens, BURNLIST_DATA_CONTRACT, { repoKey: context.repoKey }); + if (!lenses.length) return null; + + return ( + + ); +} diff --git a/dashboard/src/components/LensSwitcher/index.ts b/dashboard/src/components/LensSwitcher/index.ts new file mode 100644 index 0000000..99a83e8 --- /dev/null +++ b/dashboard/src/components/LensSwitcher/index.ts @@ -0,0 +1 @@ +export { LensSwitcher } from "./LensSwitcher"; diff --git a/dashboard/src/components/ModelLab/ModelLab.test.mjs b/dashboard/src/components/ModelLab/ModelLab.test.mjs new file mode 100644 index 0000000..ba2001a --- /dev/null +++ b/dashboard/src/components/ModelLab/ModelLab.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; +import { compileOven } from "../../../../src/ovens/dsl/oven-compile.mjs"; + +const componentPath = new URL("./ModelLab.tsx", import.meta.url).pathname; +const sourceDir = new URL("../..", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("../../oven", import.meta.url).pathname; +const shippedSourcePath = new URL("../../../../ovens/model-lab/model-lab.oven", import.meta.url); + +const payload = { + schema: "burnlist-model-lab-data@1", + generatedAt: "2026-07-22T10:00:00Z", + project: { id: "fixture", label: "Fixture Project" }, + surface: { title: "Fixture Model Lab", url: "http://127.0.0.1:5173/model-lab.html" }, + model: { + id: "player-a", + actor: { id: "actor-a", name: "Ada", country: "DE", shirtNumber: 7, sourceTeamSlot: "A" }, + animations: [{ id: "idle", slotId: 0, symbol: "idle", firstFrameIndex: 0, firstFrameId: "idle-0", frameCount: 2 }], + frameIndex: 0, + frameId: "idle-0", + frameCount: 2, + polygonCount: 12, + leafCount: 4, + leafTag: "s", + topologyMode: "stable-frame-set", + lodCount: 1, + droppedSourcePolygonCount: 0, + topologyHash: "a".repeat(64), + frameSetHash: "b".repeat(64), + runtimeConstruction: { + assetBuildCount: 0, + geometryBuildCount: 0, + materialBuildCount: 0, + sourceParseCount: 0, + topologyBuildCount: 0, + }, + }, + evidence: { + manifestSha256: "c".repeat(64), + renderPublicationSha256: "d".repeat(64), + prepareInputsSha256: "e".repeat(64), + }, +}; + +async function compile(source, file) { + const result = compileOven(source, { file }); + assert.equal(result.ok, true, result.ok ? "" : JSON.stringify(result.diagnostics)); + return result.ir; +} + +async function loadComponent() { + const outputDir = await mkdtemp(join(process.cwd(), ".model-lab-render-test-")); + const outputPath = join(outputDir, "ModelLab.mjs"); + await build({ + entryPoints: [componentPath], + bundle: true, + format: "esm", + outfile: outputPath, + platform: "node", + alias: { "@": sourceDir, "@lib": libPath, "@oven": ovenPath }, + jsx: "automatic", + packages: "external", + target: "node18", + }); + return { + component: await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`), + cleanup: () => rm(outputDir, { force: true, recursive: true }), + }; +} + +test("the shipped Model Lab source compiles to the closed specialized widget", async () => { + const source = await readFile(shippedSourcePath, "utf8"); + const ir = await compile(source, shippedSourcePath.pathname); + assert.equal(ir.root.length, 1); + assert.equal(ir.root[0].kind, "model-lab-view"); + assert.equal(ir.root[0].attributes.source, "/"); + assert.deepEqual(ir.requirements.components, ["model-lab-view"]); +}); + +test("the actual Model Lab page follows its resolved IR", { timeout: 20_000 }, async () => { + const { component, cleanup } = await loadComponent(); + try { + const shippedIr = await compile(await readFile(shippedSourcePath, "utf8"), shippedSourcePath.pathname); + const alternateIr = await compile(` + + + + `, "vendored/model-lab.oven"); + const render = (ir) => renderToStaticMarkup(createElement(component.ModelLabPageContent, { + error: "", + ir, + loading: false, + payload, + })); + + const shipped = render(shippedIr); + assert.match(shipped, /class="model-lab-oven"/u); + assert.match(shipped, /Fixture Project · player-a/u); + assert.match(shipped, /src="http:\/\/127\.0\.0\.1:5173\/model-lab\.html\?embedded=1&model=player-a&frame=0"/u); + + const vendored = render(alternateIr); + assert.match(vendored, /Vendored Model Lab layout/u); + assert.doesNotMatch(vendored, /class="model-lab-oven"/u); + assert.doesNotMatch(vendored, /Fixture Project · player-a/u); + } finally { + await cleanup(); + } +}); + +test("Model Lab polling keeps repository scoping in its data URL", async () => { + const { component, cleanup } = await loadComponent(); + try { + assert.equal(component.MODEL_LAB_POLL_MS, 2_000); + assert.equal(component.modelLabDataUrl(null), "/api/oven-data/model-lab"); + assert.equal(component.modelLabDataUrl("repo/key"), "/api/oven-data/model-lab?repoKey=repo%2Fkey"); + } finally { + await cleanup(); + } +}); diff --git a/dashboard/src/components/ModelLab/ModelLab.tsx b/dashboard/src/components/ModelLab/ModelLab.tsx index 71936b4..61c46df 100644 --- a/dashboard/src/components/ModelLab/ModelLab.tsx +++ b/dashboard/src/components/ModelLab/ModelLab.tsx @@ -1,159 +1,34 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useState, type ComponentProps } from "react"; import { ovenRepoKey } from "@lib"; +import { OvenRuntime } from "@/oven/runtime/OvenRuntime"; +import type { ModelLabPayload } from "@/oven/ModelLabView"; -type RuntimeConstruction = { - assetBuildCount: number; - geometryBuildCount: number; - materialBuildCount: number; - sourceParseCount: number; - topologyBuildCount: number; -}; +type ModelLabIr = ComponentProps["ir"]; -type ModelAnimation = { - id: string; - slotId: number; - symbol: string; - firstFrameIndex: number; - firstFrameId: string; - frameCount: number; -}; +export const MODEL_LAB_POLL_MS = 2_000; -type ComparisonImage = { - url: string; - sha256: string; - width: number; - height: number; -}; - -type ModelLabComparison = { - schema: "burnlist-model-lab-comparison@1"; - frameId: string; - referenceLabel: "Native"; - candidateLabel: "Model Lab"; - channelThreshold: number; - pass: boolean; - reportSha256: string; - angles: Array<{ - angle: 0 | 45 | 180; - native: ComparisonImage; - candidate: ComparisonImage; - diff: ComparisonImage; - metrics: { - meanAbsDelta: number; - rmsDelta: number; - maxAbsDelta: number; - changedPixelRatio: number; - pass: boolean; - }; - }>; -}; - -type ModelLabPayload = { - schema: "burnlist-model-lab-data@1"; - generatedAt: string; - project: { id: string; label: string }; - surface: { title: string; url: string }; - model: { - id: string; - actor: { - id: string; - name: string; - country: string; - shirtNumber: number; - sourceTeamSlot: "A" | "B"; - }; - animations: ModelAnimation[]; - frameIndex: number; - frameId: string; - frameCount: number; - polygonCount: number; - leafCount: number; - leafTag: "s"; - topologyMode: "stable-frame-set"; - lodCount: 1; - droppedSourcePolygonCount: number; - topologyHash: string; - frameSetHash: string; - runtimeConstruction: RuntimeConstruction; - }; - evidence: { - manifestSha256: string; - renderPublicationSha256: string; - prepareInputsSha256: string; - }; - comparison?: ModelLabComparison; -}; - -type RuntimeStats = { - ready: boolean; - modelId: string; - frameIndex: number; - frameId: string; - frameCount: number; - leafCount: number; - visibleLeafCount: number; - renderedLeafCount: number; - stableRootIdentity: boolean; - stableLeafIdentityCount: number; - connectedLeafCount: number; - childListMutationCount: number; - droppedSourcePolygonCount: number; - leafTags: string[]; - runtimeConstruction: RuntimeConstruction; -}; - -type RuntimeMessage = { - schema: "polycss-model-lab-state@1"; - status: "ready" | "error"; - stats?: RuntimeStats; - error?: string; -}; - -const MODEL_LAB_COMMAND_SCHEMA = "polycss-model-lab-command@1"; - -function shortHash(value: string) { - return `${value.slice(0, 10)}…${value.slice(-8)}`; -} - -function zeroRuntimeConstruction(value: RuntimeConstruction | undefined) { - return Boolean(value) && Object.values(value!).every((count) => count === 0); +export function modelLabDataUrl(repoKey: string | null) { + return `/api/oven-data/model-lab${repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : ""}`; } -function metric(label: string, value: string, tone = "") { - return ( -
- {label} - {value} -
- ); -} - -function comparisonFigure( - label: string, - image: ComparisonImage, - detail?: string, -) { - return ( -
-
- {label} - {detail && {detail}} -
- {label} -
- ); +export function ModelLabPageContent({ error, ir, loading, payload }: { + error: string; + ir: ModelLabIr; + loading: boolean; + payload: ModelLabPayload | null; +}) { + if (loading && !payload) { + return
Loading the bound Model Lab surface…
; + } + if (error && !payload) { + return
{error}
; + } + if (!payload) return null; + return ; } -export function ModelLabPage() { - const iframe = useRef(null); +export function ModelLabPage({ ir }: { ir: ModelLabIr }) { const [payload, setPayload] = useState(null); - const [runtime, setRuntime] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); @@ -164,9 +39,7 @@ export function ModelLabPage() { if (inFlight) return; inFlight = true; try { - const repoKey = ovenRepoKey(); - const query = repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : ""; - const response = await fetch(`/api/oven-data/model-lab${query}`, { cache: "no-store" }); + const response = await fetch(modelLabDataUrl(ovenRepoKey()), { cache: "no-store" }); const document = await response.json(); if (!response.ok) throw new Error(document.error ?? "Could not load Model Lab."); if (document.validated !== true) throw new Error("Model Lab data was not validated by the Oven."); @@ -182,186 +55,12 @@ export function ModelLabPage() { } }; void refresh(); - const timer = window.setInterval(refresh, 2_000); + const timer = window.setInterval(refresh, MODEL_LAB_POLL_MS); return () => { cancelled = true; window.clearInterval(timer); }; }, []); - const surfaceUrl = useMemo(() => { - if (!payload) return ""; - const url = new URL(payload.surface.url); - url.searchParams.set("embedded", "1"); - url.searchParams.set("model", payload.model.id); - url.searchParams.set("frame", String(payload.model.frameIndex)); - return url.href; - }, [payload?.surface.url, payload?.model.id, payload?.model.frameIndex]); - - useEffect(() => { - if (!surfaceUrl) return; - setRuntime(null); - const expectedOrigin = new URL(surfaceUrl).origin; - const receive = (event: MessageEvent) => { - if (event.source !== iframe.current?.contentWindow || event.origin !== expectedOrigin) return; - if (event.data?.schema !== "polycss-model-lab-state@1") return; - setRuntime(event.data); - }; - window.addEventListener("message", receive); - return () => window.removeEventListener("message", receive); - }, [surfaceUrl]); - - if (loading && !payload) { - return
Loading the bound Model Lab surface…
; - } - if (error && !payload) { - return
{error}
; - } - if (!payload) return null; - - const live = runtime?.stats; - const leafCount = live?.leafCount ?? payload.model.leafCount; - const stableLeaves = live?.stableLeafIdentityCount; - const leafTagsAreS = live ? live.leafTags.length === leafCount && live.leafTags.every((tag) => tag === "s") : true; - const runtimeZero = zeroRuntimeConstruction(live?.runtimeConstruction ?? payload.model.runtimeConstruction); - const liveReady = runtime?.status === "ready" && live?.ready === true; - const frameIndex = live?.frameIndex ?? payload.model.frameIndex; - const frameId = live?.frameId ?? payload.model.frameId; - const activeAnimation = payload.model.animations.find(({ firstFrameIndex, frameCount }) => ( - frameIndex >= firstFrameIndex && frameIndex < firstFrameIndex + frameCount - )); - const selectAnimation = (animationId: string) => { - const animation = payload.model.animations.find(({ id }) => id === animationId); - if (!animation || !iframe.current?.contentWindow) return; - iframe.current.contentWindow.postMessage({ - schema: MODEL_LAB_COMMAND_SCHEMA, - command: "set-frame", - frameIndex: animation.firstFrameIndex, - }, new URL(surfaceUrl).origin); - }; - - return ( -
-
-

{payload.project.label} · {payload.model.id}

-

- {liveReady ? "Live DOM" : "Connecting"} - - one frameset - - no LOD - - <s> × {leafCount} -

-
- -
-
-