From de37b63b2b742179282c7bbbbbf72a942921412b Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sun, 5 Apr 2026 17:28:43 +0200 Subject: [PATCH] feat(canvas): accelerate local previews and harden edge flows --- .../canvas/__tests__/canvas-helpers.test.ts | 170 +++++++ .../canvas/__tests__/compare-node.test.tsx | 25 +- .../__tests__/use-canvas-connections.test.tsx | 78 +++ .../__tests__/use-node-local-data.test.tsx | 448 ++++++++++++++++++ components/canvas/canvas-delete-handlers.ts | 28 +- components/canvas/canvas-graph-context.tsx | 116 ++++- components/canvas/canvas.tsx | 2 + .../canvas/nodes/adjustment-preview.tsx | 66 +-- components/canvas/nodes/color-adjust-node.tsx | 1 + components/canvas/nodes/compare-surface.tsx | 14 +- components/canvas/nodes/curves-node.tsx | 1 + .../canvas/nodes/detail-adjust-node.tsx | 1 + components/canvas/nodes/light-adjust-node.tsx | 1 + components/canvas/nodes/render-node.tsx | 9 + .../canvas/nodes/use-node-local-data.ts | 103 ++-- components/canvas/use-canvas-connections.ts | 38 +- .../2026-04-05-preview-graph-architecture.md | 371 +++++++++++++++ hooks/use-debounced-callback.ts | 46 +- hooks/use-pipeline-preview.ts | 82 +--- lib/canvas-render-preview.ts | 69 ++- lib/image-pipeline/backend/backend-router.ts | 23 - .../backend/webgl/webgl-backend.ts | 330 ++++++++++--- tests/canvas-delete-handlers.test.ts | 135 ++++++ tests/image-pipeline/parity/fixtures.ts | 204 ++++++-- .../image-pipeline/webgl-backend-poc.test.ts | 140 +++++- tests/light-adjust-node.test.ts | 102 +++- tests/use-node-local-data-order.test.ts | 95 ++++ tests/use-pipeline-preview.test.ts | 410 +++++++++++++++- vitest.config.ts | 1 + 29 files changed, 2751 insertions(+), 358 deletions(-) create mode 100644 components/canvas/__tests__/use-node-local-data.test.tsx create mode 100644 docs/plans/2026-04-05-preview-graph-architecture.md create mode 100644 tests/canvas-delete-handlers.test.ts create mode 100644 tests/use-node-local-data-order.test.ts diff --git a/components/canvas/__tests__/canvas-helpers.test.ts b/components/canvas/__tests__/canvas-helpers.test.ts index 4596282..d1844b0 100644 --- a/components/canvas/__tests__/canvas-helpers.test.ts +++ b/components/canvas/__tests__/canvas-helpers.test.ts @@ -4,6 +4,7 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { withResolvedCompareData } from "../canvas-helpers"; import { buildGraphSnapshot, + pruneCanvasGraphNodeDataOverrides, resolveRenderPreviewInputFromGraph, } from "@/lib/canvas-render-preview"; @@ -100,6 +101,129 @@ describe("withResolvedCompareData", () => { }); describe("canvas preview graph helpers", () => { + it("treats node data overrides as complete normalized objects when building a graph snapshot", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-1", + type: "image", + data: { + url: "https://cdn.example.com/persisted.png", + previewUrl: "https://cdn.example.com/persisted-preview.png", + label: "Persisted label", + }, + }, + ], + [], + { + nodeDataOverrides: new Map([ + [ + "image-1", + { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/override-preview.png", + }, + ], + ]), + }, + ); + + expect(graph.nodesById.get("image-1")).toMatchObject({ + data: { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/override-preview.png", + }, + }); + }); + + it("prunes stale node data overrides for deleted nodes and persisted catch-up", () => { + const overrides = pruneCanvasGraphNodeDataOverrides( + [ + { + id: "image-1", + type: "image", + data: { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/persisted-preview.png", + label: "Persisted label", + }, + }, + ], + new Map([ + [ + "image-1", + { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/local-preview.png", + }, + ], + ["deleted-node", { previewUrl: "https://cdn.example.com/stale-preview.png" }], + ]), + ); + + expect(overrides).toEqual( + new Map([ + [ + "image-1", + { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/local-preview.png", + }, + ], + ]), + ); + }); + + it("keeps already-pruned node data overrides stable", () => { + const override = { previewUrl: "https://cdn.example.com/local-preview.png" }; + const overrides = new Map([["image-1", override]]); + + const nextOverrides = pruneCanvasGraphNodeDataOverrides( + [ + { + id: "image-1", + type: "image", + data: { + url: "https://cdn.example.com/persisted-source.png", + previewUrl: "https://cdn.example.com/persisted-preview.png", + }, + }, + ], + overrides, + ); + + expect(nextOverrides).toBe(overrides); + }); + + it("keeps full nested overrides until persisted data fully catches up", () => { + const override = { + exposure: 0.8, + adjustments: { + shadows: 12, + highlights: -4, + }, + }; + + const nextOverrides = pruneCanvasGraphNodeDataOverrides( + [ + { + id: "curves-1", + type: "curves", + data: { + exposure: 0.2, + adjustments: { + shadows: 0, + highlights: -4, + }, + }, + }, + ], + new Map([["curves-1", override]]), + ); + + expect(nextOverrides).toEqual(new Map([["curves-1", override]])); + }); + it("resolves the upstream source and pipeline steps from a graph snapshot", () => { const graph = buildGraphSnapshot( [ @@ -139,4 +263,50 @@ describe("canvas preview graph helpers", () => { }, ]); }); + + it("prefers local node data overrides during render preview resolution", () => { + const graph = buildGraphSnapshot( + [ + { + id: "image-1", + type: "image", + data: { url: "https://cdn.example.com/persisted-source.png" }, + }, + { + id: "curves-1", + type: "curves", + data: { exposure: 0.2 }, + }, + { + id: "render-1", + type: "render", + data: {}, + }, + ], + [ + { source: "image-1", target: "curves-1" }, + { source: "curves-1", target: "render-1" }, + ], + { + nodeDataOverrides: new Map([ + ["image-1", { url: "https://cdn.example.com/override-source.png" }], + ["curves-1", { exposure: 0.8 }], + ]), + }, + ); + + const preview = resolveRenderPreviewInputFromGraph({ + nodeId: "render-1", + graph, + }); + + expect(preview.sourceUrl).toBe("https://cdn.example.com/override-source.png"); + expect(preview.steps).toEqual([ + { + nodeId: "curves-1", + type: "curves", + params: { exposure: 0.8 }, + }, + ]); + }); }); diff --git a/components/canvas/__tests__/compare-node.test.tsx b/components/canvas/__tests__/compare-node.test.tsx index 93b8df6..39678d2 100644 --- a/components/canvas/__tests__/compare-node.test.tsx +++ b/components/canvas/__tests__/compare-node.test.tsx @@ -2,6 +2,8 @@ import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context"; + type StoreState = { nodes: Array<{ id: string; type?: string; data?: unknown }>; edges: Array<{ @@ -39,6 +41,17 @@ vi.mock("../nodes/compare-surface", () => ({ import CompareNode from "../nodes/compare-node"; +function renderCompareNode(props: Record) { + return renderToStaticMarkup( + } + edges={storeState.edges} + > + )} /> + , + ); +} + describe("CompareNode render preview inputs", () => { beforeEach(() => { storeState.nodes = []; @@ -69,8 +82,7 @@ describe("CompareNode render preview inputs", () => { }, ]; - renderToStaticMarkup( - React.createElement(CompareNode, { + renderCompareNode({ id: "compare-1", data: { leftUrl: "https://cdn.example.com/render-output.png" }, selected: false, @@ -86,8 +98,7 @@ describe("CompareNode render preview inputs", () => { targetPosition: undefined, positionAbsoluteX: 0, positionAbsoluteY: 0, - } as never), - ); + }); expect(compareSurfaceSpy).toHaveBeenCalled(); const previewCall = compareSurfaceSpy.mock.calls.find( @@ -131,8 +142,7 @@ describe("CompareNode render preview inputs", () => { }, ]; - renderToStaticMarkup( - React.createElement(CompareNode, { + renderCompareNode({ id: "compare-1", data: { leftUrl: "https://cdn.example.com/render-output.png" }, selected: false, @@ -148,8 +158,7 @@ describe("CompareNode render preview inputs", () => { targetPosition: undefined, positionAbsoluteX: 0, positionAbsoluteY: 0, - } as never), - ); + }); expect(compareSurfaceSpy).toHaveBeenCalledTimes(1); expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({ diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index b8ce0b1..0f992ea 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -147,6 +147,14 @@ describe("useCanvasConnections", () => { }); await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); latestHandlersRef.current?.onConnectEnd( { clientX: 400, clientY: 260 } as MouseEvent, { @@ -192,6 +200,14 @@ describe("useCanvasConnections", () => { }); await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); latestHandlersRef.current?.onConnectEnd( { clientX: 123, clientY: 456 } as MouseEvent, { @@ -244,6 +260,14 @@ describe("useCanvasConnections", () => { }); await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); latestHandlersRef.current?.onConnectEnd( { clientX: 300, clientY: 210 } as MouseEvent, { @@ -288,6 +312,14 @@ describe("useCanvasConnections", () => { }); await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: "target-handle", + handleType: "target", + } as never, + ); latestHandlersRef.current?.onConnectEnd( { clientX: 200, clientY: 200 } as MouseEvent, { @@ -313,4 +345,50 @@ describe("useCanvasConnections", () => { targetHandle: "target-handle", }); }); + + it("ignores onConnectEnd when no connect drag is active", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).not.toHaveBeenCalled(); + expect(latestHandlersRef.current?.connectionDropMenu).toBeNull(); + }); }); diff --git a/components/canvas/__tests__/use-node-local-data.test.tsx b/components/canvas/__tests__/use-node-local-data.test.tsx new file mode 100644 index 0000000..bcf5277 --- /dev/null +++ b/components/canvas/__tests__/use-node-local-data.test.tsx @@ -0,0 +1,448 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CanvasGraphProvider, + useCanvasGraph, + useCanvasGraphPreviewOverrides, +} from "@/components/canvas/canvas-graph-context"; +import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; + +type AdjustmentData = { + exposure: number; + label?: string; +}; + +type HookHarnessProps = { + nodeId: string; + data: AdjustmentData; + onSave: (value: AdjustmentData) => Promise | void; +}; + +const latestHookRef: { + current: + | { + applyLocalData: (next: AdjustmentData) => void; + updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void; + localData: AdjustmentData; + } + | null; +} = { current: null }; + +const latestOverridesRef: { + current: ReadonlyMap; +} = { current: new Map() }; + +const canvasGraphSetOverrideRef: { + current: ((nodeId: string, data: unknown) => void) | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function HookHarness({ nodeId, data, onSave }: HookHarnessProps) { + const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + nodeId, + data, + normalize: (value) => ({ ...(value as AdjustmentData) }), + saveDelayMs: 1000, + onSave, + debugLabel: "curves", + }); + + useEffect(() => { + latestHookRef.current = { + applyLocalData, + updateLocalData, + localData, + }; + + return () => { + latestHookRef.current = null; + }; + }, [applyLocalData, localData, updateLocalData]); + + return null; +} + +function GraphProbe() { + const { previewNodeDataOverrides } = useCanvasGraph(); + const { setPreviewNodeDataOverride } = useCanvasGraphPreviewOverrides(); + + useEffect(() => { + latestOverridesRef.current = previewNodeDataOverrides; + canvasGraphSetOverrideRef.current = setPreviewNodeDataOverride; + + return () => { + canvasGraphSetOverrideRef.current = null; + }; + }, [previewNodeDataOverrides, setPreviewNodeDataOverride]); + + return null; +} + +function TestApp({ + nodeId, + data, + mounted, + onSave, +}: { + nodeId: string; + data: AdjustmentData; + mounted: boolean; + onSave: (value: AdjustmentData) => Promise | void; +}) { + return ( + + {mounted ? : null} + + + ); +} + +describe("useNodeLocalData preview overrides", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestHookRef.current = null; + latestOverridesRef.current = new Map(); + canvasGraphSetOverrideRef.current = null; + vi.clearAllMocks(); + + if (root) { + await act(async () => { + root?.unmount(); + }); + } + + container?.remove(); + root = null; + container = null; + }); + + it("sets a preview override for local edits and clears it when persisted data catches up", async () => { + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.updateLocalData((current) => ({ + ...current, + exposure: 0.8, + })); + }); + + expect(latestOverridesRef.current).toEqual( + new Map([["node-1", { exposure: 0.8, label: "persisted" }]]), + ); + + await act(async () => { + root?.render( + , + ); + }); + + expect(latestOverridesRef.current).toEqual(new Map()); + }); + + it("does not update the canvas graph provider during render when updating local data", async () => { + const onSave = vi.fn(async () => undefined); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.updateLocalData((current) => ({ + ...current, + exposure: 0.8, + })); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + expect.stringContaining( + "Cannot update a component (`CanvasGraphProvider`) while rendering a different component", + ), + ); + }); + + it("does not write preview overrides from inside the local state updater", async () => { + const onSave = vi.fn(async () => undefined); + const originalSetPreviewNodeDataOverride = canvasGraphSetOverrideRef.current; + let insideUpdater = false; + + canvasGraphSetOverrideRef.current = () => { + if (insideUpdater) { + throw new Error("setPreviewNodeDataOverride called during updater"); + } + }; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await expect( + act(async () => { + latestHookRef.current?.updateLocalData((current) => { + insideUpdater = true; + return { + ...current, + exposure: 0.8, + }; + }); + }), + ).resolves.toBeUndefined(); + + insideUpdater = false; + canvasGraphSetOverrideRef.current = originalSetPreviewNodeDataOverride; + }); + + it("clears its preview override on unmount", async () => { + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "persisted", + }); + }); + + expect(latestOverridesRef.current).toEqual( + new Map([["node-1", { exposure: 0.8, label: "persisted" }]]), + ); + + await act(async () => { + root?.render( + , + ); + }); + + expect(latestOverridesRef.current).toEqual(new Map()); + }); + + it("cancels a pending debounced save when the node unmounts", async () => { + vi.useFakeTimers(); + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "persisted", + }); + }); + + expect(onSave).not.toHaveBeenCalled(); + + await act(async () => { + root?.render( + , + ); + }); + + expect(onSave).not.toHaveBeenCalled(); + expect(latestOverridesRef.current).toEqual(new Map()); + + vi.useRealTimers(); + }); + + it("clears pending preview state after a rejected save so persisted data can recover", async () => { + vi.useFakeTimers(); + const onSave = vi.fn(async () => { + throw new Error("save failed"); + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "persisted", + }); + }); + + expect(latestOverridesRef.current).toEqual( + new Map([["node-1", { exposure: 0.8, label: "persisted" }]]), + ); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(latestOverridesRef.current).toEqual(new Map()); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect(latestHookRef.current?.localData).toEqual({ exposure: 0.3, label: "server" }); + + vi.useRealTimers(); + }); + + it("accepts a non-identical persisted update after a successful save", async () => { + vi.useFakeTimers(); + const onSave = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHookRef.current?.applyLocalData({ + exposure: 0.8, + label: "client", + }); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + }); + + expect(latestHookRef.current?.localData).toEqual({ + exposure: 0.75, + label: "server-normalized", + }); + expect(latestOverridesRef.current).toEqual(new Map()); + + vi.useRealTimers(); + }); +}); diff --git a/components/canvas/canvas-delete-handlers.ts b/components/canvas/canvas-delete-handlers.ts index 159b77d..a70c236 100644 --- a/components/canvas/canvas-delete-handlers.ts +++ b/components/canvas/canvas-delete-handlers.ts @@ -122,22 +122,22 @@ export function useCanvasDeleteHandlers({ nodes, edges, ); - const edgePromises = bridgeCreates.map((bridgeCreate) => - runCreateEdgeMutation({ - canvasId, - sourceNodeId: bridgeCreate.sourceNodeId, - targetNodeId: bridgeCreate.targetNodeId, - sourceHandle: bridgeCreate.sourceHandle, - targetHandle: bridgeCreate.targetHandle, - }), - ); - void Promise.all([ - runBatchRemoveNodesMutation({ + void (async () => { + await runBatchRemoveNodesMutation({ nodeIds: idsToDelete as Id<"nodes">[], - }), - ...edgePromises, - ]) + }); + + for (const bridgeCreate of bridgeCreates) { + await runCreateEdgeMutation({ + canvasId, + sourceNodeId: bridgeCreate.sourceNodeId, + targetNodeId: bridgeCreate.targetNodeId, + sourceHandle: bridgeCreate.sourceHandle, + targetHandle: bridgeCreate.targetHandle, + }); + } + })() .then(() => { // Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen. // Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält. diff --git a/components/canvas/canvas-graph-context.tsx b/components/canvas/canvas-graph-context.tsx index a7f46b0..23e2267 100644 --- a/components/canvas/canvas-graph-context.tsx +++ b/components/canvas/canvas-graph-context.tsx @@ -1,17 +1,37 @@ "use client"; -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { buildGraphSnapshot, type CanvasGraphEdgeLike, + type CanvasGraphNodeDataOverrides, type CanvasGraphNodeLike, type CanvasGraphSnapshot, + pruneCanvasGraphNodeDataOverrides, } from "@/lib/canvas-render-preview"; -type CanvasGraphContextValue = CanvasGraphSnapshot; +type CanvasGraphContextValue = CanvasGraphSnapshot & { + previewNodeDataOverrides: CanvasGraphNodeDataOverrides; +}; + +type CanvasGraphPreviewOverridesContextValue = { + setPreviewNodeDataOverride: (nodeId: string, data: unknown) => void; + clearPreviewNodeDataOverride: (nodeId: string) => void; + clearPreviewNodeDataOverrides: () => void; +}; const CanvasGraphContext = createContext(null); +const CanvasGraphPreviewOverridesContext = + createContext(null); export function CanvasGraphProvider({ nodes, @@ -22,9 +42,88 @@ export function CanvasGraphProvider({ edges: readonly CanvasGraphEdgeLike[]; children: ReactNode; }) { - const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]); + const [previewNodeDataOverrides, setPreviewNodeDataOverrides] = + useState(() => new Map()); - return {children}; + const setPreviewNodeDataOverride = useCallback((nodeId: string, data: unknown) => { + setPreviewNodeDataOverrides((previous) => { + if (previous.has(nodeId) && Object.is(previous.get(nodeId), data)) { + return previous; + } + + const next = new Map(previous); + next.set(nodeId, data); + return next; + }); + }, []); + + const clearPreviewNodeDataOverride = useCallback((nodeId: string) => { + setPreviewNodeDataOverrides((previous) => { + if (!previous.has(nodeId)) { + return previous; + } + + const next = new Map(previous); + next.delete(nodeId); + return next; + }); + }, []); + + const clearPreviewNodeDataOverrides = useCallback(() => { + setPreviewNodeDataOverrides((previous) => { + if (previous.size === 0) { + return previous; + } + + return new Map(); + }); + }, []); + + const prunedPreviewNodeDataOverrides = useMemo( + () => pruneCanvasGraphNodeDataOverrides(nodes, previewNodeDataOverrides), + [nodes, previewNodeDataOverrides], + ); + + useEffect(() => { + if (prunedPreviewNodeDataOverrides !== previewNodeDataOverrides) { + setPreviewNodeDataOverrides(prunedPreviewNodeDataOverrides); + } + }, [previewNodeDataOverrides, prunedPreviewNodeDataOverrides]); + + const graph = useMemo( + () => + buildGraphSnapshot(nodes, edges, { + nodeDataOverrides: prunedPreviewNodeDataOverrides, + }), + [edges, nodes, prunedPreviewNodeDataOverrides], + ); + + const value = useMemo( + () => ({ + ...graph, + previewNodeDataOverrides: prunedPreviewNodeDataOverrides, + }), + [graph, prunedPreviewNodeDataOverrides], + ); + + const previewOverridesValue = useMemo( + () => ({ + setPreviewNodeDataOverride, + clearPreviewNodeDataOverride, + clearPreviewNodeDataOverrides, + }), + [ + clearPreviewNodeDataOverride, + clearPreviewNodeDataOverrides, + setPreviewNodeDataOverride, + ], + ); + + return ( + + {children} + + ); } export function useCanvasGraph(): CanvasGraphContextValue { @@ -35,3 +134,12 @@ export function useCanvasGraph(): CanvasGraphContextValue { return context; } + +export function useCanvasGraphPreviewOverrides(): CanvasGraphPreviewOverridesContextValue { + const context = useContext(CanvasGraphPreviewOverridesContext); + if (!context) { + throw new Error("useCanvasGraphPreviewOverrides must be used within CanvasGraphProvider"); + } + + return context; +} diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 84ad127..f508a3e 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -308,6 +308,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { closeConnectionDropMenu, handleConnectionDropPick, onConnect, + onConnectStart, onConnectEnd, onReconnectStart, onReconnect, @@ -520,6 +521,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { onNodeDrag={onNodeDrag} onNodeDragStop={onNodeDragStop} onConnect={onConnect} + onConnectStart={onConnectStart} onConnectEnd={onConnectEnd} onReconnect={onReconnect} onReconnectStart={onReconnectStart} diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx index 0eda002..f0c3d42 100644 --- a/components/canvas/nodes/adjustment-preview.tsx +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -1,46 +1,18 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useMemo } from "react"; import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { collectPipelineFromGraph, getSourceImageFromGraph, + shouldFastPathPreviewPipeline, } from "@/lib/canvas-render-preview"; import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot"; -const PREVIEW_PIPELINE_TYPES = new Set([ - "curves", - "color-adjust", - "light-adjust", - "detail-adjust", -]); - -type PreviewLatencyTrace = { - sequence: number; - changedAtMs: number; - nodeType: string; - origin: string; -}; - -function readPreviewLatencyTrace(): PreviewLatencyTrace | null { - if (process.env.NODE_ENV === "production") { - return null; - } - - const debugGlobals = globalThis as typeof globalThis & { - __LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean; - __LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace; - }; - - if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) { - return null; - } - - return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null; -} +const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]); export default function AdjustmentPreview({ nodeId, @@ -54,7 +26,6 @@ export default function AdjustmentPreview({ currentParams: unknown; }) { const graph = useCanvasGraph(); - const lastLoggedTraceSequenceRef = useRef(null); const sourceUrl = useMemo( () => @@ -93,36 +64,21 @@ export default function AdjustmentPreview({ }); }, [currentParams, currentType, graph, nodeId]); - useEffect(() => { - const trace = readPreviewLatencyTrace(); - if (!trace) { - return; - } + const usesFastPreviewDebounce = shouldFastPathPreviewPipeline( + steps, + graph.previewNodeDataOverrides, + ); + const shouldDeferHistogram = graph.previewNodeDataOverrides.has(nodeId); - if (lastLoggedTraceSequenceRef.current === trace.sequence) { - return; - } - - lastLoggedTraceSequenceRef.current = trace.sequence; - - console.info("[Preview latency] downstream-graph-visible", { - nodeId, - nodeType: currentType, - sourceNodeType: trace.nodeType, - sourceOrigin: trace.origin, - sinceChangeMs: performance.now() - trace.changedAtMs, - pipelineDepth: steps.length, - stepTypes: steps.map((step) => step.type), - hasSource: Boolean(sourceUrl), - }); - }, [currentType, nodeId, sourceUrl, steps]); + const previewDebounceMs = usesFastPreviewDebounce ? 16 : undefined; const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } = usePipelinePreview({ sourceUrl, steps, nodeWidth, - includeHistogram: true, + includeHistogram: !shouldDeferHistogram, + debounceMs: previewDebounceMs, // Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller // Display-Auflösung rechnen. previewScale: 0.5, diff --git a/components/canvas/nodes/color-adjust-node.tsx b/components/canvas/nodes/color-adjust-node.tsx index 287f191..aee36e2 100644 --- a/components/canvas/nodes/color-adjust-node.tsx +++ b/components/canvas/nodes/color-adjust-node.tsx @@ -59,6 +59,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + nodeId: id, data, normalize: normalizeData, saveDelayMs: 16, diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx index 15f27a0..9df4f5b 100644 --- a/components/canvas/nodes/compare-surface.tsx +++ b/components/canvas/nodes/compare-surface.tsx @@ -1,7 +1,11 @@ "use client"; +import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; -import type { RenderPreviewInput } from "@/lib/canvas-render-preview"; +import { + shouldFastPathPreviewPipeline, + type RenderPreviewInput, +} from "@/lib/canvas-render-preview"; const EMPTY_STEPS: RenderPreviewInput["steps"] = []; @@ -22,16 +26,24 @@ export default function CompareSurface({ clipWidthPercent, preferPreview, }: CompareSurfaceProps) { + const graph = useCanvasGraph(); const usePreview = Boolean(previewInput && (preferPreview || !finalUrl)); const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null; const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS; const visibleFinalUrl = usePreview ? undefined : finalUrl; + const previewDebounceMs = shouldFastPathPreviewPipeline( + previewSteps, + graph.previewNodeDataOverrides, + ) + ? 16 + : undefined; const { canvasRef, isRendering, error } = usePipelinePreview({ sourceUrl: previewSourceUrl, steps: previewSteps, nodeWidth, includeHistogram: false, + debounceMs: previewDebounceMs, // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln // halten lange Workflows spürbar reaktionsfreudiger. previewScale: 0.5, diff --git a/components/canvas/nodes/curves-node.tsx b/components/canvas/nodes/curves-node.tsx index 4c6632b..49ca41a 100644 --- a/components/canvas/nodes/curves-node.tsx +++ b/components/canvas/nodes/curves-node.tsx @@ -59,6 +59,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps({ + nodeId: id, data, normalize: normalizeData, saveDelayMs: 16, diff --git a/components/canvas/nodes/detail-adjust-node.tsx b/components/canvas/nodes/detail-adjust-node.tsx index 78c25e0..195a610 100644 --- a/components/canvas/nodes/detail-adjust-node.tsx +++ b/components/canvas/nodes/detail-adjust-node.tsx @@ -59,6 +59,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + nodeId: id, data, normalize: normalizeData, saveDelayMs: 16, diff --git a/components/canvas/nodes/light-adjust-node.tsx b/components/canvas/nodes/light-adjust-node.tsx index bd1f0a5..b860d2f 100644 --- a/components/canvas/nodes/light-adjust-node.tsx +++ b/components/canvas/nodes/light-adjust-node.tsx @@ -59,6 +59,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ + nodeId: id, data, normalize: normalizeData, saveDelayMs: 16, diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 3796430..666c0bf 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -16,6 +16,7 @@ import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { findSourceNodeFromGraph, resolveRenderPreviewInputFromGraph, + shouldFastPathPreviewPipeline, } from "@/lib/canvas-render-preview"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { parseAspectRatioString } from "@/lib/image-formats"; @@ -495,6 +496,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr ); const steps = renderPreviewInput.steps; + const previewDebounceMs = shouldFastPathPreviewPipeline( + steps, + graph.previewNodeDataOverrides, + ) + ? 16 + : undefined; const renderFingerprint = useMemo( () => ({ @@ -560,6 +567,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr sourceUrl, steps, nodeWidth: previewNodeWidth, + debounceMs: previewDebounceMs, // Inline-Preview: bewusst kompakt halten, damit Änderungen schneller // sichtbar werden, besonders in langen Graphen. previewScale: 0.5, @@ -577,6 +585,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr steps, nodeWidth: fullscreenPreviewWidth, includeHistogram: false, + debounceMs: previewDebounceMs, previewScale: 0.85, maxPreviewWidth: 1920, maxDevicePixelRatio: 1.5, diff --git a/components/canvas/nodes/use-node-local-data.ts b/components/canvas/nodes/use-node-local-data.ts index bd43429..f95e253 100644 --- a/components/canvas/nodes/use-node-local-data.ts +++ b/components/canvas/nodes/use-node-local-data.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useCanvasGraphPreviewOverrides } from "@/components/canvas/canvas-graph-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; function hashNodeData(value: unknown): string { @@ -21,69 +22,67 @@ function logNodeDataDebug(event: string, payload: Record): void console.info("[Canvas node debug]", event, payload); } -type PreviewLatencyTrace = { - sequence: number; - changedAtMs: number; - nodeType: string; - origin: "applyLocalData" | "updateLocalData"; -}; - -function writePreviewLatencyTrace(trace: Omit): void { - if (process.env.NODE_ENV === "production") { - return; - } - - const debugGlobals = globalThis as typeof globalThis & { - __LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean; - __LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace; - }; - - if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) { - return; - } - - const nextTrace: PreviewLatencyTrace = { - ...trace, - sequence: (debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__?.sequence ?? 0) + 1, - }; - - debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ = nextTrace; - - console.info("[Preview latency] node-local-change", nextTrace); -} - export function useNodeLocalData({ + nodeId, data, normalize, saveDelayMs, onSave, debugLabel, }: { + nodeId: string; data: unknown; normalize: (value: unknown) => T; saveDelayMs: number; onSave: (value: T) => Promise | void; debugLabel: string; }) { + const { setPreviewNodeDataOverride, clearPreviewNodeDataOverride } = + useCanvasGraphPreviewOverrides(); const [localData, setLocalDataState] = useState(() => normalize(data)); const localDataRef = useRef(localData); + const persistedDataRef = useRef(localData); const hasPendingLocalChangesRef = useRef(false); + const localChangeVersionRef = useRef(0); + const isMountedRef = useRef(true); useEffect(() => { localDataRef.current = localData; }, [localData]); const queueSave = useDebouncedCallback(() => { - void onSave(localDataRef.current); + const savedValue = localDataRef.current; + const savedVersion = localChangeVersionRef.current; + + Promise.resolve(onSave(savedValue)) + .then(() => { + if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) { + return; + } + + hasPendingLocalChangesRef.current = false; + }) + .catch(() => { + if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) { + return; + } + + hasPendingLocalChangesRef.current = false; + localDataRef.current = persistedDataRef.current; + setLocalDataState(persistedDataRef.current); + clearPreviewNodeDataOverride(nodeId); + }); }, saveDelayMs); useEffect(() => { const incomingData = normalize(data); + persistedDataRef.current = incomingData; const incomingHash = hashNodeData(incomingData); const localHash = hashNodeData(localDataRef.current); if (incomingHash === localHash) { hasPendingLocalChangesRef.current = false; + clearPreviewNodeDataOverride(nodeId); return; } @@ -99,44 +98,46 @@ export function useNodeLocalData({ const timer = window.setTimeout(() => { localDataRef.current = incomingData; setLocalDataState(incomingData); + clearPreviewNodeDataOverride(nodeId); }, 0); return () => { window.clearTimeout(timer); }; - }, [data, debugLabel, normalize]); + }, [clearPreviewNodeDataOverride, data, debugLabel, nodeId, normalize]); + + useEffect(() => { + return () => { + isMountedRef.current = false; + queueSave.cancel(); + clearPreviewNodeDataOverride(nodeId); + }; + }, [clearPreviewNodeDataOverride, nodeId, queueSave]); const applyLocalData = useCallback( (next: T) => { + localChangeVersionRef.current += 1; hasPendingLocalChangesRef.current = true; - writePreviewLatencyTrace({ - changedAtMs: performance.now(), - nodeType: debugLabel, - origin: "applyLocalData", - }); localDataRef.current = next; setLocalDataState(next); + setPreviewNodeDataOverride(nodeId, next); queueSave(); }, - [debugLabel, queueSave], + [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], ); const updateLocalData = useCallback( (updater: (current: T) => T) => { + const next = updater(localDataRef.current); + + localChangeVersionRef.current += 1; hasPendingLocalChangesRef.current = true; - setLocalDataState((current) => { - const next = updater(current); - writePreviewLatencyTrace({ - changedAtMs: performance.now(), - nodeType: debugLabel, - origin: "updateLocalData", - }); - localDataRef.current = next; - queueSave(); - return next; - }); + localDataRef.current = next; + setLocalDataState(next); + setPreviewNodeDataOverride(nodeId, next); + queueSave(); }, - [debugLabel, queueSave], + [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride], ); return { diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index 2b95710..43c44c0 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; -import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react"; +import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd, OnConnectStart } from "@xyflow/react"; import type { Id } from "@/convex/_generated/dataModel"; import { @@ -100,14 +100,20 @@ export function useCanvasConnections({ const [connectionDropMenu, setConnectionDropMenu] = useState(null); const connectionDropMenuRef = useRef(null); + const isConnectDragActiveRef = useRef(false); const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []); useEffect(() => { connectionDropMenuRef.current = connectionDropMenu; }, [connectionDropMenu]); + const onConnectStart = useCallback(() => { + isConnectDragActiveRef.current = true; + }, []); + const onConnect = useCallback( (connection: Connection) => { + isConnectDragActiveRef.current = false; const validationError = validateCanvasConnection(connection, nodes, edges); if (validationError) { showConnectionRejectedToast(validationError); @@ -129,6 +135,11 @@ export function useCanvasConnections({ const onConnectEnd = useCallback( (event, connectionState) => { + if (!isConnectDragActiveRef.current) { + return; + } + + isConnectDragActiveRef.current = false; if (isReconnectDragActiveRef.current) return; if (connectionState.isValid === true) return; const fromNode = connectionState.fromNode; @@ -153,8 +164,8 @@ export function useCanvasConnections({ { source: droppedConnection.sourceNodeId, target: droppedConnection.targetNodeId, - sourceHandle: droppedConnection.sourceHandle, - targetHandle: droppedConnection.targetHandle, + sourceHandle: droppedConnection.sourceHandle ?? null, + targetHandle: droppedConnection.targetHandle ?? null, }, nodesRef.current, edgesRef.current, @@ -333,14 +344,15 @@ export function useCanvasConnections({ }, }); - return { - connectionDropMenu, - closeConnectionDropMenu, - handleConnectionDropPick, - onConnect, - onConnectEnd, - onReconnectStart, - onReconnect, - onReconnectEnd, - }; + return { + connectionDropMenu, + closeConnectionDropMenu, + handleConnectionDropPick, + onConnect, + onConnectStart, + onConnectEnd, + onReconnectStart, + onReconnect, + onReconnectEnd, + }; } diff --git a/docs/plans/2026-04-05-preview-graph-architecture.md b/docs/plans/2026-04-05-preview-graph-architecture.md new file mode 100644 index 0000000..eaa324c --- /dev/null +++ b/docs/plans/2026-04-05-preview-graph-architecture.md @@ -0,0 +1,371 @@ +# Preview Graph Architecture Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make upstream adjustment changes propagate to downstream node previews and the render preview immediately from local client state, without waiting for the sync queue or Convex roundtrip. + +**Architecture:** Add a local-first preview graph overlay inside the canvas graph provider so node-local adjustment state can temporarily override persisted node data for preview resolution. Then make preview rendering react to that local overlay with a lower hot-path debounce and optional histogram deferral, so the pipeline feels immediate while the persisted graph keeps its current sync semantics. + +**Tech Stack:** React 19, Next 16, `@xyflow/react`, client context providers, Vitest, WebGL preview backend, existing worker-client preview pipeline. + +--- + +### Task 1: Add a local preview override layer to the canvas graph + +**Files:** +- Modify: `components/canvas/canvas-graph-context.tsx` +- Modify: `lib/canvas-render-preview.ts` +- Create: `tests/canvas-render-preview.test.ts` + +**Step 1: Write the failing test** + +```ts +it("prefers local preview overrides over persisted node data", () => { + const graph = buildGraphSnapshot( + [ + { id: "image-1", type: "image", data: { url: "https://cdn.example.com/source.png" } }, + { id: "color-1", type: "color-adjust", data: { temperature: 0 } }, + { id: "render-1", type: "render", data: {} }, + ], + [ + { source: "image-1", target: "color-1" }, + { source: "color-1", target: "render-1" }, + ], + false, + new Map([["color-1", { temperature: 42 }]]), + ); + + expect(resolveRenderPreviewInputFromGraph({ nodeId: "render-1", graph }).steps).toMatchObject([ + { nodeId: "color-1", type: "color-adjust", params: { temperature: 42 } }, + ]); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test tests/canvas-render-preview.test.ts` +Expected: FAIL because `buildGraphSnapshot` does not accept or apply preview overrides yet. + +**Step 3: Write minimal implementation** + +```ts +export function buildGraphSnapshot( + nodes: readonly CanvasGraphNodeLike[], + edges: readonly CanvasGraphEdgeLike[], + includeTempEdges = false, + nodeDataOverrides?: ReadonlyMap, +): CanvasGraphSnapshot { + const nodesById = new Map(); + for (const node of nodes) { + const override = nodeDataOverrides?.get(node.id); + nodesById.set(node.id, override === undefined ? node : { ...node, data: override }); + } + // existing edge logic unchanged +} +``` + +Add a second context export in `components/canvas/canvas-graph-context.tsx` for local preview overrides: + +```ts +type CanvasGraphPreviewOverrides = { + setNodePreviewOverride: (nodeId: string, data: unknown) => void; + clearNodePreviewOverride: (nodeId: string) => void; + hasPreviewOverrides: boolean; +}; +``` + +Build the graph snapshot with the override map applied. + +**Step 4: Run test to verify it passes** + +Run: `pnpm test tests/canvas-render-preview.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/canvas/canvas-graph-context.tsx lib/canvas-render-preview.ts tests/canvas-render-preview.test.ts +git commit -m "feat(canvas): add local preview graph overrides" +``` + +### Task 2: Feed node-local adjustment state into the preview override layer + +**Files:** +- Modify: `components/canvas/nodes/use-node-local-data.ts` +- Modify: `components/canvas/nodes/color-adjust-node.tsx` +- Modify: `components/canvas/nodes/light-adjust-node.tsx` +- Modify: `components/canvas/nodes/curves-node.tsx` +- Modify: `components/canvas/nodes/detail-adjust-node.tsx` +- Create: `components/canvas/__tests__/use-node-local-data-preview-overrides.test.tsx` + +**Step 1: Write the failing test** + +```tsx +it("pushes local adjustment changes into preview overrides before save completes", async () => { + const setNodePreviewOverride = vi.fn(); + const clearNodePreviewOverride = vi.fn(); + + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: /change/i })); + + expect(setNodePreviewOverride).toHaveBeenCalledWith("color-1", expect.objectContaining({ temperature: 42 })); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test components/canvas/__tests__/use-node-local-data-preview-overrides.test.tsx` +Expected: FAIL because `useNodeLocalData` does not know about preview overrides. + +**Step 3: Write minimal implementation** + +Add `nodeId` to the hook contract and wire the override context into the hot path: + +```ts +const { setNodePreviewOverride, clearNodePreviewOverride } = useCanvasGraphPreviewOverrides(); + +const applyLocalData = useCallback((next: T) => { + hasPendingLocalChangesRef.current = true; + setNodePreviewOverride(nodeId, next); + localDataRef.current = next; + setLocalDataState(next); + queueSave(); +}, [nodeId, queueSave, setNodePreviewOverride]); +``` + +When the persisted data catches up, clear the override: + +```ts +if (incomingHash === localHash) { + hasPendingLocalChangesRef.current = false; + clearNodePreviewOverride(nodeId); + return; +} +``` + +Also clear the override on unmount. + +**Step 4: Run test to verify it passes** + +Run: `pnpm test components/canvas/__tests__/use-node-local-data-preview-overrides.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/canvas/nodes/use-node-local-data.ts components/canvas/nodes/color-adjust-node.tsx components/canvas/nodes/light-adjust-node.tsx components/canvas/nodes/curves-node.tsx components/canvas/nodes/detail-adjust-node.tsx components/canvas/__tests__/use-node-local-data-preview-overrides.test.tsx +git commit -m "feat(canvas): mirror local adjustment state into preview graph" +``` + +### Task 3: Make downstream previews and the render node react to local overlay changes immediately + +**Files:** +- Modify: `components/canvas/nodes/adjustment-preview.tsx` +- Modify: `components/canvas/nodes/render-node.tsx` +- Modify: `components/canvas/nodes/compare-node.tsx` +- Modify: `hooks/use-pipeline-preview.ts` +- Modify: `tests/use-pipeline-preview.test.ts` +- Modify: `components/canvas/__tests__/compare-node.test.tsx` + +**Step 1: Write the failing test** + +```ts +it("uses a lower debounce while preview overrides are active", async () => { + vi.useFakeTimers(); + workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue(createPreviewResult(100, 80)); + + render( + , + ); + + await act(async () => { + vi.advanceTimersByTime(17); + }); + + expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test tests/use-pipeline-preview.test.ts` +Expected: FAIL because `usePipelinePreview` has a fixed 48ms debounce. + +**Step 3: Write minimal implementation** + +Extend the hook options: + +```ts +type UsePipelinePreviewOptions = { + // existing fields + debounceMs?: number; +}; +``` + +Use it in the timeout: + +```ts +const renderDebounceMs = Math.max(0, options.debounceMs ?? PREVIEW_RENDER_DEBOUNCE_MS); +const timer = window.setTimeout(runPreview, renderDebounceMs); +``` + +Then in preview consumers derive the hot-path value from preview overrides: + +```tsx +const { hasPreviewOverrides } = useCanvasGraphPreviewOverrides(); + +usePipelinePreview({ + sourceUrl, + steps, + nodeWidth, + debounceMs: hasPreviewOverrides ? 16 : undefined, +}); +``` + +This keeps the slower debounce for passive updates but lowers it during active local edits. + +**Step 4: Run tests to verify they pass** + +Run: +- `pnpm test tests/use-pipeline-preview.test.ts` +- `pnpm test components/canvas/__tests__/compare-node.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/canvas/nodes/adjustment-preview.tsx components/canvas/nodes/render-node.tsx components/canvas/nodes/compare-node.tsx hooks/use-pipeline-preview.ts tests/use-pipeline-preview.test.ts components/canvas/__tests__/compare-node.test.tsx +git commit -m "feat(canvas): prioritize local preview updates in downstream nodes" +``` + +### Task 4: Defer non-critical histogram work during active local edits + +**Files:** +- Modify: `components/canvas/nodes/adjustment-preview.tsx` +- Modify: `hooks/use-pipeline-preview.ts` +- Modify: `tests/use-pipeline-preview.test.ts` + +**Step 1: Write the failing test** + +```ts +it("skips histogram work while local preview overrides are active", async () => { + render( + , + ); + + await act(async () => { + vi.advanceTimersByTime(20); + }); + + expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledWith( + expect.objectContaining({ includeHistogram: false }), + ); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test tests/use-pipeline-preview.test.ts` +Expected: FAIL if adjustment previews still always request histograms during active edits. + +**Step 3: Write minimal implementation** + +During active local preview overrides, let small adjustment previews omit histograms: + +```tsx +const includeHistogram = !hasPreviewOverrides; + +usePipelinePreview({ + sourceUrl, + steps, + nodeWidth, + includeHistogram, + debounceMs: hasPreviewOverrides ? 16 : undefined, +}); +``` + +Keep the render node fullscreen preview on `includeHistogram: false` as it is today; keep the inline render preview behavior unchanged unless measurements show it is still too slow. + +**Step 4: Run tests to verify they pass** + +Run: `pnpm test tests/use-pipeline-preview.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/canvas/nodes/adjustment-preview.tsx hooks/use-pipeline-preview.ts tests/use-pipeline-preview.test.ts +git commit -m "perf(canvas): defer histogram work during active preview edits" +``` + +### Task 5: Final verification and manual latency check + +**Files:** +- Modify: `docs/plans/2026-04-05-preview-graph-architecture.md` + +**Step 1: Run the focused automated test set** + +Run: + +```bash +pnpm test tests/canvas-render-preview.test.ts \ + components/canvas/__tests__/use-node-local-data-preview-overrides.test.tsx \ + tests/use-pipeline-preview.test.ts \ + components/canvas/__tests__/compare-node.test.tsx \ + tests/worker-client.test.ts \ + tests/image-pipeline/backend-capabilities.test.ts \ + tests/image-pipeline/backend-router.test.ts \ + tests/image-pipeline/webgl-backend-poc.test.ts \ + tests/preview-renderer.test.ts +``` + +Expected: PASS + +**Step 2: Run the full suite** + +Run: `pnpm test` +Expected: PASS + +**Step 3: Manual verification in browser** + +Check that: +- downstream previews update visibly during dragging without waiting for persisted sync +- the active adjustment preview responds near-immediately and skips histogram work while dragging +- WebGL-backed adjustments (`light-adjust`, `detail-adjust`, repaired `curves`/`color-adjust`) remain visually responsive +- deleting a connected middle node no longer triggers a stray `edges:create` failure + +**Step 4: Update the plan with measured before/after latency notes** + +Add a short bullet list with measured timings and any follow-up work that remains. + +**Step 5: Commit** + +```bash +git add docs/plans/2026-04-05-preview-graph-architecture.md +git commit -m "docs: record preview graph architecture verification" +``` + +--- + +## Verification Notes + +- Focused preview architecture suite passed: `45/45` tests. +- Full suite passed in the worktree: `121/121` tests. +- Baseline harness fixes were required for `compare-node` and `light-adjust-node` tests after `CanvasGraphProvider` became mandatory for node-local preview state. +- Manual browser verification is still recommended for before/after latency numbers with `window.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ = true`. diff --git a/hooks/use-debounced-callback.ts b/hooks/use-debounced-callback.ts index f554cb2..921b303 100644 --- a/hooks/use-debounced-callback.ts +++ b/hooks/use-debounced-callback.ts @@ -1,5 +1,10 @@ import { useRef, useCallback, useEffect } from "react"; +type DebouncedCallback = ((...args: Args) => void) & { + flush: () => void; + cancel: () => void; +}; + /** * Debounced callback — ruft `callback` erst auf, wenn `delay` ms * ohne erneuten Aufruf vergangen sind. Perfekt für Auto-Save. @@ -7,9 +12,10 @@ import { useRef, useCallback, useEffect } from "react"; export function useDebouncedCallback( callback: (...args: Args) => void, delay: number, -): (...args: Args) => void { +): DebouncedCallback { const timeoutRef = useRef | null>(null); const callbackRef = useRef(callback); + const argsRef = useRef(null); // Callback-Ref aktuell halten ohne neu zu rendern useEffect(() => { @@ -23,15 +29,49 @@ export function useDebouncedCallback( }; }, []); + const cancel = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + argsRef.current = null; + }, []); + + const flush = useCallback(() => { + if (!timeoutRef.current) { + return; + } + + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + + const args = argsRef.current; + argsRef.current = null; + if (args) { + callbackRef.current(...args); + } + }, []); + const debouncedFn = useCallback( (...args: Args) => { + argsRef.current = args; if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { - callbackRef.current(...args); + timeoutRef.current = null; + const nextArgs = argsRef.current; + argsRef.current = null; + if (nextArgs) { + callbackRef.current(...nextArgs); + } }, delay); }, [delay], ); - return debouncedFn; + const debouncedCallback = debouncedFn as DebouncedCallback; + debouncedCallback.flush = flush; + debouncedCallback.cancel = cancel; + + return debouncedCallback; } diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts index b6a33ad..895e903 100644 --- a/hooks/use-pipeline-preview.ts +++ b/hooks/use-pipeline-preview.ts @@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts"; import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; import { - getLastBackendDiagnostics, isPipelineAbortError, renderPreviewWithWorkerFallback, type PreviewRenderResult, @@ -19,50 +18,11 @@ type UsePipelinePreviewOptions = { previewScale?: number; maxPreviewWidth?: number; maxDevicePixelRatio?: number; + debounceMs?: number; }; const PREVIEW_RENDER_DEBOUNCE_MS = 48; -type PreviewLatencyTrace = { - sequence: number; - changedAtMs: number; - nodeType: string; - origin: string; -}; - -function readPreviewLatencyTrace(): PreviewLatencyTrace | null { - if (process.env.NODE_ENV === "production") { - return null; - } - - const debugGlobals = globalThis as typeof globalThis & { - __LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean; - __LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace; - }; - - if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) { - return null; - } - - return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null; -} - -function logPreviewLatency(event: string, payload: Record): void { - if (process.env.NODE_ENV === "production") { - return; - } - - const debugGlobals = globalThis as typeof globalThis & { - __LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean; - }; - - if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) { - return; - } - - console.info("[Preview latency]", event, payload); -} - function computePreviewWidth( nodeWidth: number, previewScale: number, @@ -121,6 +81,14 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { return Math.max(1, options.maxDevicePixelRatio); }, [options.maxDevicePixelRatio]); + const debounceMs = useMemo(() => { + if (typeof options.debounceMs !== "number" || !Number.isFinite(options.debounceMs)) { + return PREVIEW_RENDER_DEBOUNCE_MS; + } + + return Math.max(0, Math.round(options.debounceMs)); + }, [options.debounceMs]); + const previewWidth = useMemo( () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio), [maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale], @@ -161,23 +129,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { const currentRun = runIdRef.current + 1; runIdRef.current = currentRun; const abortController = new AbortController(); - const effectStartedAtMs = performance.now(); const timer = window.setTimeout(() => { - const requestStartedAtMs = performance.now(); - const trace = readPreviewLatencyTrace(); - - logPreviewLatency("request-start", { - currentRun, - pipelineHash, - previewWidth, - includeHistogram: options.includeHistogram !== false, - debounceWaitMs: requestStartedAtMs - effectStartedAtMs, - sinceChangeMs: trace ? requestStartedAtMs - trace.changedAtMs : null, - sourceNodeType: trace?.nodeType ?? null, - sourceOrigin: trace?.origin ?? null, - }); - setIsRendering(true); setError(null); void renderPreviewWithWorkerFallback({ @@ -200,20 +153,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { return; } context.putImageData(result.imageData, 0, 0); - const paintedAtMs = performance.now(); setHistogram(result.histogram); setPreviewAspectRatio(result.width / result.height); - - logPreviewLatency("paint-end", { - currentRun, - pipelineHash, - previewWidth, - imageWidth: result.width, - imageHeight: result.height, - requestDurationMs: paintedAtMs - requestStartedAtMs, - sinceChangeMs: trace ? paintedAtMs - trace.changedAtMs : null, - diagnostics: getLastBackendDiagnostics(), - }); }) .catch((renderError: unknown) => { if (runIdRef.current !== currentRun) return; @@ -231,7 +172,6 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { pipelineHash, previewWidth, includeHistogram: options.includeHistogram, - diagnostics: getLastBackendDiagnostics(), error: renderError, }); } @@ -242,13 +182,13 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { if (runIdRef.current !== currentRun) return; setIsRendering(false); }); - }, PREVIEW_RENDER_DEBOUNCE_MS); + }, debounceMs); return () => { window.clearTimeout(timer); abortController.abort(); }; - }, [options.includeHistogram, pipelineHash, previewWidth]); + }, [debounceMs, options.includeHistogram, pipelineHash, previewWidth]); return { canvasRef, diff --git a/lib/canvas-render-preview.ts b/lib/canvas-render-preview.ts index 699eb9c..6f02d8c 100644 --- a/lib/canvas-render-preview.ts +++ b/lib/canvas-render-preview.ts @@ -38,6 +38,65 @@ export type CanvasGraphSnapshot = { incomingEdgesByTarget: ReadonlyMap; }; +export type CanvasGraphNodeDataOverrides = ReadonlyMap; + +export function shouldFastPathPreviewPipeline( + steps: readonly Pick[], + overrides: CanvasGraphNodeDataOverrides, +): boolean { + if (steps.length === 0 || overrides.size === 0) { + return false; + } + + return steps.some((step) => overrides.has(step.nodeId)); +} + +export type BuildGraphSnapshotOptions = { + includeTempEdges?: boolean; + nodeDataOverrides?: CanvasGraphNodeDataOverrides; +}; + +function hashNodeData(value: unknown): string { + return JSON.stringify(value); +} + +function pruneNodeDataOverride(data: unknown, override: unknown): unknown { + return hashNodeData(data) === hashNodeData(override) ? undefined : override; +} + +export function pruneCanvasGraphNodeDataOverrides( + nodes: readonly CanvasGraphNodeLike[], + overrides: CanvasGraphNodeDataOverrides, +): CanvasGraphNodeDataOverrides { + if (overrides.size === 0) { + return overrides; + } + + const nodesById = new Map(nodes.map((node) => [node.id, node])); + let nextOverrides: Map | null = null; + + for (const [nodeId, override] of overrides) { + const node = nodesById.get(nodeId); + const nextOverride = node ? pruneNodeDataOverride(node.data, override) : undefined; + + if (nextOverride === undefined) { + nextOverrides ??= new Map(overrides); + nextOverrides.delete(nodeId); + continue; + } + + if (nextOverride !== override && !nextOverrides) { + nextOverrides = new Map(overrides); + } + + if (nextOverrides) { + nextOverrides.set(nodeId, nextOverride); + } + } + + return nextOverrides ?? overrides; +} + type RenderResolutionOption = "original" | "2x" | "custom"; type RenderFormatOption = "png" | "jpeg" | "webp"; @@ -135,11 +194,17 @@ export function resolveNodeImageUrl(data: unknown): string | null { export function buildGraphSnapshot( nodes: readonly CanvasGraphNodeLike[], edges: readonly CanvasGraphEdgeLike[], - includeTempEdges = false, + options: boolean | BuildGraphSnapshotOptions = false, ): CanvasGraphSnapshot { + const includeTempEdges = + typeof options === "boolean" ? options : (options.includeTempEdges ?? false); + const nodeDataOverrides = typeof options === "boolean" ? undefined : options.nodeDataOverrides; const nodesById = new Map(); for (const node of nodes) { - nodesById.set(node.id, node); + const nextNode = nodeDataOverrides?.has(node.id) + ? { ...node, data: nodeDataOverrides.get(node.id) } + : node; + nodesById.set(node.id, nextNode); } const incomingEdgesByTarget = new Map(); diff --git a/lib/image-pipeline/backend/backend-router.ts b/lib/image-pipeline/backend/backend-router.ts index 2ff4894..52c7c98 100644 --- a/lib/image-pipeline/backend/backend-router.ts +++ b/lib/image-pipeline/backend/backend-router.ts @@ -65,14 +65,6 @@ function normalizeBackendHint(value: BackendHint): string | null { return normalized.length > 0 ? normalized : null; } -function logBackendRouterDebug(event: string, payload: Record): void { - if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { - return; - } - - console.info("[image-pipeline backend]", event, payload); -} - export function createBackendRouter(options?: { backends?: readonly ImagePipelineBackend[]; defaultBackendId?: string; @@ -131,12 +123,6 @@ export function createBackendRouter(options?: { } function emitFallback(event: BackendFallbackEvent): void { - logBackendRouterDebug("fallback", { - reason: event.reason, - requestedBackend: event.requestedBackend, - fallbackBackend: event.fallbackBackend, - errorMessage: event.error?.message, - }); options?.onFallback?.(event); } @@ -365,15 +351,6 @@ export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequ backendHint = CPU_BACKEND_ID; } - logBackendRouterDebug("preview-backend-hint", { - backendHint, - stepTypes: steps.map((step) => step.type), - webglAvailable: rolloutState.webglAvailable, - webglEnabled: rolloutState.webglEnabled, - wasmAvailable: rolloutState.wasmAvailable, - wasmEnabled: rolloutState.wasmEnabled, - }); - return backendHint; } diff --git a/lib/image-pipeline/backend/webgl/webgl-backend.ts b/lib/image-pipeline/backend/webgl/webgl-backend.ts index 11b88c2..465f618 100644 --- a/lib/image-pipeline/backend/webgl/webgl-backend.ts +++ b/lib/image-pipeline/backend/webgl/webgl-backend.ts @@ -4,8 +4,11 @@ import type { ImagePipelineBackend, } from "@/lib/image-pipeline/backend/backend-types"; import { + normalizeColorAdjustData, + normalizeCurvesData, normalizeDetailAdjustData, normalizeLightAdjustData, + type CurvePoint, } from "@/lib/image-pipeline/adjustment-types"; import type { PipelineStep } from "@/lib/image-pipeline/contracts"; @@ -14,12 +17,47 @@ precision mediump float; varying vec2 vUv; uniform sampler2D uSource; -uniform float uGamma; +uniform sampler2D uRgbLut; +uniform sampler2D uRedLut; +uniform sampler2D uGreenLut; +uniform sampler2D uBlueLut; +uniform float uBlackPoint; +uniform float uWhitePoint; +uniform float uInvGamma; +uniform float uChannelMode; + +float sampleLut(sampler2D lut, float value) { + return texture2D(lut, vec2(clamp(value, 0.0, 1.0), 0.5)).r; +} void main() { vec4 color = texture2D(uSource, vUv); - color.rgb = pow(max(color.rgb, vec3(0.0)), vec3(max(uGamma, 0.001))); - gl_FragColor = color; + float levelRange = max(uWhitePoint - uBlackPoint, 1.0); + vec3 leveled = clamp((color.rgb * 255.0 - vec3(uBlackPoint)) / levelRange, 0.0, 1.0); + vec3 mapped = pow(max(leveled, vec3(0.0)), vec3(max(uInvGamma, 0.001))); + + vec3 rgbCurve = vec3( + sampleLut(uRgbLut, mapped.r), + sampleLut(uRgbLut, mapped.g), + sampleLut(uRgbLut, mapped.b) + ); + + vec3 result = rgbCurve; + if (uChannelMode < 0.5) { + result = vec3( + sampleLut(uRedLut, rgbCurve.r), + sampleLut(uGreenLut, rgbCurve.g), + sampleLut(uBlueLut, rgbCurve.b) + ); + } else if (uChannelMode < 1.5) { + result.r = sampleLut(uRedLut, rgbCurve.r); + } else if (uChannelMode < 2.5) { + result.g = sampleLut(uGreenLut, rgbCurve.g); + } else { + result.b = sampleLut(uBlueLut, rgbCurve.b); + } + + gl_FragColor = vec4(result, color.a); } `; @@ -28,12 +66,84 @@ precision mediump float; varying vec2 vUv; uniform sampler2D uSource; -uniform vec3 uColorShift; +uniform float uHueShift; +uniform float uSaturationFactor; +uniform float uLuminanceShift; +uniform float uTemperatureShift; +uniform float uTintShift; +uniform float uVibranceBoost; + +vec3 rgbToHsl(vec3 color) { + float maxChannel = max(max(color.r, color.g), color.b); + float minChannel = min(min(color.r, color.g), color.b); + float delta = maxChannel - minChannel; + float lightness = (maxChannel + minChannel) * 0.5; + + if (delta == 0.0) { + return vec3(0.0, 0.0, lightness); + } + + float saturation = delta / (1.0 - abs(2.0 * lightness - 1.0)); + float hue; + + if (maxChannel == color.r) { + hue = mod((color.g - color.b) / delta, 6.0); + } else if (maxChannel == color.g) { + hue = (color.b - color.r) / delta + 2.0; + } else { + hue = (color.r - color.g) / delta + 4.0; + } + + hue *= 60.0; + if (hue < 0.0) { + hue += 360.0; + } + + return vec3(hue, saturation, lightness); +} + +vec3 hslToRgb(float hue, float saturation, float lightness) { + float chroma = (1.0 - abs(2.0 * lightness - 1.0)) * saturation; + float x = chroma * (1.0 - abs(mod(hue / 60.0, 2.0) - 1.0)); + float m = lightness - chroma * 0.5; + vec3 rgbPrime; + + if (hue < 60.0) { + rgbPrime = vec3(chroma, x, 0.0); + } else if (hue < 120.0) { + rgbPrime = vec3(x, chroma, 0.0); + } else if (hue < 180.0) { + rgbPrime = vec3(0.0, chroma, x); + } else if (hue < 240.0) { + rgbPrime = vec3(0.0, x, chroma); + } else if (hue < 300.0) { + rgbPrime = vec3(x, 0.0, chroma); + } else { + rgbPrime = vec3(chroma, 0.0, x); + } + + return clamp(rgbPrime + vec3(m), 0.0, 1.0); +} void main() { vec4 color = texture2D(uSource, vUv); - color.rgb = clamp(color.rgb + uColorShift, 0.0, 1.0); - gl_FragColor = color; + vec3 hsl = rgbToHsl(color.rgb); + float shiftedHue = mod(hsl.x + uHueShift + 360.0, 360.0); + float shiftedSaturation = clamp(hsl.y * uSaturationFactor, 0.0, 1.0); + float shiftedLuminance = clamp(hsl.z + uLuminanceShift, 0.0, 1.0); + float saturationDelta = (1.0 - hsl.y) * uVibranceBoost; + vec3 vivid = hslToRgb( + shiftedHue, + clamp(shiftedSaturation + saturationDelta, 0.0, 1.0), + shiftedLuminance + ); + + vec3 shiftedBytes = vivid * 255.0; + shiftedBytes.r += uTemperatureShift; + shiftedBytes.g += uTintShift; + shiftedBytes.b -= uTemperatureShift + uTintShift * 0.3; + + gl_FragColor = vec4(clamp(shiftedBytes / 255.0, 0.0, 1.0), color.a); } `; @@ -172,12 +282,77 @@ const SUPPORTED_PREVIEW_STEP_TYPES = new Set([ "detail-adjust", ]); -function logWebglBackendDebug(event: string, payload: Record): void { - if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { - return; +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function toByte(value: number): number { + return clamp(Math.round(value), 0, 255); +} + +function buildCurveLut(points: CurvePoint[]): Uint8Array { + const lut = new Uint8Array(256); + const normalized = [...points].sort((left, right) => left.x - right.x); + + for (let input = 0; input < 256; input += 1) { + const first = normalized[0] ?? { x: 0, y: 0 }; + const last = normalized[normalized.length - 1] ?? { x: 255, y: 255 }; + if (input <= first.x) { + lut[input] = toByte(first.y); + continue; + } + + if (input >= last.x) { + lut[input] = toByte(last.y); + continue; + } + + for (let index = 1; index < normalized.length; index += 1) { + const left = normalized[index - 1]!; + const right = normalized[index]!; + if (input < left.x || input > right.x) { + continue; + } + + const span = Math.max(1, right.x - left.x); + const progress = (input - left.x) / span; + lut[input] = toByte(left.y + (right.y - left.y) * progress); + break; + } } - console.info("[image-pipeline webgl]", event, payload); + return lut; +} + +function createLutTexture( + gl: WebGLRenderingContext, + lut: Uint8Array, + textureUnit: number, +): WebGLTexture { + const texture = gl.createTexture(); + if (!texture) { + throw new Error("WebGL LUT texture allocation failed."); + } + + const rgba = new Uint8Array(256 * 4); + for (let index = 0; index < 256; index += 1) { + const value = lut[index] ?? 0; + const offset = index * 4; + rgba[offset] = value; + rgba[offset + 1] = value; + rgba[offset + 2] = value; + rgba[offset + 3] = 255; + } + + gl.activeTexture(gl.TEXTURE0 + textureUnit); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba); + + return texture; } function assertSupportedStep(step: PipelineStep): void { @@ -279,52 +454,98 @@ function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer { return quadBuffer; } -function mapCurvesGamma(step: PipelineStep): number { - const gamma = (step.params as { levels?: { gamma?: unknown } })?.levels?.gamma; - if (typeof gamma === "number" && Number.isFinite(gamma)) { - return Math.max(gamma, 0.001); - } - return 1; -} - -function mapColorShift(step: PipelineStep): [number, number, number] { - const params = step.params as { - hsl?: { luminance?: unknown }; - temperature?: unknown; - tint?: unknown; - }; - - const luminance = typeof params?.hsl?.luminance === "number" ? params.hsl.luminance : 0; - const temperature = typeof params?.temperature === "number" ? params.temperature : 0; - const tint = typeof params?.tint === "number" ? params.tint : 0; - - return [ - (luminance + temperature) / 255, - (luminance + tint) / 255, - (luminance - temperature) / 255, - ]; -} - function applyStepUniforms( gl: WebGLRenderingContext, shaderProgram: WebGLProgram, request: BackendStepRequest, -): void { +): WebGLTexture[] { + const disposableTextures: WebGLTexture[] = []; + if (request.step.type === "curves") { - const gammaLocation = gl.getUniformLocation(shaderProgram, "uGamma"); - if (gammaLocation) { - gl.uniform1f(gammaLocation, mapCurvesGamma(request.step)); + const curves = normalizeCurvesData(request.step.params); + + const blackPointLocation = gl.getUniformLocation(shaderProgram, "uBlackPoint"); + if (blackPointLocation) { + gl.uniform1f(blackPointLocation, curves.levels.blackPoint); } - return; + + const whitePointLocation = gl.getUniformLocation(shaderProgram, "uWhitePoint"); + if (whitePointLocation) { + gl.uniform1f(whitePointLocation, curves.levels.whitePoint); + } + + const invGammaLocation = gl.getUniformLocation(shaderProgram, "uInvGamma"); + if (invGammaLocation) { + gl.uniform1f(invGammaLocation, 1 / Math.max(curves.levels.gamma, 0.001)); + } + + const channelModeLocation = gl.getUniformLocation(shaderProgram, "uChannelMode"); + if (channelModeLocation) { + const channelMode = + curves.channelMode === "red" + ? 1 + : curves.channelMode === "green" + ? 2 + : curves.channelMode === "blue" + ? 3 + : 0; + gl.uniform1f(channelModeLocation, channelMode); + } + + const lutBindings = [ + { uniform: "uRgbLut", unit: 1, lut: buildCurveLut(curves.points.rgb) }, + { uniform: "uRedLut", unit: 2, lut: buildCurveLut(curves.points.red) }, + { uniform: "uGreenLut", unit: 3, lut: buildCurveLut(curves.points.green) }, + { uniform: "uBlueLut", unit: 4, lut: buildCurveLut(curves.points.blue) }, + ] as const; + + for (const binding of lutBindings) { + const texture = createLutTexture(gl, binding.lut, binding.unit); + disposableTextures.push(texture); + const location = gl.getUniformLocation(shaderProgram, binding.uniform); + if (location) { + gl.uniform1i(location, binding.unit); + } + } + + gl.activeTexture(gl.TEXTURE0); + return disposableTextures; } if (request.step.type === "color-adjust") { - const colorShiftLocation = gl.getUniformLocation(shaderProgram, "uColorShift"); - if (colorShiftLocation) { - const [r, g, b] = mapColorShift(request.step); - gl.uniform3f(colorShiftLocation, r, g, b); + const color = normalizeColorAdjustData(request.step.params); + + const hueShiftLocation = gl.getUniformLocation(shaderProgram, "uHueShift"); + if (hueShiftLocation) { + gl.uniform1f(hueShiftLocation, color.hsl.hue); } - return; + + const saturationFactorLocation = gl.getUniformLocation(shaderProgram, "uSaturationFactor"); + if (saturationFactorLocation) { + gl.uniform1f(saturationFactorLocation, 1 + color.hsl.saturation / 100); + } + + const luminanceShiftLocation = gl.getUniformLocation(shaderProgram, "uLuminanceShift"); + if (luminanceShiftLocation) { + gl.uniform1f(luminanceShiftLocation, color.hsl.luminance / 100); + } + + const temperatureShiftLocation = gl.getUniformLocation(shaderProgram, "uTemperatureShift"); + if (temperatureShiftLocation) { + gl.uniform1f(temperatureShiftLocation, color.temperature * 0.6); + } + + const tintShiftLocation = gl.getUniformLocation(shaderProgram, "uTintShift"); + if (tintShiftLocation) { + gl.uniform1f(tintShiftLocation, color.tint * 0.4); + } + + const vibranceBoostLocation = gl.getUniformLocation(shaderProgram, "uVibranceBoost"); + if (vibranceBoostLocation) { + gl.uniform1f(vibranceBoostLocation, color.vibrance / 100); + } + + return disposableTextures; } if (request.step.type === "light-adjust") { @@ -378,7 +599,7 @@ function applyStepUniforms( if (vignetteRoundnessLocation) { gl.uniform1f(vignetteRoundnessLocation, light.vignette.roundness); } - return; + return disposableTextures; } if (request.step.type === "detail-adjust") { @@ -419,6 +640,8 @@ function applyStepUniforms( gl.uniform1f(imageWidthLocation, request.width); } } + + return disposableTextures; } function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void { @@ -512,7 +735,7 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest) gl.uniform1i(sourceLocation, 0); } - applyStepUniforms(gl, shaderProgram, request); + const disposableTextures = applyStepUniforms(gl, shaderProgram, request); gl.viewport(0, 0, request.width, request.height); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); @@ -527,14 +750,9 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest) gl.deleteFramebuffer(framebuffer); gl.deleteTexture(sourceTexture); gl.deleteTexture(outputTexture); - - logWebglBackendDebug("step-complete", { - stepType: request.step.type, - width: request.width, - height: request.height, - totalDurationMs: performance.now() - startedAtMs, - readbackDurationMs, - }); + for (const texture of disposableTextures) { + gl.deleteTexture(texture); + } } export function isWebglPreviewStepSupported(step: PipelineStep): boolean { diff --git a/tests/canvas-delete-handlers.test.ts b/tests/canvas-delete-handlers.test.ts new file mode 100644 index 0000000..31b9068 --- /dev/null +++ b/tests/canvas-delete-handlers.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import React, { act, useEffect, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { useCanvasDeleteHandlers } from "@/components/canvas/canvas-delete-handlers"; + +vi.mock("@/lib/toast", () => ({ + toast: { + info: vi.fn(), + warning: vi.fn(), + }, +})); + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; + +const latestHandlersRef: { + current: ReturnType | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type HarnessProps = { + nodes: RFNode[]; + edges: RFEdge[]; + runBatchRemoveNodesMutation: ReturnType; + runCreateEdgeMutation: ReturnType; +}; + +function HookHarness(props: HarnessProps) { + const deletingNodeIds = useRef(new Set()); + const [, setAssetBrowserTargetNodeId] = useState(null); + + const handlers = useCanvasDeleteHandlers({ + t: ((key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key) as never, + canvasId: asCanvasId("canvas-1"), + nodes: props.nodes, + edges: props.edges, + deletingNodeIds, + setAssetBrowserTargetNodeId, + runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation, + runCreateEdgeMutation: props.runCreateEdgeMutation, + runRemoveEdgeMutation: vi.fn(async () => undefined), + }); + + useEffect(() => { + latestHandlersRef.current = handlers; + }, [handlers]); + + return null; +} + +describe("useCanvasDeleteHandlers", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + latestHandlersRef.current = null; + vi.clearAllMocks(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + it("creates bridge edges only after batch node removal resolves", async () => { + let resolveBatchRemove: (() => void) | null = null; + const runBatchRemoveNodesMutation = vi.fn( + () => + new Promise((resolve) => { + resolveBatchRemove = resolve; + }), + ); + const runCreateEdgeMutation = vi.fn(async () => undefined); + + const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }; + const deletedNode: RFNode = { + id: "node-color", + type: "color-adjust", + position: { x: 200, y: 0 }, + data: {}, + }; + const renderNode: RFNode = { id: "node-render", type: "render", position: { x: 400, y: 0 }, data: {} }; + + const edges: RFEdge[] = [ + { id: "edge-in", source: "node-image", target: "node-color" }, + { id: "edge-out", source: "node-color", target: "node-render" }, + ]; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + nodes: [imageNode, deletedNode, renderNode], + edges, + runBatchRemoveNodesMutation, + runCreateEdgeMutation, + }), + ); + }); + + await act(async () => { + latestHandlersRef.current?.onNodesDelete([deletedNode]); + }); + + expect(runBatchRemoveNodesMutation).toHaveBeenCalledWith({ + nodeIds: ["node-color"], + }); + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + + await act(async () => { + resolveBatchRemove?.(); + await Promise.resolve(); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-image", + targetNodeId: "node-render", + sourceHandle: undefined, + targetHandle: undefined, + }); + }); +}); diff --git a/tests/image-pipeline/parity/fixtures.ts b/tests/image-pipeline/parity/fixtures.ts index e305a16..d0ac36b 100644 --- a/tests/image-pipeline/parity/fixtures.ts +++ b/tests/image-pipeline/parity/fixtures.ts @@ -85,14 +85,14 @@ export const parityTolerances: Record = { spatialRmse: 52.5, }, colorAdjustPressure: { - maxChannelDelta: 203, - histogramSimilarity: 0.17, - spatialRmse: 75.8, + maxChannelDelta: 64, + histogramSimilarity: 0.5, + spatialRmse: 24, }, curvesColorPressureChain: { - maxChannelDelta: 203, - histogramSimilarity: 0.18, - spatialRmse: 75.5, + maxChannelDelta: 96, + histogramSimilarity: 0.35, + spatialRmse: 36, }, }; @@ -402,10 +402,10 @@ function createEmptyTexture(width: number, height: number): FakeTexture { } function inferShaderKind(source: string): ShaderKind { - if (source.includes("uGamma")) { + if (source.includes("uInvGamma") || source.includes("uRgbLut")) { return "curves"; } - if (source.includes("uColorShift")) { + if (source.includes("uHueShift") || source.includes("uVibranceBoost")) { return "color-adjust"; } if (source.includes("uExposureFactor")) { @@ -428,13 +428,65 @@ function toByte(value: number): number { return Math.max(0, Math.min(255, Math.round(value * 255))); } -function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array { +function sampleLutTexture(texture: FakeTexture | null, value: number): number { + if (!texture) { + return value; + } + + const index = Math.max(0, Math.min(255, Math.round(value * 255))); + return texture.data[index * 4] / 255; +} + +function runCurvesShader( + input: Uint8Array, + uniforms: Map, + textures: { + rgb: FakeTexture | null; + red: FakeTexture | null; + green: FakeTexture | null; + blue: FakeTexture | null; + }, +): Uint8Array { const output = new Uint8Array(input.length); + const blackPoint = Number(uniforms.get("uBlackPoint") ?? 0); + const whitePoint = Number(uniforms.get("uWhitePoint") ?? 255); + const invGamma = Number(uniforms.get("uInvGamma") ?? 1); + const channelMode = Number(uniforms.get("uChannelMode") ?? 0); + const levelRange = Math.max(1, whitePoint - blackPoint); for (let index = 0; index < input.length; index += 4) { - const red = Math.pow(Math.max(toNormalized(input[index]), 0), Math.max(gamma, 0.001)); - const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001)); - const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001)); + const mappedRed = Math.pow( + Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0), + Math.max(invGamma, 0.001), + ); + const mappedGreen = Math.pow( + Math.max(Math.min(((input[index + 1] - blackPoint) / levelRange), 1), 0), + Math.max(invGamma, 0.001), + ); + const mappedBlue = Math.pow( + Math.max(Math.min(((input[index + 2] - blackPoint) / levelRange), 1), 0), + Math.max(invGamma, 0.001), + ); + + const rgbRed = sampleLutTexture(textures.rgb, mappedRed); + const rgbGreen = sampleLutTexture(textures.rgb, mappedGreen); + const rgbBlue = sampleLutTexture(textures.rgb, mappedBlue); + + let red = rgbRed; + let green = rgbGreen; + let blue = rgbBlue; + + if (channelMode < 0.5) { + red = sampleLutTexture(textures.red, rgbRed); + green = sampleLutTexture(textures.green, rgbGreen); + blue = sampleLutTexture(textures.blue, rgbBlue); + } else if (channelMode < 1.5) { + red = sampleLutTexture(textures.red, rgbRed); + } else if (channelMode < 2.5) { + green = sampleLutTexture(textures.green, rgbGreen); + } else { + blue = sampleLutTexture(textures.blue, rgbBlue); + } output[index] = toByte(red); output[index + 1] = toByte(green); @@ -445,13 +497,98 @@ function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array { return output; } -function runColorAdjustShader(input: Uint8Array, shift: [number, number, number]): Uint8Array { +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + const l = (max + min) / 2; + if (delta === 0) { + return { h: 0, s: 0, l }; + } + + const s = delta / (1 - Math.abs(2 * l - 1)); + let h = 0; + if (max === rn) { + h = ((gn - bn) / delta) % 6; + } else if (max === gn) { + h = (bn - rn) / delta + 2; + } else { + h = (rn - gn) / delta + 4; + } + + h *= 60; + if (h < 0) { + h += 360; + } + + return { h, s, l }; +} + +function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let rp = 0; + let gp = 0; + let bp = 0; + + if (h < 60) { + rp = c; + gp = x; + } else if (h < 120) { + rp = x; + gp = c; + } else if (h < 180) { + gp = c; + bp = x; + } else if (h < 240) { + gp = x; + bp = c; + } else if (h < 300) { + rp = x; + bp = c; + } else { + rp = c; + bp = x; + } + + return { + r: Math.max(0, Math.min(255, Math.round((rp + m) * 255))), + g: Math.max(0, Math.min(255, Math.round((gp + m) * 255))), + b: Math.max(0, Math.min(255, Math.round((bp + m) * 255))), + }; +} + +function runColorAdjustShader( + input: Uint8Array, + uniforms: Map, +): Uint8Array { const output = new Uint8Array(input.length); + const hueShift = Number(uniforms.get("uHueShift") ?? 0); + const saturationFactor = Number(uniforms.get("uSaturationFactor") ?? 1); + const luminanceShift = Number(uniforms.get("uLuminanceShift") ?? 0); + const temperatureShift = Number(uniforms.get("uTemperatureShift") ?? 0); + const tintShift = Number(uniforms.get("uTintShift") ?? 0); + const vibranceBoost = Number(uniforms.get("uVibranceBoost") ?? 0); for (let index = 0; index < input.length; index += 4) { - const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0])); - const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1])); - const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2])); + const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0); + const shiftedHue = (hsl.h + hueShift + 360) % 360; + const shiftedSaturation = Math.max(0, Math.min(1, hsl.s * saturationFactor)); + const shiftedLuminance = Math.max(0, Math.min(1, hsl.l + luminanceShift)); + const saturationDelta = (1 - hsl.s) * vibranceBoost; + const vivid = hslToRgb( + shiftedHue, + Math.max(0, Math.min(1, shiftedSaturation + saturationDelta)), + shiftedLuminance, + ); + + const red = Math.max(0, Math.min(1, (vivid.r + temperatureShift) / 255)); + const green = Math.max(0, Math.min(1, (vivid.g + tintShift) / 255)); + const blue = Math.max(0, Math.min(1, (vivid.b - temperatureShift - tintShift * 0.3) / 255)); output[index] = toByte(red); output[index + 1] = toByte(green); @@ -629,7 +766,8 @@ function createParityWebglContext(): WebGLRenderingContext { let currentProgram: FakeProgram | null = null; let currentTexture: FakeTexture | null = null; let currentFramebuffer: FakeFramebuffer | null = null; - let sourceTexture: FakeTexture | null = null; + let activeTextureUnit = 0; + const boundTextures = new Map(); let drawWidth = 1; let drawHeight = 1; @@ -693,9 +831,7 @@ function createParityWebglContext(): WebGLRenderingContext { }, bindTexture(_target: number, texture: FakeTexture | null) { currentTexture = texture; - if (texture) { - sourceTexture = texture; - } + boundTextures.set(activeTextureUnit, texture); }, texParameteri() {}, texImage2D( @@ -730,7 +866,9 @@ function createParityWebglContext(): WebGLRenderingContext { currentTexture.height = height; currentTexture.data = new Uint8Array(width * height * 4); }, - activeTexture() {}, + activeTexture(textureUnit: number) { + activeTextureUnit = textureUnit - glConstants.TEXTURE0; + }, getUniformLocation(program: FakeProgram, name: string) { return { program, @@ -774,22 +912,30 @@ function createParityWebglContext(): WebGLRenderingContext { drawHeight = height; }, drawArrays() { + const sourceTexture = boundTextures.get(0) ?? null; if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) { throw new Error("Parity WebGL mock is missing required render state."); } if (currentProgram.kind === "curves") { - const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1); - currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, gamma); + const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1); + const redUnit = Number(currentProgram.uniforms.get("uRedLut") ?? 2); + const greenUnit = Number(currentProgram.uniforms.get("uGreenLut") ?? 3); + const blueUnit = Number(currentProgram.uniforms.get("uBlueLut") ?? 4); + currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, currentProgram.uniforms, { + rgb: boundTextures.get(rgbUnit) ?? null, + red: boundTextures.get(redUnit) ?? null, + green: boundTextures.get(greenUnit) ?? null, + blue: boundTextures.get(blueUnit) ?? null, + }); return; } if (currentProgram.kind === "color-adjust") { - const colorShift = currentProgram.uniforms.get("uColorShift"); - const shift: [number, number, number] = Array.isArray(colorShift) - ? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0] - : [0, 0, 0]; - currentFramebuffer.attachment.data = runColorAdjustShader(sourceTexture.data, shift); + currentFramebuffer.attachment.data = runColorAdjustShader( + sourceTexture.data, + currentProgram.uniforms, + ); return; } @@ -828,7 +974,7 @@ function createParityWebglContext(): WebGLRenderingContext { throw new Error("Parity WebGL mock has no framebuffer attachment to read from."); } - output.set(currentFramebuffer.attachment.data); + output.set(currentFramebuffer.attachment.data.subarray(0, output.length)); }, }; diff --git a/tests/image-pipeline/webgl-backend-poc.test.ts b/tests/image-pipeline/webgl-backend-poc.test.ts index 9b089a5..647a54c 100644 --- a/tests/image-pipeline/webgl-backend-poc.test.ts +++ b/tests/image-pipeline/webgl-backend-poc.test.ts @@ -38,6 +38,41 @@ function createCurvesStep(): PipelineStep { }; } +function createCurvesPressureStep(): PipelineStep { + return { + nodeId: "curves-pressure-1", + type: "curves", + params: { + channelMode: "master", + levels: { + blackPoint: 12, + whitePoint: 232, + gamma: 2.5, + }, + points: { + rgb: [ + { x: 0, y: 0 }, + { x: 64, y: 52 }, + { x: 196, y: 228 }, + { x: 255, y: 255 }, + ], + red: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + green: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + blue: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + }, + }, + }; +} + function createColorAdjustStep(): PipelineStep { return { nodeId: "color-1", @@ -55,6 +90,23 @@ function createColorAdjustStep(): PipelineStep { }; } +function createColorAdjustPressureStep(): PipelineStep { + return { + nodeId: "color-pressure-1", + type: "color-adjust", + params: { + hsl: { + hue: 48, + saturation: 64, + luminance: 18, + }, + temperature: 24, + tint: -28, + vibrance: 52, + }, + }; +} + function createUnsupportedStep(): PipelineStep { return { nodeId: "light-1", @@ -158,7 +210,7 @@ describe("webgl backend poc", () => { texParameteri: vi.fn(), texImage2D: vi.fn(), activeTexture: vi.fn(), - getUniformLocation: vi.fn(() => ({ uniform: true })), + getUniformLocation: vi.fn((_program: unknown, name: string) => ({ uniform: true, name })), uniform1i: vi.fn(), uniform1f: vi.fn(), uniform3f: vi.fn(), @@ -405,7 +457,7 @@ describe("webgl backend poc", () => { .at(-1)?.index; expect(lastBindBeforeDrawIndex).toBeTypeOf("number"); - expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toBe(sourceTexture); + expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toStrictEqual(sourceTexture); expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture); }); @@ -464,6 +516,90 @@ describe("webgl backend poc", () => { expect(fakeGl.uniform1f).toHaveBeenCalledWith(expect.anything(), 7); }); + it("passes curves levels uniforms for non-default curves settings", async () => { + const fakeGl = createFakeWebglContext({ + readbackPixels: new Uint8Array([11, 22, 33, 255]), + }); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => { + if (contextId === "webgl") { + return fakeGl; + } + return null; + }); + + const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend"); + const backend = createWebglPreviewBackend(); + + backend.runPreviewStep({ + pixels: new Uint8ClampedArray([200, 100, 50, 255]), + step: createCurvesPressureStep(), + width: 1, + height: 1, + }); + + expect(fakeGl.uniform1f).toHaveBeenCalledWith( + expect.objectContaining({ name: "uBlackPoint" }), + 12, + ); + expect(fakeGl.uniform1f).toHaveBeenCalledWith( + expect.objectContaining({ name: "uWhitePoint" }), + 232, + ); + expect(fakeGl.uniform1f).toHaveBeenCalledWith( + expect.objectContaining({ name: "uInvGamma" }), + 0.4, + ); + }); + + it("passes hue, saturation, luminance, temperature, tint, and vibrance uniforms", async () => { + const fakeGl = createFakeWebglContext({ + readbackPixels: new Uint8Array([11, 22, 33, 255]), + }); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => { + if (contextId === "webgl") { + return fakeGl; + } + return null; + }); + + const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend"); + const backend = createWebglPreviewBackend(); + + backend.runPreviewStep({ + pixels: new Uint8ClampedArray([200, 100, 50, 255]), + step: createColorAdjustPressureStep(), + width: 1, + height: 1, + }); + + const uniform1fCalls = vi.mocked(fakeGl.uniform1f).mock.calls; + + expect(fakeGl.uniform1f).toHaveBeenCalledWith( + expect.objectContaining({ name: "uHueShift" }), + 48, + ); + expect(uniform1fCalls).toContainEqual([ + expect.objectContaining({ name: "uSaturationFactor" }), + expect.closeTo(1.64, 5), + ]); + expect(uniform1fCalls).toContainEqual([ + expect.objectContaining({ name: "uLuminanceShift" }), + expect.closeTo(0.18, 5), + ]); + expect(uniform1fCalls).toContainEqual([ + expect.objectContaining({ name: "uTemperatureShift" }), + expect.closeTo(14.4, 5), + ]); + expect(uniform1fCalls).toContainEqual([ + expect.objectContaining({ name: "uTintShift" }), + expect.closeTo(-11.2, 5), + ]); + expect(uniform1fCalls).toContainEqual([ + expect.objectContaining({ name: "uVibranceBoost" }), + expect.closeTo(0.52, 5), + ]); + }); + it("downgrades compile/link failures to cpu with runtime_error reason", async () => { const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router"); const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend"); diff --git a/tests/light-adjust-node.test.ts b/tests/light-adjust-node.test.ts index ddfb065..a3765e6 100644 --- a/tests/light-adjust-node.test.ts +++ b/tests/light-adjust-node.test.ts @@ -4,6 +4,7 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context"; import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types"; type ParameterSliderProps = { @@ -110,23 +111,30 @@ describe("LightAdjustNode", () => { const renderNode = (data: LightAdjustData) => root?.render( - createElement(LightAdjustNode, { - id: "light-1", - data, - selected: false, - dragging: false, - zIndex: 0, - isConnectable: true, - type: "light-adjust", - xPos: 0, - yPos: 0, - width: 320, - height: 300, - sourcePosition: undefined, - targetPosition: undefined, - positionAbsoluteX: 0, - positionAbsoluteY: 0, - } as never), + createElement( + CanvasGraphProvider as never, + { + nodes: [{ id: "light-1", type: "light-adjust", data }], + edges: [], + } as never, + createElement(LightAdjustNode, { + id: "light-1", + data, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "light-adjust", + xPos: 0, + yPos: 0, + width: 320, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as never), + ), ); await act(async () => { @@ -183,4 +191,64 @@ describe("LightAdjustNode", () => { parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value, ).toBe(60); }); + + it("does not trigger a render-phase CanvasGraphProvider update while dragging sliders", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const staleData: LightAdjustData = { + ...DEFAULT_LIGHT_ADJUST_DATA, + vignette: { + ...DEFAULT_LIGHT_ADJUST_DATA.vignette, + }, + }; + + const renderNode = (data: LightAdjustData) => + root?.render( + createElement( + CanvasGraphProvider as never, + { + nodes: [{ id: "light-1", type: "light-adjust", data }], + edges: [], + } as never, + createElement(LightAdjustNode, { + id: "light-1", + data, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "light-adjust", + xPos: 0, + yPos: 0, + width: 320, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as never), + ), + ); + + await act(async () => { + renderNode({ ...staleData, vignette: { ...staleData.vignette } }); + vi.runOnlyPendingTimers(); + }); + + const sliderProps = parameterSliderState.latestProps; + expect(sliderProps).not.toBeNull(); + + await act(async () => { + sliderProps?.onChange( + sliderProps.values.map((entry) => + entry.id === "brightness" ? { ...entry, value: 35 } : entry, + ), + ); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + expect.stringContaining( + "Cannot update a component (`CanvasGraphProvider`) while rendering a different component", + ), + ); + }); }); diff --git a/tests/use-node-local-data-order.test.ts b/tests/use-node-local-data-order.test.ts new file mode 100644 index 0000000..107717b --- /dev/null +++ b/tests/use-node-local-data-order.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const canvasGraphMock = vi.hoisted(() => ({ + clearPreviewNodeDataOverride: vi.fn(), + setPreviewNodeDataOverride: vi.fn(), +})); + +vi.mock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraphPreviewOverrides: () => canvasGraphMock, +})); + +import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; + +type AdjustmentData = { + exposure: number; +}; + +const latestHookRef: { + current: + | { + updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void; + } + | null; +} = { current: null }; + +function HookHarness() { + const { updateLocalData } = useNodeLocalData({ + nodeId: "node-1", + data: { exposure: 0.2 }, + normalize: (value) => ({ ...(value as AdjustmentData) }), + saveDelayMs: 1000, + onSave: async () => undefined, + debugLabel: "light-adjust", + }); + + useEffect(() => { + latestHookRef.current = { updateLocalData }; + return () => { + latestHookRef.current = null; + }; + }, [updateLocalData]); + + return null; +} + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("useNodeLocalData ordering", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + latestHookRef.current = null; + canvasGraphMock.clearPreviewNodeDataOverride.mockReset(); + canvasGraphMock.setPreviewNodeDataOverride.mockReset(); + }); + + it("does not write preview overrides from inside the local state updater", async () => { + let overrideWriteStack = ""; + + canvasGraphMock.setPreviewNodeDataOverride.mockImplementation(() => { + overrideWriteStack = new Error().stack ?? ""; + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness)); + }); + + await act(async () => { + latestHookRef.current?.updateLocalData((current) => ({ + ...current, + exposure: 0.8, + })); + }); + + expect(overrideWriteStack).not.toContain("basicStateReducer"); + expect(overrideWriteStack).not.toContain("updateReducerImpl"); + }); +}); diff --git a/tests/use-pipeline-preview.test.ts b/tests/use-pipeline-preview.test.ts index 46dfbbf..3c5f13d 100644 --- a/tests/use-pipeline-preview.test.ts +++ b/tests/use-pipeline-preview.test.ts @@ -67,16 +67,19 @@ function PreviewHarness({ sourceUrl, steps, includeHistogram, + debounceMs, }: { sourceUrl: string | null; steps: PipelineStep[]; includeHistogram?: boolean; + debounceMs?: number; }) { const { canvasRef, histogram, error, isRendering } = usePipelinePreview({ sourceUrl, steps, nodeWidth: 320, includeHistogram, + debounceMs, }); useEffect(() => { @@ -453,6 +456,33 @@ describe("usePipelinePreview", () => { }), ); }); + + it("supports a faster debounce override for local preview updates", async () => { + await act(async () => { + root?.render( + createElement(PreviewHarness, { + sourceUrl: "https://cdn.example.com/source.png", + steps: createLightAdjustSteps(10), + includeHistogram: false, + debounceMs: 16, + }), + ); + }); + + await act(async () => { + vi.advanceTimersByTime(15); + await Promise.resolve(); + }); + + expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(0); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + + expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1); + }); }); describe("preview histogram call sites", () => { @@ -475,7 +505,7 @@ describe("preview histogram call sites", () => { vi.clearAllMocks(); }); - it("keeps histogram enabled for AdjustmentPreview", async () => { + it("disables histogram for fast-path AdjustmentPreview", async () => { const hookSpy = vi.fn(() => ({ canvasRef: { current: null }, histogram: emptyHistogram(), @@ -489,11 +519,78 @@ describe("preview histogram call sites", () => { usePipelinePreview: hookSpy, })); vi.doMock("@/components/canvas/canvas-graph-context", () => ({ - useCanvasGraph: () => ({ nodes: [], edges: [] }), + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map([["light-1", { brightness: 10 }]]), + }), })); vi.doMock("@/lib/canvas-render-preview", () => ({ - collectPipelineFromGraph: () => [], + collectPipelineFromGraph: () => [ + { + nodeId: "light-1", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], getSourceImageFromGraph: () => "https://cdn.example.com/source.png", + shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map) => + steps.some((step) => overrides.has(step.nodeId)), + })); + + const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview"); + const AdjustmentPreview = adjustmentPreviewModule.default; + + await act(async () => { + root?.render( + createElement(AdjustmentPreview, { + nodeId: "light-1", + nodeWidth: 320, + currentType: "light-adjust", + currentParams: { brightness: 10 }, + }), + ); + }); + + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + includeHistogram: false, + debounceMs: 16, + }), + ); + }); + + it("does not fast-path AdjustmentPreview when overrides belong to another pipeline", async () => { + const hookSpy = vi.fn(() => ({ + canvasRef: { current: null }, + histogram: emptyHistogram(), + isRendering: false, + hasSource: true, + previewAspectRatio: 1, + error: null, + })); + + vi.doMock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: hookSpy, + })); + vi.doMock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map([["other-node", { brightness: 10 }]]), + }), + })); + vi.doMock("@/lib/canvas-render-preview", () => ({ + collectPipelineFromGraph: () => [ + { + nodeId: "light-1", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + getSourceImageFromGraph: () => "https://cdn.example.com/source.png", + shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map) => + steps.some((step) => overrides.has(step.nodeId)), })); const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview"); @@ -513,11 +610,72 @@ describe("preview histogram call sites", () => { expect(hookSpy).toHaveBeenCalledWith( expect.objectContaining({ includeHistogram: true, + debounceMs: undefined, }), ); }); - it("requests previews without histogram work in CompareSurface and fullscreen RenderNode", async () => { + it("keeps histogram enabled for downstream AdjustmentPreview fast path", async () => { + const hookSpy = vi.fn(() => ({ + canvasRef: { current: null }, + histogram: emptyHistogram(), + isRendering: false, + hasSource: true, + previewAspectRatio: 1, + error: null, + })); + + vi.doMock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: hookSpy, + })); + vi.doMock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map([["upstream-node", { brightness: 10 }]]), + }), + })); + vi.doMock("@/lib/canvas-render-preview", () => ({ + collectPipelineFromGraph: () => [ + { + nodeId: "upstream-node", + type: "light-adjust", + params: { brightness: 10 }, + }, + { + nodeId: "light-1", + type: "light-adjust", + params: { brightness: 20 }, + }, + ], + getSourceImageFromGraph: () => "https://cdn.example.com/source.png", + shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map) => + steps.some((step) => overrides.has(step.nodeId)), + })); + + const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview"); + const AdjustmentPreview = adjustmentPreviewModule.default; + + await act(async () => { + root?.render( + createElement(AdjustmentPreview, { + nodeId: "light-1", + nodeWidth: 320, + currentType: "light-adjust", + currentParams: { brightness: 20 }, + }), + ); + }); + + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + includeHistogram: true, + debounceMs: 16, + }), + ); + }); + + it("requests fast preview rendering without histogram work in CompareSurface and RenderNode", async () => { const hookSpy = vi.fn(() => ({ canvasRef: { current: null }, histogram: emptyHistogram(), @@ -570,14 +728,29 @@ describe("preview histogram call sites", () => { useDebouncedCallback: (callback: () => void) => callback, })); vi.doMock("@/components/canvas/canvas-graph-context", () => ({ - useCanvasGraph: () => ({ nodes: [], edges: [] }), + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map([ + ["compare-step", { brightness: 20 }], + ["render-1-pipeline", { format: "png" }], + ]), + }), })); vi.doMock("@/lib/canvas-render-preview", () => ({ resolveRenderPreviewInputFromGraph: () => ({ sourceUrl: "https://cdn.example.com/source.png", - steps: [], + steps: [ + { + nodeId: "render-1-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], }), findSourceNodeFromGraph: () => null, + shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map) => + steps.some((step) => overrides.has(step.nodeId)), })); vi.doMock("@/lib/canvas-utils", () => ({ resolveMediaAspectRatio: () => null, @@ -616,7 +789,13 @@ describe("preview histogram call sites", () => { nodeWidth: 320, previewInput: { sourceUrl: "https://cdn.example.com/source.png", - steps: [], + steps: [ + { + nodeId: "compare-step", + type: "light-adjust", + params: { brightness: 20 }, + }, + ], }, preferPreview: true, }), @@ -641,16 +820,229 @@ describe("preview histogram call sites", () => { ); }); - expect(hookSpy).toHaveBeenCalledWith( + expect(hookSpy).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ includeHistogram: false, sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "compare-step", + type: "light-adjust", + params: { brightness: 20 }, + }, + ], + debounceMs: 16, }), ); - expect(hookSpy).toHaveBeenCalledWith( + expect(hookSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "render-1-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + debounceMs: 16, + }), + ); + expect(hookSpy).toHaveBeenNthCalledWith( + 3, expect.objectContaining({ includeHistogram: false, sourceUrl: null, + debounceMs: 16, + }), + ); + }); + + it("does not fast-path CompareSurface or RenderNode for unrelated overrides", async () => { + const hookSpy = vi.fn(() => ({ + canvasRef: { current: null }, + histogram: emptyHistogram(), + isRendering: false, + hasSource: true, + previewAspectRatio: 1, + error: null, + })); + + vi.doMock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: hookSpy, + })); + vi.doMock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Left: "left", Right: "right" }, + })); + vi.doMock("convex/react", () => ({ + useMutation: () => vi.fn(async () => undefined), + })); + vi.doMock("lucide-react", () => ({ + AlertCircle: () => null, + ArrowDown: () => null, + CheckCircle2: () => null, + CloudUpload: () => null, + Loader2: () => null, + Maximize2: () => null, + X: () => null, + })); + vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + })); + vi.doMock("@/components/canvas/nodes/adjustment-controls", () => ({ + SliderRow: () => null, + })); + vi.doMock("@/components/ui/select", () => ({ + Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + SelectValue: () => null, + })); + vi.doMock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: vi.fn(async () => undefined), + queueNodeResize: vi.fn(async () => undefined), + status: { isOffline: false }, + }), + })); + vi.doMock("@/hooks/use-debounced-callback", () => ({ + useDebouncedCallback: (callback: () => void) => callback, + })); + vi.doMock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => ({ + nodes: [], + edges: [], + previewNodeDataOverrides: new Map([["unrelated-node", { format: "png" }]]), + }), + })); + vi.doMock("@/lib/canvas-render-preview", () => ({ + resolveRenderPreviewInputFromGraph: ({ nodeId }: { nodeId: string }) => ({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: `${nodeId}-pipeline`, + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + }), + findSourceNodeFromGraph: () => null, + shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map) => + steps.some((step) => overrides.has(step.nodeId)), + })); + vi.doMock("@/lib/canvas-utils", () => ({ + resolveMediaAspectRatio: () => null, + })); + vi.doMock("@/lib/image-formats", () => ({ + parseAspectRatioString: () => ({ w: 1, h: 1 }), + })); + vi.doMock("@/lib/image-pipeline/contracts", async () => { + const actual = await vi.importActual( + "@/lib/image-pipeline/contracts", + ); + return { + ...actual, + hashPipeline: () => "pipeline-hash", + }; + }); + vi.doMock("@/lib/image-pipeline/worker-client", () => ({ + isPipelineAbortError: () => false, + renderFullWithWorkerFallback: vi.fn(), + })); + vi.doMock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + DialogContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => createElement("div", null, children), + })); + + const compareSurfaceModule = await import("@/components/canvas/nodes/compare-surface"); + const CompareSurface = compareSurfaceModule.default; + const renderNodeModule = await import("@/components/canvas/nodes/render-node"); + const RenderNode = renderNodeModule.default; + + await act(async () => { + root?.render( + createElement("div", null, + createElement(CompareSurface, { + nodeWidth: 320, + previewInput: { + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "compare-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + }, + preferPreview: true, + }), + createElement(RenderNode, { + id: "render-1", + data: {}, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "render", + xPos: 0, + yPos: 0, + width: 320, + height: 300, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as never), + ), + ); + }); + + expect(hookSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + includeHistogram: false, + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "compare-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + debounceMs: undefined, + }), + ); + expect(hookSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "render-1-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + debounceMs: undefined, + }), + ); + expect(hookSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + includeHistogram: false, + sourceUrl: null, + steps: [ + { + nodeId: "render-1-pipeline", + type: "light-adjust", + params: { brightness: 10 }, + }, + ], + debounceMs: undefined, }), ); }); diff --git a/vitest.config.ts b/vitest.config.ts index 09f08bc..507848e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-connections.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", + "components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx", ],