feat: update LemonSpace ADR to reflect WebWorker migration and caching strategy

- 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.
This commit is contained in:
Matthias
2026-03-31 14:02:39 +02:00
parent 05f82af982
commit 73bdf56f72

View File

@@ -8,7 +8,7 @@
## 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.
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.
---
@@ -17,7 +17,7 @@ Adjustment-Nodes arbeiten non-destruktiv über eine **edge-basierte Pipeline**.
| 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 |
| 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 |
@@ -53,7 +53,7 @@ Wenn ein Node seine Live-Preview rendern will, traversiert er die Edge-Kette **r
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
4. Wende die aktive Bildpipeline (Worker-Renderpfad) auf das Quell-Bild an
5. Zeige Ergebnis als Preview
```
@@ -92,27 +92,80 @@ function getSourceImage(nodeId: string, edges: Edge[], nodes: Node[]): string |
### 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.
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 → Kurven → Farbe → Detail
User ändert Farbe-Parameter
→ Kurven-Cache bleibt gültig
→ Farbe + Detail werden neu gerendert
Bild-URL unverändert
→ Quell-Bitmap bleibt im Worker-Cache
→ neue Parameter triggern nur Re-Render der Verarbeitung
```
Invalidierung: Wenn ein Upstream-Node seine Parameter ändert, werden alle Downstream-Caches invalidiert. Die Invalidierung propagiert über die Edge-Kette vorwärts.
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/
@@ -320,7 +373,7 @@ data: {
preset: string | null, // "warm" | "cool" | "vintage" | "desaturate" | null
}
// type: "light"
// type: "light-adjust"
data: {
brightness: number, // -100 bis +100, default 0
contrast: number, // -100 bis +100, default 0
@@ -337,7 +390,7 @@ data: {
preset: string | null, // "hdr" | "lowkey" | "highkey" | "flat" | null
}
// type: "detail"
// type: "detail-adjust"
data: {
sharpen: {
amount: number, // 0500, default 0 (Prozent)
@@ -371,48 +424,43 @@ data: {
### 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.
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 ist der einzige Node in der Bildbearbeitungs-Kategorie, der serverseitig arbeitet.
Der Render-Node rendert aktuell client-seitig über die Worker-Bridge und bietet primär einen lokalen Datei-Export.
### 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
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 statt server-seitig?**
**Warum client-seitig rendern?**
- 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
- 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 → uploading → done | error
idle → rendering → 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
- `rendering`: Worker rendert und erzeugt Blob
- `done`: Download wurde angestoßen
- `error`: Worker-Render oder Download-Erzeugung 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.
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.
---
@@ -437,17 +485,21 @@ components/canvas/nodes/
```tsx
// Pseudocode
function AdjustmentPreview({ nodeId }: { nodeId: string }) {
const nodes = useNodes();
const edges = useEdges();
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);
// WebGL-Pipeline ausführen (gecached)
const previewUrl = usePipelinePreview(sourceUrl, pipeline);
// Worker-Preview (mit Main-Thread-Fallback)
const { previewRef, histogram, isRendering } = usePipelinePreview(
sourceUrl,
pipeline,
nodeWidth,
);
return <img src={previewUrl} className="w-full h-auto rounded" />;
return <canvas ref={previewRef} className="w-full h-auto rounded" />;
}
```
@@ -503,8 +555,8 @@ adjustmentPresets: defineTable({
nodeType: v.union( // Für welchen Adjustment-Typ
v.literal("curves"),
v.literal("color-adjust"),
v.literal("light"),
v.literal("detail"),
v.literal("light-adjust"),
v.literal("detail-adjust"),
),
params: v.any(), // Die gespeicherten Parameter
createdAt: v.number(),
@@ -558,8 +610,8 @@ Neue Sidebar-Kategorie **"Bildbearbeitung"** mit fünf Einträgen:
|---|---|---|
| Kurven | `curves` | `TrendingUp` (lucide) |
| Farbe | `color-adjust` | `Palette` (lucide) |
| Licht | `light` | `Sun` (lucide) |
| Detail | `detail` | `Focus` (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).
@@ -592,17 +644,19 @@ Die Validierung läuft in `canvas.tsx` bei `onConnect` — ungültige Verbindung
```
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.fragTonwert-Kurven + Levels
color-adjust.frag ← HSL, Color Balance, Temperature, Vibrance
light.frag ← Brightness, Contrast, Exposure, H/S, Vignette
detail.fragSharpen, Clarity, Denoise, Grain
blur.frag Hilfshader für Unsharp Mask + Denoise
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.tsWebGL-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.tsZielauflö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
@@ -610,10 +664,10 @@ components/canvas/nodes/
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
render-node.tsx ← Download-Export über `bridge.renderFull(...)`
hooks/
use-pipeline-preview.ts ← Hook: Pipeline ausführen → Preview-URL
use-pipeline-preview.ts ← Hook: Worker-Preview mit Fallback + Recovery
```
---
@@ -623,12 +677,12 @@ hooks/
| 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. |
| 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 (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. |
| 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. |
---
@@ -637,12 +691,12 @@ hooks/
| Aspekt | Wert |
|---|---|
| Credit-Kosten Adjustments | 0 Cr (client-seitig) |
| Credit-Kosten Render | 0 Cr (kein KI-API-Call, nur Convex Storage) |
| 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 |
| WebGL-Version | WebGL2 (ES 3.0 Shaders) |
| Primärer Renderpfad | Web Worker + OffscreenCanvas/2D |
---