From 05f82af98235cf7c2dd4ccddcbbe1e305fe79d3e Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Mon, 30 Mar 2026 09:55:06 +0200 Subject: [PATCH] feat: improve canvas node management with enhanced deletion logic - Added functionality to prevent node deletion based on synchronization status, providing user feedback through notifications. - Introduced helper functions to clarify reasons for blocking deletions, enhancing user experience during interactions. - Updated asset node styling for better visual consistency and adjusted minimum dimensions for improved layout management. --- .docs/LemonSpace_ADR_AdjustmentStack.md | 649 ++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 .docs/LemonSpace_ADR_AdjustmentStack.md diff --git a/.docs/LemonSpace_ADR_AdjustmentStack.md b/.docs/LemonSpace_ADR_AdjustmentStack.md new file mode 100644 index 0000000..af9bb50 --- /dev/null +++ b/.docs/LemonSpace_ADR_AdjustmentStack.md @@ -0,0 +1,649 @@ +# 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack + +**Status:** Accepted +**Datum:** MĂ€rz 2026 +**Kontext:** PRD v1.4, Kategorie 4 (Bildbearbeitung), Phase 2 + +--- + +## 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 ĂŒber **eigene GLSL-Shader** mit einem minimalen WebGL-Wrapper. Keine externen Packages. + +--- + +## 2. Verworfene Alternativen + +| Alternative | Warum verworfen | +|---|---| +| Expliziter Stack als Datenstruktur | Redundantes Datenmodell neben den Edges; Umsortieren erfordert Array-Manipulation statt Edge-Neuverbindung; widerspricht dem Canvas-Paradigma | +| Canvas 2D API (CPU) | Zu langsam bei großen Bildern; blockiert Main Thread; kein Parallelismus | +| glfx.js | Letztes Update 9+ Jahre alt; kein ESM-Support; mĂŒsste geforkt und gepflegt werden | +| PixiJS | 200+ KB Framework-Overhead fĂŒr einen Use Case (Filter auf Einzelbild); bringt Scene Graph, Sprites, Animation mit die wir nicht brauchen | + +--- + +## 3. Architektur-Überblick + +### Edge-basierte Pipeline + +Der Adjustment-Stack ergibt sich aus der Edge-Kette im Canvas. Jeder Adjustment-Node hat einen Input-Handle und einen Output-Handle. Die Reihenfolge der Adjustments ist die Reihenfolge der Verbindungen. + +``` +Bild-Node ──edge──▶ Kurven-Node ──edge──▶ Farbe-Node ──edge──▶ Render-Node + (Quelle) (Adjustment) (Adjustment) (Materialisierung) + storageId params only params only → neues Bild +``` + +**Branching** funktioniert automatisch — ein Quell-Node kann mehrere ausgehende Edges haben: + +``` + ┌──▶ Kurven (warm) ──▶ Licht (hell) ──▶ Render A +Bild-Node ─────────── + └──▶ Kurven (cool) ──▶ Detail (scharf) ──▶ Render B +``` + +**Umsortieren** = Edge löschen + neu ziehen. Kein Array-Reordering, kein zweites Datenmodell. + +### Pipeline-Traversierung + +Wenn ein Node seine Live-Preview rendern will, traversiert er die Edge-Kette **rĂŒckwĂ€rts** bis zum Quell-Bild: + +``` +1. Node fragt: "Wer ist mein Input?" → folge eingehender Edge +2. Rekursiv weiter bis ein Node mit Bild-Daten erreicht wird (Bild-Node, KI-Bild-Node) +3. Sammle alle Adjustment-Parameter in Reihenfolge ein +4. Wende Shader-Pipeline auf das Quell-Bild an +5. Zeige Ergebnis als Preview +``` + +```ts +// Pseudocode: Pipeline-Traversierung +function collectPipeline(nodeId: string, edges: Edge[], nodes: Node[]): PipelineStep[] { + const incomingEdge = edges.find(e => e.target === nodeId); + if (!incomingEdge) return []; // Kein Input → leere Pipeline + + const sourceNode = nodes.find(n => n.id === incomingEdge.source); + if (!sourceNode) return []; + + // Rekursion: erst die Pipeline des VorgĂ€ngers sammeln + const upstream = collectPipeline(sourceNode.id, edges, nodes); + + // Ist der Source-Node ein Adjustment? → seine Parameter zur Pipeline hinzufĂŒgen + if (isAdjustmentNode(sourceNode)) { + return [...upstream, { type: sourceNode.type, params: sourceNode.data }]; + } + + // Ist der Source-Node ein Bild? → Pipeline-Anfang (kein Step, aber Bild-URL wird separat ermittelt) + return upstream; +} + +function getSourceImage(nodeId: string, edges: Edge[], nodes: Node[]): string | null { + const incomingEdge = edges.find(e => e.target === nodeId); + if (!incomingEdge) return null; + + const sourceNode = nodes.find(n => n.id === incomingEdge.source); + if (!sourceNode) return null; + + if (isImageSource(sourceNode)) return sourceNode.data.url; + return getSourceImage(sourceNode.id, edges, nodes); +} +``` + +### Caching-Strategie + +Jeder Adjustment-Node cached sein Preview-Ergebnis als WebGL-Texture. Bei einer ParameterĂ€nderung wird nur ab diesem Node neu gerendert — Upstream-Ergebnisse bleiben gecached. + +``` +Bild → Kurven → Farbe → Detail + ↑ + User Ă€ndert Farbe-Parameter + → Kurven-Cache bleibt gĂŒltig + → Farbe + Detail werden neu gerendert +``` + +Invalidierung: Wenn ein Upstream-Node seine Parameter Ă€ndert, werden alle Downstream-Caches invalidiert. Die Invalidierung propagiert ĂŒber die Edge-Kette vorwĂ€rts. + +--- + +## 4. WebGL-Wrapper + +### Dateien + +``` +lib/ + image-pipeline/ + gl-wrapper.ts ← WebGL-Context, Texture-Management, Shader-Kompilierung + pipeline.ts ← Pipeline-Traversierung, Cache, Orchestrierung + shaders/ + curves.frag ← Tonwert-Kurven (Lookup-Table als 1D-Texture) + color-adjust.frag ← HSL, Color Balance, Temperature/Tint, Vibrance + light.frag ← Brightness, Contrast, Exposure, Highlights/Shadows, Vignette + detail.frag ← Unsharp Mask, Clarity, Denoise, Grain + passthrough.vert ← Gemeinsamer Vertex-Shader (Fullscreen-Quad) +``` + +### gl-wrapper.ts — Verantwortlichkeiten + +```ts +class GLWrapper { + private gl: WebGL2RenderingContext; + private programs: Map; // Kompilierte Shader-Programme + private textures: Map; // Gecachte Zwischenergebnisse + + // Canvas erstellen (offscreen, nicht sichtbar im DOM) + constructor(width: number, height: number); + + // Bild von URL in Texture laden + loadTexture(url: string): Promise; + + // Shader-Programm kompilieren und cachen + getProgram(shaderType: AdjustmentType): WebGLProgram; + + // Einen Adjustment-Schritt ausfĂŒhren: Input-Texture → Output-Texture + applyShader( + program: WebGLProgram, + inputTexture: WebGLTexture, + uniforms: Record, + outputTexture?: WebGLTexture // Optional: in existierende Texture rendern + ): WebGLTexture; + + // Ergebnis als ImageData / Blob extrahieren (fĂŒr Preview oder Render-Node) + readPixels(): ImageData; + toBlob(format: "png" | "jpeg" | "webp", quality?: number): Promise; + + // AufrĂ€umen + dispose(): void; +} +``` + +### passthrough.vert — Gemeinsamer Vertex-Shader + +Alle Adjustment-Shader verwenden denselben Vertex-Shader. Er rendert ein bildschirmfĂŒllendes Quad und reicht UV-Koordinaten an den Fragment-Shader durch: + +```glsl +#version 300 es +in vec2 a_position; // [-1, 1] Fullscreen-Quad +in vec2 a_texCoord; // [0, 1] UV-Koordinaten +out vec2 v_texCoord; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_texCoord = a_texCoord; +} +``` + +### Shader-Architektur (Fragment-Shader) + +Jeder Adjustment-Typ ist ein eigener Fragment-Shader. Uniforms steuern die Parameter. + +**Curves (curves.frag):** + +Kontrollpunkte werden in eine 256-Entry Lookup-Table (LUT) interpoliert und als 1D-Texture ĂŒbergeben. Der Shader samplet die LUT pro Kanal. + +```glsl +#version 300 es +precision highp float; + +in vec2 v_texCoord; +out vec4 fragColor; + +uniform sampler2D u_image; +uniform sampler2D u_lutRGB; // 256×1 Lookup-Table (alle KanĂ€le) +uniform sampler2D u_lutRed; // 256×1 Lookup-Table (Rot-Kanal, optional) +uniform sampler2D u_lutGreen; // 256×1 Lookup-Table (GrĂŒn-Kanal, optional) +uniform sampler2D u_lutBlue; // 256×1 Lookup-Table (Blau-Kanal, optional) +uniform bool u_hasPerChannel; // Einzelkanal-Kurven aktiv? + +// Levels +uniform float u_blackPoint; // 0.0–1.0 (default 0.0) +uniform float u_whitePoint; // 0.0–1.0 (default 1.0) +uniform float u_gamma; // 0.1–10.0 (default 1.0) + +void main() { + vec4 color = texture(u_image, v_texCoord); + + // Levels: Remap + Gamma + color.rgb = clamp((color.rgb - u_blackPoint) / (u_whitePoint - u_blackPoint), 0.0, 1.0); + color.rgb = pow(color.rgb, vec3(1.0 / u_gamma)); + + // RGB-Kurve anwenden + color.r = texture(u_lutRGB, vec2(color.r, 0.5)).r; + color.g = texture(u_lutRGB, vec2(color.g, 0.5)).r; + color.b = texture(u_lutRGB, vec2(color.b, 0.5)).r; + + // Per-Channel Kurven (optional) + if (u_hasPerChannel) { + color.r = texture(u_lutRed, vec2(color.r, 0.5)).r; + color.g = texture(u_lutGreen, vec2(color.g, 0.5)).r; + color.b = texture(u_lutBlue, vec2(color.b, 0.5)).r; + } + + fragColor = color; +} +``` + +**Color Adjust (color-adjust.frag):** + +HSL-Konvertierung, Color Balance (3-Wege), Temperature/Tint, Vibrance. + +```glsl +// Kern-Uniforms: +uniform float u_hue; // -180 bis +180 +uniform float u_saturation; // -100 bis +100 +uniform float u_luminance; // -100 bis +100 +uniform float u_temperature; // -100 bis +100 (Cool ↔ Warm) +uniform float u_tint; // -100 bis +100 (Green ↔ Magenta) +uniform float u_vibrance; // -100 bis +100 +uniform vec3 u_shadowBalance; // Color Balance: Shadows (CMY offsets) +uniform vec3 u_midtoneBalance; // Color Balance: Midtones +uniform vec3 u_highlightBalance;// Color Balance: Highlights +``` + +**Light (light.frag):** + +Exposure, Highlights/Shadows Recovery, HDR Tone Mapping (Local Contrast), Vignette. + +```glsl +// Kern-Uniforms: +uniform float u_brightness; // -100 bis +100 +uniform float u_contrast; // -100 bis +100 +uniform float u_exposure; // -5.0 bis +5.0 (EV) +uniform float u_highlights; // -100 bis +100 +uniform float u_shadows; // -100 bis +100 +uniform float u_whites; // -100 bis +100 +uniform float u_blacks; // -100 bis +100 +uniform float u_vignetteAmount; // 0.0 bis 1.0 +uniform float u_vignetteSize; // 0.0 bis 1.0 +uniform vec2 u_resolution; // BildgrĂ¶ĂŸe (fĂŒr Vignette-Berechnung) +``` + +**Detail (detail.frag):** + +Unsharp Mask benötigt zwei Passes (Blur → Differenz). Clarity arbeitet auf Midtones. + +```glsl +// Kern-Uniforms: +uniform float u_sharpenAmount; // 0.0 bis 5.0 +uniform float u_sharpenRadius; // 0.5 bis 5.0 +uniform float u_sharpenThreshold; // 0.0 bis 1.0 +uniform float u_clarity; // -100 bis +100 (Midtone Contrast) +uniform float u_denoiseStrength; // 0.0 bis 1.0 +uniform float u_grainAmount; // 0.0 bis 1.0 +uniform float u_grainSize; // 0.5 bis 3.0 +uniform float u_time; // FĂŒr Grain-Noise-Seed +``` + +> **Hinweis:** Unsharp Mask und Denoise benötigen Multi-Pass-Rendering (erst Blur, dann Differenz/Blend). Der GLWrapper unterstĂŒtzt dies ĂŒber Framebuffer-Ping-Pong zwischen zwei Textures. + +--- + +## 5. Datenmodell (Convex) + +Adjustment-Nodes speichern **nur Parameter** im `data`-Feld. Keine Pixel, kein `storageId`, kein Zwischen-Ergebnis. + +### Node-Typen und `data`-Felder + +```ts +// type: "curves" +data: { + channelMode: "rgb" | "red" | "green" | "blue", + points: { + rgb: Array<{ x: number; y: number }>, // Kontrollpunkte [0–255] + red: Array<{ x: number; y: number }>, + green: Array<{ x: number; y: number }>, + blue: Array<{ x: number; y: number }>, + }, + levels: { + blackPoint: number, // 0–255, default 0 + whitePoint: number, // 0–255, default 255 + gamma: number, // 0.1–10.0, default 1.0 + }, + preset: string | null, // "contrast" | "brighten" | "darken" | "film" | "cross-process" | null +} + +// type: "color-adjust" +data: { + hsl: { + hue: number, // -180 bis +180, default 0 + saturation: number, // -100 bis +100, default 0 + luminance: number, // -100 bis +100, default 0 + }, + colorBalance: { + shadows: { cyan_red: number, magenta_green: number, yellow_blue: number }, + midtones: { cyan_red: number, magenta_green: number, yellow_blue: number }, + highlights: { cyan_red: number, magenta_green: number, yellow_blue: number }, + }, + temperature: number, // -100 bis +100, default 0 + tint: number, // -100 bis +100, default 0 + vibrance: number, // -100 bis +100, default 0 + preset: string | null, // "warm" | "cool" | "vintage" | "desaturate" | null +} + +// type: "light" +data: { + brightness: number, // -100 bis +100, default 0 + contrast: number, // -100 bis +100, default 0 + exposure: number, // -5.0 bis +5.0, default 0 + highlights: number, // -100 bis +100, default 0 + shadows: number, // -100 bis +100, default 0 + whites: number, // -100 bis +100, default 0 + blacks: number, // -100 bis +100, default 0 + vignette: { + amount: number, // 0.0 bis 1.0, default 0 + size: number, // 0.0 bis 1.0, default 0.5 + roundness: number, // 0.0 bis 1.0, default 1.0 + }, + preset: string | null, // "hdr" | "lowkey" | "highkey" | "flat" | null +} + +// type: "detail" +data: { + sharpen: { + amount: number, // 0–500, default 0 (Prozent) + radius: number, // 0.5–5.0, default 1.0 + threshold: number, // 0–255, default 0 + }, + clarity: number, // -100 bis +100, default 0 + denoise: { + luminance: number, // 0–100, default 0 + color: number, // 0–100, default 0 + }, + grain: { + amount: number, // 0–100, default 0 + size: number, // 0.5–3.0, default 1.0 + }, + preset: string | null, // "web" | "print" | "soft-glow" | "film-grain" | null +} + +// type: "render" +data: { + outputResolution: "original" | "2x" | "custom", + customWidth?: number, + customHeight?: number, + format: "png" | "jpeg" | "webp", + jpegQuality: number, // 1–100, default 90 (nur bei jpeg) + storageId?: string, // Erst nach Render befĂŒllt + url?: string, // Von Convex Query aufgelöst (wie bei Bild-Node) + lastRenderedAt?: number, +} +``` + +### Schema-ErgĂ€nzung (convex/schema.ts) + +Die `nodes`-Tabelle braucht keine Schema-Änderung — das polymorphe `data`-Feld (`v.any()`) trĂ€gt die Parameter bereits. Neue `type`-Werte (`curves`, `color-adjust`, `light`, `detail`, `render`) werden in die bestehende Union aufgenommen. + +--- + +## 6. Render-Node + +Der Render-Node ist der einzige Node in der Bildbearbeitungs-Kategorie, der serverseitig arbeitet. + +### Flow + +``` +1. User klickt "Render" am Render-Node +2. Client: collectPipeline() → vollstĂ€ndiger Adjustment-Stack +3. Client: FĂŒhrt Pipeline client-seitig aus (WebGL) +4. Client: glWrapper.toBlob() → Ergebnis als Blob +5. Client: Upload Blob → Convex Storage (wie Bild-Upload) +6. Client: updateData({ storageId, lastRenderedAt }) → Convex Mutation +7. Convex Query: storageId → url auflösen (wie bei Bild-/KI-Bild-Node) +8. Render-Node zeigt finales Bild +``` + +**Warum client-seitig rendern statt server-seitig?** + +- WebGL-Pipeline existiert bereits im Client (Preview) +- Kein Server-Roundtrip fĂŒr die Bildverarbeitung nötig +- Server mĂŒsste die gleiche Pipeline in jimp/sharp nachbauen (Aufwand, ParitĂ€t-Risiko) +- Nur der Upload des fertigen Blobs geht ĂŒber Convex Storage + +**Render-Status am Node:** + +``` +idle → rendering → uploading → done | error +``` + +- `rendering`: Client fĂŒhrt Pipeline aus (schnell, < 1s) +- `uploading`: Blob wird zu Convex Storage hochgeladen +- `done`: storageId gesetzt, Bild sichtbar +- `error`: Pipeline oder Upload fehlgeschlagen + +### Re-Render + +Wenn Upstream-Adjustments geĂ€ndert werden, zeigt der Render-Node einen visuellen Hinweis: "Out of date — Re-render". Der Render-Node tracked einen `pipelineHash` (Hash ĂŒber alle Upstream-Parameter) und vergleicht ihn mit dem Hash zum Zeitpunkt des letzten Renders. + +--- + +## 7. Live-Preview in Adjustment-Nodes + +Jeder Adjustment-Node zeigt eine Live-Preview des Bildes mit allen bisherigen Adjustments (inklusive seiner eigenen). + +### Implementierung + +``` +components/canvas/nodes/ + adjustment-preview.tsx ← Shared Preview-Komponente fĂŒr alle Adjustment-Nodes + curves-node.tsx ← Kurven-UI (Kurven-Editor + Preview) + color-adjust-node.tsx ← Farbe-UI (HSL-Slider, Color Balance Wheels + Preview) + light-node.tsx ← Licht-UI (Slider-Batterie + Preview) + detail-node.tsx ← Detail-UI (Slider + Preview) + render-node.tsx ← Render-Button + finales Bild +``` + +### adjustment-preview.tsx + +```tsx +// Pseudocode +function AdjustmentPreview({ nodeId }: { nodeId: string }) { + const nodes = useNodes(); + const edges = useEdges(); + + // Pipeline rĂŒckwĂ€rts traversieren + const sourceUrl = getSourceImage(nodeId, edges, nodes); + const pipeline = collectPipeline(nodeId, edges, nodes); + + // WebGL-Pipeline ausfĂŒhren (gecached) + const previewUrl = usePipelinePreview(sourceUrl, pipeline); + + return ; +} +``` + +### Performance-Budget + +- Preview-Auflösung: **Dynamisch** — proportional zur Node-Breite auf dem Canvas. Berechnung: `previewWidth = Math.min(nodeWidth * devicePixelRatio, 1024)`. Kleine Nodes bekommen kleine Previews, vergrĂ¶ĂŸerte Nodes bekommen schĂ€rfere. Obergrenze 1024px verhindert GPU-Überlastung bei extrem großen Nodes. +- Mindestbreite Adjustment-Nodes: **240px** — darunter werden Slider und Kurven-Editor unbedienbar. React Flow `minWidth` im NODE_DEFAULTS setzen. +- Debounce auf Slider-Änderungen: 16ms (requestAnimationFrame-aligned) +- Volle Auflösung nur beim Render-Node + +--- + +## 8. Preset-System + +Presets sind vordefinierte Parameter-Konfigurationen. Es gibt zwei Arten: Built-in-Presets (hardcoded, sofort verfĂŒgbar) und User-Presets (in Convex gespeichert, nutzerspezifisch). + +### Built-in-Presets + +```ts +// lib/image-pipeline/presets.ts + +export const CURVE_PRESETS: Record = { + contrast: { + channelMode: "rgb", + points: { rgb: [{ x: 0, y: 0 }, { x: 64, y: 48 }, { x: 192, y: 220 }, { x: 255, y: 255 }], ... }, + levels: { blackPoint: 0, whitePoint: 255, gamma: 1.0 }, + preset: "contrast", + }, + film: { ... }, + "cross-process": { ... }, +}; + +export const LIGHT_PRESETS: Record = { + hdr: { brightness: 0, contrast: 30, exposure: 0.5, highlights: -40, shadows: 60, ... }, + lowkey: { brightness: -20, contrast: 40, exposure: -0.5, ... }, + highkey: { brightness: 30, contrast: -10, exposure: 1.0, ... }, + flat: { brightness: 0, contrast: -50, exposure: 0, ... }, +}; +``` + +### User-Presets (Convex-persistiert) + +User-Presets werden in einer eigenen Convex-Tabelle gespeichert — keine Browser-AbhĂ€ngigkeit, kein Datenverlust bei Cache-Clear, verfĂŒgbar auf allen GerĂ€ten. + +**Neues Schema:** + +```ts +// convex/schema.ts — neue Tabelle + +adjustmentPresets: defineTable({ + userId: v.id("users"), + name: v.string(), // "Mein Film-Look" + nodeType: v.union( // FĂŒr welchen Adjustment-Typ + v.literal("curves"), + v.literal("color-adjust"), + v.literal("light"), + v.literal("detail"), + ), + params: v.any(), // Die gespeicherten Parameter + createdAt: v.number(), +}) + .index("by_userId", ["userId"]) + .index("by_userId_nodeType", ["userId", "nodeType"]), +``` + +**CRUD:** + +```ts +// convex/presets.ts + +export const list = query({ + args: { nodeType: v.optional(v.string()) }, + handler: async (ctx, args) => { + const user = await requireAuth(ctx); + if (args.nodeType) { + return ctx.db.query("adjustmentPresets") + .withIndex("by_userId_nodeType", q => q.eq("userId", user._id).eq("nodeType", args.nodeType)) + .collect(); + } + return ctx.db.query("adjustmentPresets") + .withIndex("by_userId", q => q.eq("userId", user._id)) + .collect(); + }, +}); + +export const save = mutation({ + args: { name: v.string(), nodeType: v.string(), params: v.any() }, + handler: async (ctx, args) => { ... }, +}); + +export const remove = mutation({ + args: { presetId: v.id("adjustmentPresets") }, + handler: async (ctx, args) => { ... }, +}); +``` + +**UI-Flow:** + +Am Adjustment-Node: Preset-Dropdown zeigt erst Built-in-Presets, dann eine Trennlinie, dann User-Presets. Daneben ein "Save"-Button der die aktuellen Parameter als User-Preset speichert (Name-Input via Inline-TextField). Auswahl eines Presets ĂŒberschreibt alle Parameter. Danach können Parameter manuell angepasst werden — `preset` wird auf `null` gesetzt (Custom). + +--- + +## 9. Sidebar-Integration + +Neue Sidebar-Kategorie **"Bildbearbeitung"** mit fĂŒnf EintrĂ€gen: + +| Sidebar-Eintrag | Node-Type | Icon | +|---|---|---| +| Kurven | `curves` | `TrendingUp` (lucide) | +| Farbe | `color-adjust` | `Palette` (lucide) | +| Licht | `light` | `Sun` (lucide) | +| Detail | `detail` | `Focus` (lucide) | +| Render | `render` | `ImageDown` (lucide) | + +Drag-Data: `application/lemonspace-node-type` mit dem jeweiligen `type`-String (konsistent mit bestehenden Nodes). + +--- + +## 10. Edge-Validierung + +Nicht jeder Node darf mit jedem verbunden werden. FĂŒr Adjustment-Nodes gelten Regeln: + +| Verbindung | Erlaubt? | +|---|---| +| Bild-Node → Adjustment-Node | ✅ | +| KI-Bild-Node → Adjustment-Node | ✅ | +| Adjustment-Node → Adjustment-Node | ✅ (Kette) | +| Adjustment-Node → Render-Node | ✅ | +| Bild-Node → Render-Node | ✅ (direkter Render ohne Adjustments) | +| Adjustment-Node → KI-Bild-Node | ❌ | +| Adjustment-Node → Prompt-Node | ❌ | +| Adjustment-Node → Compare-Node | ✅ (Preview als Bild-Quelle) | +| Text-Node → Adjustment-Node | ❌ | +| Adjustment-Node hat > 1 eingehende Edge | ❌ (genau 1 Input) | + +Die Validierung lĂ€uft in `canvas.tsx` bei `onConnect` — ungĂŒltige Verbindungen werden abgelehnt mit Toast-Feedback. + +--- + +## 11. Dateistruktur (Phase 2 — Bildbearbeitung) + +``` +lib/ + image-pipeline/ + gl-wrapper.ts ← WebGL-Context, Texture, Shader-Kompilierung + pipeline.ts ← Pipeline-Traversierung, Cache, Orchestrierung + presets.ts ← Built-in Presets fĂŒr alle Adjustment-Typen + curve-interpolation.ts ← Monotone kubische Interpolation → LUT + shaders/ + passthrough.vert ← Gemeinsamer Vertex-Shader + curves.frag ← Tonwert-Kurven + Levels + color-adjust.frag ← HSL, Color Balance, Temperature, Vibrance + light.frag ← Brightness, Contrast, Exposure, H/S, Vignette + detail.frag ← Sharpen, Clarity, Denoise, Grain + blur.frag ← Hilfshader fĂŒr Unsharp Mask + Denoise + +components/canvas/nodes/ + adjustment-preview.tsx ← Shared Preview fĂŒr alle Adjustment-Nodes + curves-node.tsx ← Kurven-Editor (interaktive BĂ©zier-Kurve) + color-adjust-node.tsx ← HSL-Slider, Color Balance, Temperature + light-node.tsx ← Slider-Batterie fĂŒr Licht-Parameter + detail-node.tsx ← Sharpen/Clarity/Denoise/Grain Slider + render-node.tsx ← Render-Button, Format-Auswahl, finales Bild + +hooks/ + use-pipeline-preview.ts ← Hook: Pipeline ausfĂŒhren → Preview-URL +``` + +--- + +## 12. Offene Entscheidungen + +| Thema | Status | Notizen | +|---|---|---| +| User-Presets persistieren | ✅ | Convex-Tabelle `adjustmentPresets` mit userId-Index. Kein Local Storage — Presets ĂŒberleben Cache-Clear und sind gerĂ€teĂŒbergreifend verfĂŒgbar. | +| Histogram-UI im Kurven-Node | ✅ | Histogram wird aus dem Pipeline-Output berechnet — zeigt die Tonwertverteilung *nach* allen vorhergehenden Adjustments. `gl.readPixels()` auf den aktuellen Framebuffer, dann HĂ€ufigkeitsverteilung ĂŒber R/G/B/Luminanz in JS berechnen. Downsampled auf Preview-Auflösung (nicht Originalbild), damit der Readback schnell bleibt. | +| Preview-Auflösung dynamisch | ✅ | Proportional zur Node-Breite × `devicePixelRatio`, gecapped bei 1024px. Adjustment-Nodes haben eine Mindestbreite von 240px. | +| 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 (WebGL → Blob → Upload). Server mĂŒsste Pipeline duplizieren. | +| WebGL-Fallback | ⏳ | Canvas 2D als Fallback? Praktisch alle modernen Browser haben WebGL2. Aufwand vs. Nutzen. | +| Detail-Node: Multi-Pass-Architektur | ⏳ | Framebuffer-Ping-Pong fĂŒr Unsharp Mask + Denoise. Exakte Implementierung TBD. | + +--- + +## 13. Credits & Performance + +| Aspekt | Wert | +|---|---| +| Credit-Kosten Adjustments | 0 Cr (client-seitig) | +| Credit-Kosten Render | 0 Cr (kein KI-API-Call, nur Convex Storage) | +| Preview-Latenz (Ziel) | < 16ms (60fps bei Slider-Drag) | +| Preview-Auflösung | Dynamisch: nodeWidth × devicePixelRatio, max 1024px | +| Mindestbreite Adjustment-Nodes | 240px | +| Max. Bild-Auflösung Render | Original-Auflösung | +| WebGL-Version | WebGL2 (ES 3.0 Shaders) | + +--- + +*LemonSpace ADR — Adjustment-Stack — MĂ€rz 2026*