From c3af221b53bd02a03743ff0148488e64af8af0c7 Mon Sep 17 00:00:00 2001 From: Niklas Puller <26602675+assert-not-singularity@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:46:44 +0200 Subject: [PATCH 1/3] feat(frontend): live preview + run (M3 WI-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor is now runnable: it debounces the canvas graph to POST /api/evaluate and shows a live preview on each node. - toGnodeGraph converts the ReactFlow canvas to a .gnode payload. - Debounced evaluate (keyed on a position-independent graph signature) with all nodes as targets; a persistent-cache-friendly loop. - GlitchNode shows a live thumbnail + error border, read from a PreviewContext (kept out of the node state that triggers evaluation). - Toolbar: global seed (+ reroll), output size, and evaluate status (evaluating / ready / node error / invalid). - ConfigPanel image_id field: editable id + "upload…" (POST /api/images). - Validation/error bar for invalid-graph (400) messages; cleaner API error text. Verified live in-browser: a Load node with an uploaded image renders its thumbnail from the backend and the toolbar shows "ready". Gate green: biome check, tsc -b, vite build. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.css | 89 ++++++++++++++++++ frontend/src/App.tsx | 118 +++++++++++++++++++----- frontend/src/api/client.ts | 18 +++- frontend/src/components/ConfigPanel.tsx | 58 ++++++++++++ frontend/src/components/GlitchNode.tsx | 28 +++++- frontend/src/components/Toolbar.tsx | 46 +++++++++ frontend/src/contexts.ts | 11 +++ frontend/src/graph.ts | 32 +++++++ 8 files changed, 370 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/Toolbar.tsx create mode 100644 frontend/src/contexts.ts create mode 100644 frontend/src/graph.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index e31b036..b8656c7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -257,6 +257,21 @@ font-family: ui-monospace, Consolas, monospace; resize: vertical; } +.upload-row { + align-items: center; +} +.upload-btn { + cursor: pointer; + padding: 0.25rem 0.5rem; + background: #111827; + border: 1px solid #1e293b; + border-radius: 5px; + font-size: 0.75rem; + color: #cbd5e1; +} +.upload-btn:hover { + border-color: #334155; +} .vec2-field { border: none; padding: 0; @@ -267,3 +282,77 @@ color: #cbd5e1; padding: 0; } + +/* ── Toolbar ───────────────────────────────────────────────── */ +.toolbar { + display: flex; + align-items: center; + gap: 0.6rem; + margin-left: auto; + font-size: 0.8rem; + color: #94a3b8; +} +.tb-field { + display: flex; + align-items: center; + gap: 0.35rem; +} +.tb-field input { + width: 4.5rem; + padding: 0.25rem 0.4rem; + background: #111827; + border: 1px solid #1e293b; + color: #e2e8f0; + border-radius: 5px; +} +.tb-x { + color: #64748b; +} +.tb-status { + min-width: 5rem; + text-align: right; +} +.status-ready { + color: #4ade80; +} +.status-evaluating { + color: #eab308; +} +.status-node-error { + color: #fb7185; +} +.status-invalid { + color: #f97316; +} + +/* ── Validation bar ────────────────────────────────────────── */ +.validation-bar { + padding: 0.4rem 1.2rem; + background: #2a1520; + border-bottom: 1px solid #7f1d1d; + color: #fca5a5; + font-size: 0.8rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* ── Node thumbnail / error ────────────────────────────────── */ +.glitch-node.errored { + border-color: #fb7185; +} +.node-thumb { + display: block; + width: 100%; + border-top: 1px solid #1e293b; + border-radius: 0 0 8px 8px; + image-rendering: pixelated; +} +.node-error { + padding: 0.3rem 0.6rem; + font-size: 0.68rem; + color: #fca5a5; + border-top: 1px solid #7f1d1d; + max-width: 220px; + white-space: normal; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39f579a..c615582 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,15 +14,20 @@ import { useReactFlow, } from '@xyflow/react' import '@xyflow/react/dist/style.css' -import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' +import { evaluateGraph } from './api/client' import { ConfigPanel } from './components/ConfigPanel' import { GlitchNode, type GlitchNodeType } from './components/GlitchNode' import { Palette } from './components/Palette' +import { Toolbar } from './components/Toolbar' +import { PreviewContext } from './contexts' +import { toGnodeGraph } from './graph' import { useNodes } from './hooks/useNodes' -import type { NodeDescriptor } from './types' +import type { NodeDescriptor, NodePreview } from './types' const nodeTypes = { glitchNode: GlitchNode } +const DEBOUNCE_MS = 350 /** Short base name for a node id, e.g. "displace.band" -> "band". */ function baseName(type: string): string { @@ -36,6 +41,12 @@ function Editor() { const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [selectedId, setSelectedId] = useState(null) + const [seed, setSeed] = useState(1) + const [resolution, setResolution] = useState<[number, number]>([512, 512]) + const [previews, setPreviews] = useState>({}) + const [nodeErrors, setNodeErrors] = useState>({}) + const [issues, setIssues] = useState([]) + const [status, setStatus] = useState('') const { screenToFlowPosition } = useReactFlow() const canvasRef = useRef(null) @@ -122,6 +133,54 @@ function Editor() { [setNodes], ) + // Latest graph, read by the debounced evaluate without being a hook dependency. + const graphRef = useRef({ nodes, edges, seed, resolution }) + graphRef.current = { nodes, edges, seed, resolution } + + // A signature of everything that affects the *output* (not node positions). + const evalKey = useMemo( + () => + JSON.stringify({ + n: nodes.map((n) => [n.id, n.data.descriptor.type, n.data.params]), + e: edges.map((e) => [e.source, e.sourceHandle, e.target, e.targetHandle]), + seed, + resolution, + }), + [nodes, edges, seed, resolution], + ) + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional debounce keyed on evalKey; the latest graph is read via graphRef. + useEffect(() => { + const current = graphRef.current + if (current.nodes.length === 0) { + setPreviews({}) + setNodeErrors({}) + setIssues([]) + setStatus('') + return + } + setStatus('evaluating') + const timer = setTimeout(() => { + const { nodes: ns, edges: es, seed: sd, resolution: res } = graphRef.current + const targets = ns.map((n) => n.id) + evaluateGraph(toGnodeGraph(ns, es, sd, res), targets) + .then((result) => { + setPreviews(result.previews) + setNodeErrors(result.errors) + setIssues([]) + setStatus(Object.keys(result.errors).length > 0 ? 'node error' : 'ready') + }) + .catch((err: unknown) => { + setPreviews({}) + setNodeErrors({}) + setIssues([err instanceof Error ? err.message : String(err)]) + setStatus('invalid') + }) + }, DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [evalKey]) + + const previewState = useMemo(() => ({ previews, errors: nodeErrors }), [previews, nodeErrors]) const selected = selectedId ? nodes.find((n) => n.id === selectedId) : undefined return ( @@ -129,8 +188,23 @@ function Editor() {
gnode - node-based glitch editor +
+ {issues.length > 0 && ( +
+ {issues.map((msg) => ( + + {msg} + + ))} +
+ )}
{/* biome-ignore lint/a11y/noStaticElementInteractions: the canvas is a drag-and-drop drop target; keyboard interaction is handled by React Flow */}
- - - - - + + + + + + +
{selected && ( (res: Response): Promise { if (!res.ok) { - let detail = res.statusText + let message = res.statusText try { - const body = (await res.json()) as { detail?: unknown } - if (body?.detail) detail = JSON.stringify(body.detail) + const detail = ((await res.json()) as { detail?: unknown }).detail + if ( + detail && + typeof detail === 'object' && + Array.isArray((detail as { errors?: unknown }).errors) + ) { + message = (detail as { errors: string[] }).errors.join('; ') + } else if (typeof detail === 'string') { + message = detail + } else if (detail) { + message = JSON.stringify(detail) + } } catch { // non-JSON error body — keep statusText } - throw new Error(`${res.status}: ${detail}`) + throw new Error(message) } return res.json() as Promise } diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index f149562..36f0535 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,3 +1,5 @@ +import { type ChangeEvent, useState } from 'react' +import { uploadImage } from '../api/client' import type { JsonSchema, JsonSchemaProperty } from '../types' interface ConfigPanelProps { @@ -105,6 +107,10 @@ function Field({ name, prop, value, onChange }: FieldProps) { const current = value === undefined ? effective.default : value const id = `param-${name}` + if (name === 'image_id') { + return + } + const labelEl = (