# 🍋 LemonSpace — ADR: Non-destruktiver Adjustment-Stack **Status:** In Progress (Phase 0) **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. 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. --- ## 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 | | Reine Canvas-2D-Pipeline im Main Thread | Bei grĂ¶ĂŸeren Bildern und hĂ€ufiger Interaktion zu teuer; blockiert UI-Thread | | 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 die aktive Bildpipeline (Worker-Renderpfad) 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 Die aktuelle Produktiv-Implementierung cached im Worker vor allem das Quellbild (`sourceUrl` → `ImageBitmap`). Bei reinen ParameterĂ€nderungen bleibt dieser Cache gĂŒltig, nur die Verarbeitungsschritte werden neu ausgefĂŒhrt. ``` Bild-URL unverĂ€ndert → Quell-Bitmap bleibt im Worker-Cache → neue Parameter triggern nur Re-Render der Verarbeitung ``` Invalidierung erfolgt request-basiert: Neue Requests verdrĂ€ngen veraltete Ergebnisse; stale Bitmaps werden verworfen und freigegeben. --- ## Implementierungsstand (Stand: Phase 0) ### Aktueller Ist-Zustand - 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. ### Was in Phase 0 bewusst noch nicht enthalten ist - 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 - 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 (Zielbild) ### Übernommene Konzepte (architektonisch) - Entkopplung von UI und Bildpipeline ĂŒber Worker + Bridge. - Trennung von Preview und Full-Render API. - Deterministische Pipeline-Berechnung und zyklussichere Traversierung. ### Bewusst offen gehaltene Punkte - 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 - Evaluierung, ob und wo ein stĂ€rkerer WebGL-Worker-Pfad wieder sinnvoll ist (insbesondere bei komplexen Anpassungen oder sehr großen Bildern). - Fortlaufende Beobachtung der Worker/Fallback-Quote anhand `preview-metrics`. - Falls persistierte Render-Artefakte wieder Produktziel werden, separaten Upload-/Persistenzpfad explizit ergĂ€nzen. --- ## 4. WebGL-Wrapper > **Hinweis zum Ist-Stand:** Dieser Abschnitt dokumentiert die Ziel-/Referenzarchitektur. Die Runtime ist im aktuellen Repository noch nicht integriert. ### Geplante Dateien ``` lib/ image-pipeline/ index.ts ← Singleton-Lifecycle fĂŒr PipelineBridge pipeline-bridge.ts ← Main-Thread-Bridge (Worker API) pipeline.worker.ts ← Worker-Pipeline (OffscreenCanvas/2D) preview-metrics.ts ← Laufzeit-Metriken (Worker/Fallback) 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-adjust" 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-adjust" 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-adjust`, `detail-adjust`, `render`) werden in die bestehende Union aufgenommen. --- ## 6. Render-Node Der Render-Node rendert aktuell client-seitig ĂŒber die Worker-Bridge und bietet primĂ€r einen lokalen Datei-Export. ### Flow ``` 1. User startet Export im Render-Node 2. Client: `collectPipeline()` + `getSourceImage()` 3. Client: `PipelineBridge.renderFull(...)` im Worker (OffscreenCanvas) 4. Worker: `convertToBlob(...)` und RĂŒckgabe von Blob + Output-Dimensionen 5. Client: Download-Link (`URL.createObjectURL`) wird erzeugt und ausgelöst ``` **Warum client-seitig rendern?** - Pipeline-Logik und Vorschaupfad sind bereits im Client vorhanden - Kein obligatorischer Server-Roundtrip fĂŒr die Bildberechnung - Export ist direkt als Datei verfĂŒgbar **Render-Status am Node:** ``` idle → rendering → done | error ``` - `rendering`: Worker rendert und erzeugt Blob - `done`: Download wurde angestoßen - `error`: Worker-Render oder Download-Erzeugung fehlgeschlagen ### Re-Render Pipeline-Hashing bleibt als Basis fĂŒr Änderungsdetektion relevant (`hashPipeline(...)`). Ein persistierter „Out of date — Re-render“-Flow mit serverseitigem Artefakt ist in der aktuellen Export-Implementierung nicht der zentrale Pfad. --- ## 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, edges } = useCanvasGraph(); const nodeWidth = useNodeWidth(nodeId); // Pipeline rĂŒckwĂ€rts traversieren const sourceUrl = getSourceImage(nodeId, edges, nodes); const pipeline = collectPipeline(nodeId, edges, nodes); // Worker-Preview (mit Main-Thread-Fallback) const { previewRef, histogram, isRendering } = usePipelinePreview( sourceUrl, pipeline, nodeWidth, ); 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-adjust"), v.literal("detail-adjust"), ), 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-adjust` | `Sun` (lucide) | | Detail | `detail-adjust` | `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. Ziel-Dateistruktur (Phase 2 — Bildbearbeitung) ``` lib/ image-pipeline/ index.ts ← get/dispose PipelineBridge (Singleton) pipeline-bridge.ts ← Main-Thread-Bridge fĂŒr Worker-Aufrufe pipeline.worker.ts ← Worker-Pipeline (Preview + Full Render) pipeline.ts ← Edge-Traversierung + Pipeline-Hashing canvas-render.ts ← Main-Thread-Fallback-Rendering webgl-render.ts ← Optionaler WebGL-Renderpfad gl-wrapper.ts ← WebGL-Helfer fĂŒr den optionalen Pfad curve-lut.ts ← Kurven-LUT-Berechnung + Anwendung adjustments.ts ← Aggregation/Anwendung von Adjustment-Parametern histogram.ts ← Histogramm-Berechnung und Datentransferformate render-size.ts ← Zielauflösung/Skalierungslogik preview-metrics.ts ← Worker/Fallback-Metriken presets.ts ← Built-in Presets fĂŒr Adjustment-Typen 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 ← Download-Export ĂŒber `bridge.renderFull(...)` hooks/ use-pipeline-preview.ts ← Hook: Worker-Preview mit Fallback + Recovery ``` --- ## 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. Im Worker wird dazu das finale ImageData gelesen und in Histogram-Bins aggregiert. | | 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 ĂŒ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 | ⏳ | Optionaler Off-Path; Entscheidung folgt in spĂ€teren Phasen. | --- ## 13. Credits & Performance | Aspekt | Wert | |---|---| | Credit-Kosten Adjustments | 0 Cr (client-seitig) | | Credit-Kosten Render | 0 Cr (kein KI-API-Call; aktueller Exportpfad ist client-seitig) | | 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 | | PrimĂ€rer Renderpfad | Ziel: Web Worker (technischer Unterpfad wird spĂ€ter festgelegt) | --- *LemonSpace ADR — Adjustment-Stack — MĂ€rz 2026*