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:
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
134
components/ui/drawer.tsx
Normal file
134
components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
23
convex/node-type-validator.ts
Normal file
23
convex/node-type-validator.ts
Normal 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);
|
||||
@@ -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<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(
|
||||
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() });
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
68
lib/canvas-node-types.ts
Normal file
68
lib/canvas-node-types.ts
Normal 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);
|
||||
}
|
||||
235
lib/image-pipeline/contracts.ts
Normal file
235
lib/image-pipeline/contracts.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user