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.
This commit is contained in:
Matthias
2026-04-02 08:26:06 +02:00
parent 2142249ed5
commit 624beac6dc
10 changed files with 552 additions and 100 deletions

View File

@@ -1,6 +1,6 @@
# 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack # 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack
**Status:** Accepted **Status:** In Progress (Phase 0)
**Datum:** März 2026 **Datum:** März 2026
**Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2 **Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2
@@ -8,7 +8,14 @@
## 1. Entscheidung ## 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`. - Es gibt derzeit **keine produktiv integrierte Frontend-Runtime** für die Image-Pipeline im Repository.
- Preview- und Full-Render laufen über Worker-Requests aus `hooks/use-pipeline-preview.ts` und `components/canvas/nodes/render-node.tsx`. - Phase 0 liefert den Architektur- und Vertragsabgleich (Node-Type Single Source, Pipeline-Contract als pure TS-Funktionen, serverseitige Guard-Rules für Adjustment-Data).
- Die Worker-Pipeline rendert aktuell über `OffscreenCanvas` + 2D-Kontext (inkl. Kurven-LUT, Canvas-Filter und nachgelagerte Pixel-Adjustments), Histogramm-Berechnung erfolgt im Worker. - Worker-Preview/Worker-Full-Render bleiben Zielarchitektur für die weiteren Phasen.
- 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.
### 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. - Keine UI-Integration für Adjustment-Preview oder Render-Node-Workflow.
- Statt einer reinen „WebGL-im-Worker“-Ausführung nutzt die aktuelle Worker-Pipeline einen OffscreenCanvas/2D-Rendering-Pfad mit ergänzenden ImageData-Operationen. - Kein Worker-Bridge-Lifecycle im Canvas.
- Der Guide skizziert konzeptionell eine zentrale Worker-Instanz; die aktuelle Bridge betreibt getrennte Worker-Kanäle für Preview und Full-Render. - Keine produktive WebGL-Pipeline.
- Der im ADR beschriebene Render-Node-Flow mit Convex-Storage-Materialisierung ist in der aktuellen UI nicht der Default-Exportpfad.
### Fallback- und Recovery-Mechanismen ### Fallback- und Recovery-Mechanismen
- `usePipelinePreview` versucht Worker-Rendering zuerst und schaltet bei Fehlern auf Main-Thread-Fallback (`canvas-render.ts`) um. - Für die Zielimplementierung bleibt Main-Thread-Fallback mit Recovery der Default.
- Während des Fallback-Betriebs werden Worker-Recovery-Retries zeit- und zählbasiert angestoßen; bei erfolgreicher Probe wird zurück auf Worker gewechselt. - Phase 0 definiert dafür den deterministischen Pipeline-Contract (`collectPipeline`, `getSourceImage`, `hashPipeline`) als Grundlage für Preview und Full-Render.
- 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`.
--- ---
## 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`). - Entkopplung von UI und Bildpipeline über Worker + Bridge.
- Request-basierte Worker-API mit korrelierbarer Request-ID. - Trennung von Preview und Full-Render API.
- Rückgabe von Preview-Bitmaps und Histogramm-Daten über Worker-Messages. - Deterministische Pipeline-Berechnung und zyklussichere Traversierung.
- Singleton-Verwaltung der Bridge (`lib/image-pipeline/index.ts`) und Cleanup im Canvas-Lifecycle.
### Bewusst abgewandelte Punkte ### Bewusst offen gehaltene Punkte
- Reine WebGL-im-Worker-Zielarchitektur wurde zugunsten eines OffscreenCanvas/2D-Pfads umgesetzt. - Ob der Renderpfad über OffscreenCanvas/2D, WebGL oder hybrid ausgeführt wird.
- Getrennte Worker für Preview und Full-Render statt nur eines universellen Workers. - Ob Preview und Full-Render denselben Worker teilen oder separiert laufen.
- Render-Node-Integration ist aktuell auf clientseitigen Export fokussiert, nicht auf serverseitige Persistierung als Standardfluss. - Persistenzstrategie für final gerenderte Artefakte.
### Offene Punkte / Follow-ups ### Offene Punkte / Follow-ups
@@ -155,9 +156,9 @@ Invalidierung erfolgt request-basiert: Neue Requests verdrängen veraltete Ergeb
## 4. WebGL-Wrapper ## 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/ 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/ 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. | | 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. | | 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. | | 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 | | Preview-Auflösung | Dynamisch: nodeWidth × devicePixelRatio, max 1024px |
| Mindestbreite Adjustment-Nodes | 240px | | Mindestbreite Adjustment-Nodes | 240px |
| Max. Bild-Auflösung Render | Original-Auflösung | | 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) |
--- ---

134
components/ui/drawer.tsx Normal file
View File

@@ -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<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -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<TValues[number], "required", string> {
return v.union(
...values.map((value) => v.literal(value)) as [
Validator<TValues[number], "required", string>,
Validator<TValues[number], "required", string>,
...Validator<TValues[number], "required", string>[],
],
);
}
export const phase1NodeTypeValidator = buildNodeTypeUnion(PHASE1_CANVAS_NODE_TYPES);
export const nodeTypeValidator = buildNodeTypeUnion(CANVAS_NODE_TYPES);
export const adjustmentNodeTypeValidator = buildNodeTypeUnion(ADJUSTMENT_NODE_TYPES);

View File

@@ -2,6 +2,8 @@ import { query, mutation, QueryCtx, MutationCtx } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { requireAuth } from "./helpers"; import { requireAuth } from "./helpers";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { isAdjustmentNodeType } from "../lib/canvas-node-types";
import { nodeTypeValidator } from "./node-type-validator";
// ============================================================================ // ============================================================================
// Interne Helpers // Interne Helpers
@@ -40,6 +42,35 @@ type NodeCreateMutationName =
| "nodes.createWithEdgeFromSource" | "nodes.createWithEdgeFromSource"
| "nodes.createWithEdgeToTarget"; | "nodes.createWithEdgeToTarget";
const DISALLOWED_ADJUSTMENT_DATA_KEYS = [
"storageId",
"url",
"blob",
"blobUrl",
"imageData",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
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( async function getIdempotentNodeCreateResult(
ctx: MutationCtx, ctx: MutationCtx,
args: { args: {
@@ -159,7 +190,7 @@ export const get = query({
export const listByType = query({ export const listByType = query({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
type: v.string(), type: nodeTypeValidator,
}, },
handler: async (ctx, { canvasId, type }) => { handler: async (ctx, { canvasId, type }) => {
const user = await requireAuth(ctx); const user = await requireAuth(ctx);
@@ -187,7 +218,7 @@ export const listByType = query({
export const create = mutation({ export const create = mutation({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
type: v.string(), type: nodeTypeValidator,
positionX: v.number(), positionX: v.number(),
positionY: v.number(), positionY: v.number(),
width: v.number(), width: v.number(),
@@ -212,6 +243,8 @@ export const create = mutation({
return existingNodeId; return existingNodeId;
} }
assertNoAdjustmentImagePayload(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId, canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"], type: args.type as Doc<"nodes">["type"],
@@ -246,7 +279,7 @@ export const create = mutation({
export const createWithEdgeSplit = mutation({ export const createWithEdgeSplit = mutation({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
type: v.string(), type: nodeTypeValidator,
positionX: v.number(), positionX: v.number(),
positionY: v.number(), positionY: v.number(),
width: v.number(), width: v.number(),
@@ -280,6 +313,8 @@ export const createWithEdgeSplit = mutation({
throw new Error("Edge not found"); throw new Error("Edge not found");
} }
assertNoAdjustmentImagePayload(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId, canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"], type: args.type as Doc<"nodes">["type"],
@@ -434,7 +469,7 @@ export const splitEdgeAtExistingNode = mutation({
export const createWithEdgeFromSource = mutation({ export const createWithEdgeFromSource = mutation({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
type: v.string(), type: nodeTypeValidator,
positionX: v.number(), positionX: v.number(),
positionY: v.number(), positionY: v.number(),
width: v.number(), width: v.number(),
@@ -466,6 +501,8 @@ export const createWithEdgeFromSource = mutation({
throw new Error("Source node not found"); throw new Error("Source node not found");
} }
assertNoAdjustmentImagePayload(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId, canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"], type: args.type as Doc<"nodes">["type"],
@@ -508,7 +545,7 @@ export const createWithEdgeFromSource = mutation({
export const createWithEdgeToTarget = mutation({ export const createWithEdgeToTarget = mutation({
args: { args: {
canvasId: v.id("canvases"), canvasId: v.id("canvases"),
type: v.string(), type: nodeTypeValidator,
positionX: v.number(), positionX: v.number(),
positionY: v.number(), positionY: v.number(),
width: v.number(), width: v.number(),
@@ -540,6 +577,8 @@ export const createWithEdgeToTarget = mutation({
throw new Error("Target node not found"); throw new Error("Target node not found");
} }
assertNoAdjustmentImagePayload(args.type, args.data);
const nodeId = await ctx.db.insert("nodes", { const nodeId = await ctx.db.insert("nodes", {
canvasId: args.canvasId, canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"], type: args.type as Doc<"nodes">["type"],
@@ -659,6 +698,7 @@ export const updateData = mutation({
if (!node) throw new Error("Node not found"); if (!node) throw new Error("Node not found");
await getCanvasOrThrow(ctx, node.canvasId, user.userId); await getCanvasOrThrow(ctx, node.canvasId, user.userId);
assertNoAdjustmentImagePayload(node.type, data);
await ctx.db.patch(nodeId, { data }); await ctx.db.patch(nodeId, { data });
await ctx.db.patch(node.canvasId, { updatedAt: Date.now() }); await ctx.db.patch(node.canvasId, { updatedAt: Date.now() });
}, },

View File

@@ -2,76 +2,23 @@
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values"; import { v } from "convex/values";
import {
nodeTypeValidator,
phase1NodeTypeValidator,
} from "./node-type-validator";
// ============================================================================ // ============================================================================
// Node Types // Node Types
// ============================================================================ // ============================================================================
// Phase 1 Node Types // Phase 1 Node Types
const phase1NodeTypes = v.union( const phase1NodeTypes = phase1NodeTypeValidator;
// 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")
);
// Alle Node Types (Phase 1 + spätere Phasen) // Alle Node Types (Phase 1 + spätere Phasen)
// Phase 2+3 Typen sind hier schon definiert, damit das Schema nicht bei // 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 // jedem Phasenübergang migriert werden muss. Die UI zeigt nur die Typen
// der jeweiligen Phase an. // der jeweiligen Phase an.
const nodeType = v.union( const nodeType = nodeTypeValidator;
// 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")
);
// Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD) // Node Status — direkt am Node sichtbar (UX-Strategie aus dem PRD)
const nodeStatus = v.union( const nodeStatus = v.union(

View File

@@ -1,9 +1,9 @@
import type { Doc } from "@/convex/_generated/dataModel";
import { nodeTypes } from "@/components/canvas/node-types"; import { nodeTypes } from "@/components/canvas/node-types";
import { import {
CANVAS_NODE_TEMPLATES, CANVAS_NODE_TEMPLATES,
type CanvasNodeTemplate, type CanvasNodeTemplate,
} from "@/lib/canvas-node-templates"; } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types";
/** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */ /** PRD-Kategorien (Reihenfolge für Sidebar / Dropdown). */
export type NodeCategoryId = export type NodeCategoryId =
@@ -30,7 +30,7 @@ export const NODE_CATEGORIES_ORDERED: NodeCategoryId[] = (
Object.keys(NODE_CATEGORY_META) as NodeCategoryId[] Object.keys(NODE_CATEGORY_META) as NodeCategoryId[]
).sort((a, b) => NODE_CATEGORY_META[a].order - NODE_CATEGORY_META[b].order); ).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 = { export type NodeCatalogEntry = {
type: CatalogNodeType; type: CatalogNodeType;

68
lib/canvas-node-types.ts Normal file
View File

@@ -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<CanvasNodeType>(CANVAS_NODE_TYPES);
const ADJUSTMENT_NODE_TYPE_SET = new Set<AdjustmentNodeType>(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);
}

View File

@@ -0,0 +1,235 @@
export type PipelineStep<TNodeType extends string = string, TData = unknown> = {
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<TId extends IdLike = string> = {
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<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike> = {
path: TNode[];
selectedEdges: TEdge[];
};
function toComparableId(value: IdLike): string {
return String(value);
}
function selectIncomingEdge<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
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<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
options: UpstreamTraversalOptions<TNode, TEdge>,
): UpstreamWalkResult<TNode, TEdge> {
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<string, TNode>();
for (const node of options.nodes) {
byId.set(toComparableId(getNodeId(node)), node);
}
const incomingByTarget = new Map<string, TEdge[]>();
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<string>();
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<TNode, TEdge> & {
isPipelineNode: (node: TNode) => boolean;
},
): PipelineStep<TNode["type"], TNode["data"]>[] {
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<TNode["type"], TNode["data"]>[] = [];
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<TNode, TEdge> & {
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<string, unknown>;
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,
}),
);
}

View File

@@ -42,6 +42,7 @@
"shadcn": "^4.1.0", "shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

3
pnpm-lock.yaml generated
View File

@@ -104,6 +104,9 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 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: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6