Files
lemonspace_app/.docs/LemonSpace_ADR_AdjustmentStack.md
Matthias Meister 05f82af982 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.
2026-03-30 09:55:06 +02:00

24 KiB
Raw Blame History

🍋 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
// 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

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:

#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.

#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.

// 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.

// 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.

// 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

// 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

// 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 <img src={previewUrl} 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

// 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:

// 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:

// 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