- Revised the image processing pipeline description to highlight the transition to a Web-Worker-based architecture using OffscreenCanvas for rendering. - Updated caching strategy details to clarify how source images are cached and how parameter changes affect rendering. - Documented the current implementation status, including the integration of worker requests for preview and full rendering, and noted deviations from the original architectural vision. - Added sections on fallback mechanisms and the influence of the WebWorker migration guide on the current implementation.
704 lines
27 KiB
Markdown
704 lines
27 KiB
Markdown
# đ 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 primĂ€r ĂŒber eine **Web-Worker-Pipeline** mit OffscreenCanvas/2D-Rendering; ein WebGL-Pfad existiert ergĂ€nzend fĂŒr kompatible Teilpfade. 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 |
|
||
| 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: 31.03.2026)
|
||
|
||
### Produktiv umgesetzt
|
||
|
||
- 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.
|
||
|
||
### Abweichungen zur ursprĂŒnglichen ADR-Intention / Zielvision aus dem Guide
|
||
|
||
- 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.
|
||
|
||
### 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`.
|
||
|
||
---
|
||
|
||
## Einfluss des WebWorker-Migration-Guides
|
||
|
||
### Ăbernommene Konzepte
|
||
|
||
- 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.
|
||
|
||
### Bewusst abgewandelte 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.
|
||
|
||
### 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 weiterhin die WebGL-Ziel-/Referenzarchitektur der Pipeline. Produktiv lĂ€uft die Preview-/Render-AusfĂŒhrung derzeit primĂ€r im Worker ĂŒber OffscreenCanvas/2D.
|
||
|
||
### 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<string, WebGLProgram>; // Kompilierte Shader-Programme
|
||
private textures: Map<string, WebGLTexture>; // Gecachte Zwischenergebnisse
|
||
|
||
// Canvas erstellen (offscreen, nicht sichtbar im DOM)
|
||
constructor(width: number, height: number);
|
||
|
||
// Bild von URL in Texture laden
|
||
loadTexture(url: string): Promise<WebGLTexture>;
|
||
|
||
// 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<string, number | number[]>,
|
||
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<Blob>;
|
||
|
||
// 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 <canvas ref={previewRef} className="w-full h-auto rounded" />;
|
||
}
|
||
```
|
||
|
||
### 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<string, CurvesData> = {
|
||
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<string, LightData> = {
|
||
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. 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 | âł | Guide-Zielbild; aktuell produktiv ist ein OffscreenCanvas/2D-Pfad im Worker. |
|
||
|
||
---
|
||
|
||
## 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 | Web Worker + OffscreenCanvas/2D |
|
||
|
||
---
|
||
|
||
*LemonSpace ADR â Adjustment-Stack â MĂ€rz 2026*
|