From 624beac6dc69559304917ab642b672924fbfd81a Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 2 Apr 2026 08:26:06 +0200 Subject: [PATCH] Enhance canvas components with improved error handling and aspect ratio normalization - Added error name tracking in NodeErrorBoundary for better debugging. - Introduced aspect ratio normalization in PromptNode to ensure valid values are used. - Updated debounced state management in CanvasInner for improved performance. - Enhanced SelectContent component to support optional portal rendering. --- .docs/LemonSpace_ADR_AdjustmentStack.md | 67 +++---- components/ui/drawer.tsx | 134 ++++++++++++++ convex/node-type-validator.ts | 23 +++ convex/nodes.ts | 50 ++++- convex/schema.ts | 67 +------ lib/canvas-node-catalog.ts | 4 +- lib/canvas-node-types.ts | 68 +++++++ lib/image-pipeline/contracts.ts | 235 ++++++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 + 10 files changed, 552 insertions(+), 100 deletions(-) create mode 100644 components/ui/drawer.tsx create mode 100644 convex/node-type-validator.ts create mode 100644 lib/canvas-node-types.ts create mode 100644 lib/image-pipeline/contracts.ts diff --git a/.docs/LemonSpace_ADR_AdjustmentStack.md b/.docs/LemonSpace_ADR_AdjustmentStack.md index 7c4cd6f..cbc93da 100644 --- a/.docs/LemonSpace_ADR_AdjustmentStack.md +++ b/.docs/LemonSpace_ADR_AdjustmentStack.md @@ -1,6 +1,6 @@ # 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack -**Status:** Accepted +**Status:** In Progress (Phase 0) **Datum:** März 2026 **Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2 @@ -8,7 +8,14 @@ ## 1. Entscheidung -Adjustment-Nodes arbeiten non-destruktiv über eine **edge-basierte Pipeline**. Die Edge-Kette im Canvas *ist* der Stack — kein separates Datenmodell. Die Bildverarbeitung läuft client-seitig primär über eine **Web-Worker-Pipeline** mit OffscreenCanvas/2D-Rendering; ein WebGL-Pfad existiert ergänzend für kompatible Teilpfade. Keine externen Packages. +Adjustment-Nodes arbeiten non-destruktiv über eine **edge-basierte Pipeline**. Die Edge-Kette im Canvas *ist* der Stack — kein separates Datenmodell. + +Ziel-API der Umsetzung: + +- `Worker Preview`: Preview-Rendering als primärer Pfad. +- `Worker Full Render`: Voll-Render als separater Worker-Pfad. +- `Fallback/Recovery`: Main-Thread-Fallback bleibt Default-Sicherheitsnetz. +- `WebGL`: optionaler Off-Path, nicht vorausgesetzt für Phase 0. --- @@ -104,46 +111,40 @@ Invalidierung erfolgt request-basiert: Neue Requests verdrängen veraltete Ergeb --- -## Implementierungsstand (Stand: 31.03.2026) +## Implementierungsstand (Stand: Phase 0) -### Produktiv umgesetzt +### Aktueller Ist-Zustand -- WebWorker-Migration ist produktiv aktiv über `lib/image-pipeline/pipeline.worker.ts`, `lib/image-pipeline/pipeline-bridge.ts` und `lib/image-pipeline/index.ts`. -- Preview- und Full-Render laufen über Worker-Requests aus `hooks/use-pipeline-preview.ts` und `components/canvas/nodes/render-node.tsx`. -- Die Worker-Pipeline rendert aktuell über `OffscreenCanvas` + 2D-Kontext (inkl. Kurven-LUT, Canvas-Filter und nachgelagerte Pixel-Adjustments), Histogramm-Berechnung erfolgt im Worker. -- Render-Node nutzt `bridge.renderFull(...)` und liefert aktuell einen lokalen Download-Export (kein Convex-Upload in diesem Pfad). -- Lifecycle-Cleanup ist angebunden: `disposePipelineBridge()` wird in `components/canvas/canvas.tsx` beim Unmount ausgeführt. +- Es gibt derzeit **keine produktiv integrierte Frontend-Runtime** für die Image-Pipeline im Repository. +- Phase 0 liefert den Architektur- und Vertragsabgleich (Node-Type Single Source, Pipeline-Contract als pure TS-Funktionen, serverseitige Guard-Rules für Adjustment-Data). +- Worker-Preview/Worker-Full-Render bleiben Zielarchitektur für die weiteren Phasen. -### Abweichungen zur ursprünglichen ADR-Intention / Zielvision aus dem Guide +### Was in Phase 0 bewusst noch nicht enthalten ist -- Die ursprünglich beschriebene, primär shader-zentrierte WebGL-Architektur ist nicht 1:1 der produktive Standardpfad. -- Statt einer reinen „WebGL-im-Worker“-Ausführung nutzt die aktuelle Worker-Pipeline einen OffscreenCanvas/2D-Rendering-Pfad mit ergänzenden ImageData-Operationen. -- Der Guide skizziert konzeptionell eine zentrale Worker-Instanz; die aktuelle Bridge betreibt getrennte Worker-Kanäle für Preview und Full-Render. -- Der im ADR beschriebene Render-Node-Flow mit Convex-Storage-Materialisierung ist in der aktuellen UI nicht der Default-Exportpfad. +- Keine UI-Integration für Adjustment-Preview oder Render-Node-Workflow. +- Kein Worker-Bridge-Lifecycle im Canvas. +- Keine produktive WebGL-Pipeline. ### Fallback- und Recovery-Mechanismen -- `usePipelinePreview` versucht Worker-Rendering zuerst und schaltet bei Fehlern auf Main-Thread-Fallback (`canvas-render.ts`) um. -- Während des Fallback-Betriebs werden Worker-Recovery-Retries zeit- und zählbasiert angestoßen; bei erfolgreicher Probe wird zurück auf Worker gewechselt. -- Stale Ergebnisse werden über Request-Sequenzierung verworfen; betroffene `ImageBitmap`s werden aktiv freigegeben. -- Preview-Metriken erfassen u. a. Fallback-Switches und Recoveries über `lib/image-pipeline/preview-metrics.ts`. +- Für die Zielimplementierung bleibt Main-Thread-Fallback mit Recovery der Default. +- Phase 0 definiert dafür den deterministischen Pipeline-Contract (`collectPipeline`, `getSourceImage`, `hashPipeline`) als Grundlage für Preview und Full-Render. --- -## Einfluss des WebWorker-Migration-Guides +## Einfluss des WebWorker-Migration-Guides (Zielbild) -### Übernommene Konzepte +### Übernommene Konzepte (architektonisch) -- Entkopplung von UI und Bildpipeline über Worker + Bridge (`pipeline.worker.ts` / `pipeline-bridge.ts`). -- Request-basierte Worker-API mit korrelierbarer Request-ID. -- Rückgabe von Preview-Bitmaps und Histogramm-Daten über Worker-Messages. -- Singleton-Verwaltung der Bridge (`lib/image-pipeline/index.ts`) und Cleanup im Canvas-Lifecycle. +- Entkopplung von UI und Bildpipeline über Worker + Bridge. +- Trennung von Preview und Full-Render API. +- Deterministische Pipeline-Berechnung und zyklussichere Traversierung. -### Bewusst abgewandelte Punkte +### Bewusst offen gehaltene Punkte -- Reine WebGL-im-Worker-Zielarchitektur wurde zugunsten eines OffscreenCanvas/2D-Pfads umgesetzt. -- Getrennte Worker für Preview und Full-Render statt nur eines universellen Workers. -- Render-Node-Integration ist aktuell auf clientseitigen Export fokussiert, nicht auf serverseitige Persistierung als Standardfluss. +- Ob der Renderpfad über OffscreenCanvas/2D, WebGL oder hybrid ausgeführt wird. +- Ob Preview und Full-Render denselben Worker teilen oder separiert laufen. +- Persistenzstrategie für final gerenderte Artefakte. ### Offene Punkte / Follow-ups @@ -155,9 +156,9 @@ Invalidierung erfolgt request-basiert: Neue Requests verdrängen veraltete Ergeb ## 4. WebGL-Wrapper -> **Hinweis zum Ist-Stand:** Dieser Abschnitt dokumentiert weiterhin die WebGL-Ziel-/Referenzarchitektur der Pipeline. Produktiv läuft die Preview-/Render-Ausführung derzeit primär im Worker über OffscreenCanvas/2D. +> **Hinweis zum Ist-Stand:** Dieser Abschnitt dokumentiert die Ziel-/Referenzarchitektur. Die Runtime ist im aktuellen Repository noch nicht integriert. -### Dateien +### Geplante Dateien ``` lib/ @@ -639,7 +640,7 @@ Die Validierung läuft in `canvas.tsx` bei `onConnect` — ungültige Verbindung --- -## 11. Dateistruktur (Phase 2 — Bildbearbeitung) +## 11. Ziel-Dateistruktur (Phase 2 — Bildbearbeitung) ``` lib/ @@ -682,7 +683,7 @@ hooks/ | Adjustment-Node Resize | ✅ | Resizeable (wie alle Nodes via base-node-wrapper), mit `minWidth: 240`. Preview skaliert mit, Slider-Layout bleibt stabil. | | Render-Node: Client- vs. Server-seitig | ✅ | Client-seitig über Worker-Bridge; aktueller Pfad ist Download-Export (`renderFull`) statt serverseitiger Persistierung. | | Worker-Fallback/Recovery | ✅ | Bei Worker-Fehlern Fallback auf Main Thread; periodische Recovery-Versuche zurück in den Worker-Pfad. | -| Reine WebGL-im-Worker-Architektur | ⏳ | Guide-Zielbild; aktuell produktiv ist ein OffscreenCanvas/2D-Pfad im Worker. | +| Reine WebGL-im-Worker-Architektur | ⏳ | Optionaler Off-Path; Entscheidung folgt in späteren Phasen. | --- @@ -696,7 +697,7 @@ hooks/ | Preview-Auflösung | Dynamisch: nodeWidth × devicePixelRatio, max 1024px | | Mindestbreite Adjustment-Nodes | 240px | | Max. Bild-Auflösung Render | Original-Auflösung | -| Primärer Renderpfad | Web Worker + OffscreenCanvas/2D | +| Primärer Renderpfad | Ziel: Web Worker (technischer Unterpfad wird später festgelegt) | --- diff --git a/components/ui/drawer.tsx b/components/ui/drawer.tsx new file mode 100644 index 0000000..a4dfff7 --- /dev/null +++ b/components/ui/drawer.tsx @@ -0,0 +1,134 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/convex/node-type-validator.ts b/convex/node-type-validator.ts new file mode 100644 index 0000000..3e9a104 --- /dev/null +++ b/convex/node-type-validator.ts @@ -0,0 +1,23 @@ +import { v, type Validator } from "convex/values"; + +import { + ADJUSTMENT_NODE_TYPES, + CANVAS_NODE_TYPES, + PHASE1_CANVAS_NODE_TYPES, +} from "../lib/canvas-node-types"; + +function buildNodeTypeUnion< + const TValues extends readonly [string, string, ...string[]], +>(values: TValues): Validator { + return v.union( + ...values.map((value) => v.literal(value)) as [ + Validator, + Validator, + ...Validator[], + ], + ); +} + +export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES); +export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES); +export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES); diff --git a/convex/nodes.ts b/convex/nodes.ts index b46a2d0..d9199c1 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -2,6 +2,8 @@ import { query, mutation, QueryCtx, MutationCtx } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; import type { Doc, Id } from "./_generated/dataModel"; +import { isAdjustmentNodeType } from "../lib/canvas-node-types"; +import { nodeTypeValidator } from "./node-type-validator"; // ============================================================================ // Interne Helpers @@ -40,6 +42,35 @@ type NodeCreateMutationName = | "nodes.createWithEdgeFromSource" | "nodes.createWithEdgeToTarget"; +const DISALLOWED_ADJUSTMENT_DATA_KEYS = [ + "storageId", + "url", + "blob", + "blobUrl", + "imageData", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNoAdjustmentImagePayload( + nodeType: Doc<"nodes">["type"], + data: unknown, +): void { + if (!isAdjustmentNodeType(nodeType) || !isRecord(data)) { + return; + } + + for (const key of DISALLOWED_ADJUSTMENT_DATA_KEYS) { + if (key in data) { + throw new Error( + `Adjustment nodes accept parameter data only. '${key}' is not allowed in data.`, + ); + } + } +} + async function getIdempotentNodeCreateResult( ctx: MutationCtx, args: { @@ -159,7 +190,7 @@ export const get = query({ export const listByType = query({ args: { canvasId: v.id("canvases"), - type: v.string(), + type: nodeTypeValidator, }, handler: async (ctx, { canvasId, type }) => { const user = await requireAuth(ctx); @@ -187,7 +218,7 @@ export const listByType = query({ export const create = mutation({ args: { canvasId: v.id("canvases"), - type: v.string(), + type: nodeTypeValidator, positionX: v.number(), positionY: v.number(), width: v.number(), @@ -212,6 +243,8 @@ export const create = mutation({ return existingNodeId; } + assertNoAdjustmentImagePayload(args.type, args.data); + const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, type: args.type as Doc<"nodes">["type"], @@ -246,7 +279,7 @@ export const create = mutation({ export const createWithEdgeSplit = mutation({ args: { canvasId: v.id("canvases"), - type: v.string(), + type: nodeTypeValidator, positionX: v.number(), positionY: v.number(), width: v.number(), @@ -280,6 +313,8 @@ export const createWithEdgeSplit = mutation({ throw new Error("Edge not found"); } + assertNoAdjustmentImagePayload(args.type, args.data); + const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, type: args.type as Doc<"nodes">["type"], @@ -434,7 +469,7 @@ export const splitEdgeAtExistingNode = mutation({ export const createWithEdgeFromSource = mutation({ args: { canvasId: v.id("canvases"), - type: v.string(), + type: nodeTypeValidator, positionX: v.number(), positionY: v.number(), width: v.number(), @@ -466,6 +501,8 @@ export const createWithEdgeFromSource = mutation({ throw new Error("Source node not found"); } + assertNoAdjustmentImagePayload(args.type, args.data); + const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, type: args.type as Doc<"nodes">["type"], @@ -508,7 +545,7 @@ export const createWithEdgeFromSource = mutation({ export const createWithEdgeToTarget = mutation({ args: { canvasId: v.id("canvases"), - type: v.string(), + type: nodeTypeValidator, positionX: v.number(), positionY: v.number(), width: v.number(), @@ -540,6 +577,8 @@ export const createWithEdgeToTarget = mutation({ throw new Error("Target node not found"); } + assertNoAdjustmentImagePayload(args.type, args.data); + const nodeId = await ctx.db.insert("nodes", { canvasId: args.canvasId, type: args.type as Doc<"nodes">["type"], @@ -659,6 +698,7 @@ export const updateData = mutation({ if (!node) throw new Error("Node not found"); await getCanvasOrThrow(ctx, node.canvasId, user.userId); + assertNoAdjustmentImagePayload(node.type, data); await ctx.db.patch(nodeId, { data }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); }, diff --git a/convex/schema.ts b/convex/schema.ts index 7af1ff2..3d606ff 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2,76 +2,23 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { + nodeTypeValidator, + phase1NodeTypeValidator, +} from "./node-type-validator"; + // ============================================================================ // Node Types // ============================================================================ // Phase 1 Node Types -const phase1NodeTypes = v.union( - // Quelle - v.literal("image"), - v.literal("text"), - v.literal("prompt"), - // KI-Ausgabe - v.literal("ai-image"), - // Canvas & Layout - v.literal("group"), - v.literal("frame"), - v.literal("note"), - v.literal("compare") -); +const phase1NodeTypes = phase1NodeTypeValidator; // Alle Node Types (Phase 1 + spätere Phasen) // Phase 2+3 Typen sind hier schon definiert, damit das Schema nicht bei // jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen // der jeweiligen Phase an. -const nodeType = v.union( - // Quelle (Phase 1) - v.literal("image"), - v.literal("text"), - v.literal("prompt"), - // Quelle (Phase 2) - v.literal("color"), - v.literal("video"), - v.literal("asset"), - // KI-Ausgabe (Phase 1) - v.literal("ai-image"), - // KI-Ausgabe (Phase 2) - v.literal("ai-text"), - v.literal("ai-video"), - // KI-Ausgabe (Phase 3) - v.literal("agent-output"), - // Transformation (Phase 2) - v.literal("crop"), - v.literal("bg-remove"), - v.literal("upscale"), - // Transformation (Phase 3) - v.literal("style-transfer"), - v.literal("face-restore"), - // Bildbearbeitung (Phase 2) - v.literal("curves"), - v.literal("color-adjust"), - v.literal("light-adjust"), - v.literal("detail-adjust"), - v.literal("render"), - // Steuerung (Phase 2) - v.literal("splitter"), - v.literal("loop"), - v.literal("agent"), - // Steuerung (Phase 3) - v.literal("mixer"), - v.literal("switch"), - // Canvas & Layout (Phase 1) - v.literal("group"), - v.literal("frame"), - v.literal("note"), - v.literal("compare"), - // Canvas & Layout (Phase 2) - v.literal("text-overlay"), - // Canvas & Layout (Phase 3) - v.literal("comment"), - v.literal("presentation") -); +const nodeType = nodeTypeValidator; // Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD) const nodeStatus = v.union( diff --git a/lib/canvas-node-catalog.ts b/lib/canvas-node-catalog.ts index 3fd1cc0..72f2864 100644 --- a/lib/canvas-node-catalog.ts +++ b/lib/canvas-node-catalog.ts @@ -1,9 +1,9 @@ -import type { Doc } from "@/convex/_generated/dataModel"; import { nodeTypes } from "@/components/canvas/node-types"; import { CANVAS_NODE_TEMPLATES, type CanvasNodeTemplate, } from "@/lib/canvas-node-templates"; +import type { CanvasNodeType } from "@/lib/canvas-node-types"; /** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */ export type NodeCategoryId = @@ -30,7 +30,7 @@ export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = ( Object.keys(NODE_CATEGORY_META) as NodeCategoryId[] ).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order); -export type CatalogNodeType = Doc<"nodes">["type"]; +export type CatalogNodeType = CanvasNodeType; export type NodeCatalogEntry = { type: CatalogNodeType; diff --git a/lib/canvas-node-types.ts b/lib/canvas-node-types.ts new file mode 100644 index 0000000..53a2ee6 --- /dev/null +++ b/lib/canvas-node-types.ts @@ -0,0 +1,68 @@ +export const PHASE1_CANVAS_NODE_TYPES = [ + "image", + "text", + "prompt", + "ai-image", + "group", + "frame", + "note", + "compare", +] as const; + +export const CANVAS_NODE_TYPES = [ + "image", + "text", + "prompt", + "color", + "video", + "asset", + "ai-image", + "ai-text", + "ai-video", + "agent-output", + "crop", + "bg-remove", + "upscale", + "style-transfer", + "face-restore", + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", + "render", + "splitter", + "loop", + "agent", + "mixer", + "switch", + "group", + "frame", + "note", + "compare", + "text-overlay", + "comment", + "presentation", +] as const; + +export const ADJUSTMENT_NODE_TYPES = [ + "curves", + "color-adjust", + "light-adjust", + "detail-adjust", + "render", +] as const; + +export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number]; +export type Phase1CanvasNodeType = (typeof PHASE1_CANVAS_NODE_TYPES)[number]; +export type AdjustmentNodeType = (typeof ADJUSTMENT_NODE_TYPES)[number]; + +const CANVAS_NODE_TYPE_SET = new Set(CANVAS_NODE_TYPES); +const ADJUSTMENT_NODE_TYPE_SET = new Set(ADJUSTMENT_NODE_TYPES); + +export function isCanvasNodeType(value: string): value is CanvasNodeType { + return CANVAS_NODE_TYPE_SET.has(value as CanvasNodeType); +} + +export function isAdjustmentNodeType(value: string): value is AdjustmentNodeType { + return ADJUSTMENT_NODE_TYPE_SET.has(value as AdjustmentNodeType); +} diff --git a/lib/image-pipeline/contracts.ts b/lib/image-pipeline/contracts.ts new file mode 100644 index 0000000..e81ab59 --- /dev/null +++ b/lib/image-pipeline/contracts.ts @@ -0,0 +1,235 @@ +export type PipelineStep = { + nodeId: string; + type: TNodeType; + params: TData; +}; + +type IdLike = string | number; + +export type PipelineNodeLike< + TNodeType extends string = string, + TData = unknown, + TId extends IdLike = string, +> = { + id: TId; + type: TNodeType; + data?: TData; +}; + +export type PipelineEdgeLike = { + source: TId; + target: TId; +}; + +type UpstreamTraversalOptions< + TNode extends PipelineNodeLike, + TEdge extends PipelineEdgeLike, +> = { + nodeId: TNode["id"]; + nodes: readonly TNode[]; + edges: readonly TEdge[]; + getNodeId?: (node: TNode) => TNode["id"]; + getNodeType?: (node: TNode) => TNode["type"]; + getNodeData?: (node: TNode) => TNode["data"]; + getEdgeSource?: (edge: TEdge) => TNode["id"]; + getEdgeTarget?: (edge: TEdge) => TNode["id"]; +}; + +type UpstreamWalkResult = { + path: TNode[]; + selectedEdges: TEdge[]; +}; + +function toComparableId(value: IdLike): string { + return String(value); +} + +function selectIncomingEdge( + incomingEdges: readonly TEdge[], + getEdgeSource: (edge: TEdge) => TNode["id"], +): TEdge | null { + if (incomingEdges.length === 0) { + return null; + } + + const sortedIncoming = [...incomingEdges].sort((left, right) => + toComparableId(getEdgeSource(left)).localeCompare(toComparableId(getEdgeSource(right))), + ); + + return sortedIncoming[0] ?? null; +} + +function walkUpstream( + options: UpstreamTraversalOptions, +): UpstreamWalkResult { + const getNodeId = options.getNodeId ?? ((node: TNode) => node.id); + const getEdgeSource = options.getEdgeSource ?? ((edge: TEdge) => edge.source as TNode["id"]); + const getEdgeTarget = options.getEdgeTarget ?? ((edge: TEdge) => edge.target as TNode["id"]); + + const byId = new Map(); + for (const node of options.nodes) { + byId.set(toComparableId(getNodeId(node)), node); + } + + const incomingByTarget = new Map(); + for (const edge of options.edges) { + const key = toComparableId(getEdgeTarget(edge)); + const existing = incomingByTarget.get(key); + if (existing) { + existing.push(edge); + } else { + incomingByTarget.set(key, [edge]); + } + } + + const path: TNode[] = []; + const selectedEdges: TEdge[] = []; + const visiting = new Set(); + + const visit = (currentId: TNode["id"]): void => { + const key = toComparableId(currentId); + if (visiting.has(key)) { + throw new Error(`Cycle detected in pipeline graph at node '${key}'.`); + } + + visiting.add(key); + + const incomingEdges = incomingByTarget.get(key) ?? []; + const incoming = selectIncomingEdge(incomingEdges, getEdgeSource); + if (incoming) { + selectedEdges.push(incoming); + visit(getEdgeSource(incoming)); + } + + visiting.delete(key); + + const current = byId.get(key); + if (current) { + path.push(current); + } + }; + + visit(options.nodeId); + + return { + path, + selectedEdges, + }; +} + +export function collectPipeline< + TNode extends PipelineNodeLike, + TEdge extends PipelineEdgeLike, +>( + options: UpstreamTraversalOptions & { + isPipelineNode: (node: TNode) => boolean; + }, +): PipelineStep[] { + const getNodeId = options.getNodeId ?? ((node: TNode) => node.id); + const getNodeType = options.getNodeType ?? ((node: TNode) => node.type); + const getNodeData = options.getNodeData ?? ((node: TNode) => node.data); + + const traversal = walkUpstream(options); + + const steps: PipelineStep[] = []; + for (const node of traversal.path) { + if (!options.isPipelineNode(node)) { + continue; + } + + steps.push({ + nodeId: toComparableId(getNodeId(node)), + type: getNodeType(node), + params: getNodeData(node), + }); + } + + return steps; +} + +export function getSourceImage< + TNode extends PipelineNodeLike, + TEdge extends PipelineEdgeLike, + TSourceImage, +>( + options: UpstreamTraversalOptions & { + isSourceNode: (node: TNode) => boolean; + getSourceImageFromNode: (node: TNode) => TSourceImage | null | undefined; + }, +): TSourceImage | null { + const traversal = walkUpstream(options); + + for (let index = traversal.path.length - 1; index >= 0; index -= 1) { + const node = traversal.path[index]; + if (!options.isSourceNode(node)) { + continue; + } + + const sourceImage = options.getSourceImageFromNode(node); + if (sourceImage != null) { + return sourceImage; + } + } + + return null; +} + +function stableStringify(value: unknown): string { + if (value === null || value === undefined) { + return "null"; + } + + const valueType = typeof value; + if (valueType === "number" || valueType === "boolean") { + return JSON.stringify(value); + } + + if (valueType === "string") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + + if (valueType === "object") { + const record = value as Record; + const sortedEntries = Object.entries(record).sort(([a], [b]) => + a.localeCompare(b), + ); + + const serialized = sortedEntries + .map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableStringify(nestedValue)}`) + .join(","); + return `{${serialized}}`; + } + + return JSON.stringify(String(value)); +} + +function fnv1aHash(input: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash += + (hash << 1) + + (hash << 4) + + (hash << 7) + + (hash << 8) + + (hash << 24); + } + + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +export function hashPipeline( + sourceImage: unknown, + steps: readonly PipelineStep[], +): string { + return fnv1aHash( + stableStringify({ + sourceImage, + steps, + }), + ); +} diff --git a/package.json b/package.json index 324a3f5..82ea747 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "shadcn": "^4.1.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 317cd4a..0fe7fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zod: specifier: ^4.3.6 version: 4.3.6