feat(canvas): accelerate local previews and harden edge flows

This commit is contained in:
2026-04-05 17:28:43 +02:00
parent 451ab0b986
commit de37b63b2b
29 changed files with 2751 additions and 358 deletions

View File

@@ -4,6 +4,7 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { withResolvedCompareData } from "../canvas-helpers"; import { withResolvedCompareData } from "../canvas-helpers";
import { import {
buildGraphSnapshot, buildGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
resolveRenderPreviewInputFromGraph, resolveRenderPreviewInputFromGraph,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
@@ -100,6 +101,129 @@ describe("withResolvedCompareData", () => {
}); });
describe("canvas preview graph helpers", () => { 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", () => { it("resolves the upstream source and pipeline steps from a graph snapshot", () => {
const graph = buildGraphSnapshot( 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 },
},
]);
});
}); });

View File

@@ -2,6 +2,8 @@ import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
type StoreState = { type StoreState = {
nodes: Array<{ id: string; type?: string; data?: unknown }>; nodes: Array<{ id: string; type?: string; data?: unknown }>;
edges: Array<{ edges: Array<{
@@ -39,6 +41,17 @@ vi.mock("../nodes/compare-surface", () => ({
import CompareNode from "../nodes/compare-node"; import CompareNode from "../nodes/compare-node";
function renderCompareNode(props: Record<string, unknown>) {
return renderToStaticMarkup(
<CanvasGraphProvider
nodes={storeState.nodes as Array<{ id: string; type: string; data?: unknown }>}
edges={storeState.edges}
>
<CompareNode {...(props as React.ComponentProps<typeof CompareNode>)} />
</CanvasGraphProvider>,
);
}
describe("CompareNode render preview inputs", () => { describe("CompareNode render preview inputs", () => {
beforeEach(() => { beforeEach(() => {
storeState.nodes = []; storeState.nodes = [];
@@ -69,8 +82,7 @@ describe("CompareNode render preview inputs", () => {
}, },
]; ];
renderToStaticMarkup( renderCompareNode({
React.createElement(CompareNode, {
id: "compare-1", id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" }, data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false, selected: false,
@@ -86,8 +98,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined, targetPosition: undefined,
positionAbsoluteX: 0, positionAbsoluteX: 0,
positionAbsoluteY: 0, positionAbsoluteY: 0,
} as never), });
);
expect(compareSurfaceSpy).toHaveBeenCalled(); expect(compareSurfaceSpy).toHaveBeenCalled();
const previewCall = compareSurfaceSpy.mock.calls.find( const previewCall = compareSurfaceSpy.mock.calls.find(
@@ -131,8 +142,7 @@ describe("CompareNode render preview inputs", () => {
}, },
]; ];
renderToStaticMarkup( renderCompareNode({
React.createElement(CompareNode, {
id: "compare-1", id: "compare-1",
data: { leftUrl: "https://cdn.example.com/render-output.png" }, data: { leftUrl: "https://cdn.example.com/render-output.png" },
selected: false, selected: false,
@@ -148,8 +158,7 @@ describe("CompareNode render preview inputs", () => {
targetPosition: undefined, targetPosition: undefined,
positionAbsoluteX: 0, positionAbsoluteX: 0,
positionAbsoluteY: 0, positionAbsoluteY: 0,
} as never), });
);
expect(compareSurfaceSpy).toHaveBeenCalledTimes(1); expect(compareSurfaceSpy).toHaveBeenCalledTimes(1);
expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({ expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({

View File

@@ -147,6 +147,14 @@ describe("useCanvasConnections", () => {
}); });
await act(async () => { await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd( latestHandlersRef.current?.onConnectEnd(
{ clientX: 400, clientY: 260 } as MouseEvent, { clientX: 400, clientY: 260 } as MouseEvent,
{ {
@@ -192,6 +200,14 @@ describe("useCanvasConnections", () => {
}); });
await act(async () => { await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd( latestHandlersRef.current?.onConnectEnd(
{ clientX: 123, clientY: 456 } as MouseEvent, { clientX: 123, clientY: 456 } as MouseEvent,
{ {
@@ -244,6 +260,14 @@ describe("useCanvasConnections", () => {
}); });
await act(async () => { await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: null,
handleType: "source",
} as never,
);
latestHandlersRef.current?.onConnectEnd( latestHandlersRef.current?.onConnectEnd(
{ clientX: 300, clientY: 210 } as MouseEvent, { clientX: 300, clientY: 210 } as MouseEvent,
{ {
@@ -288,6 +312,14 @@ describe("useCanvasConnections", () => {
}); });
await act(async () => { await act(async () => {
latestHandlersRef.current?.onConnectStart?.(
{} as MouseEvent,
{
nodeId: "node-source",
handleId: "target-handle",
handleType: "target",
} as never,
);
latestHandlersRef.current?.onConnectEnd( latestHandlersRef.current?.onConnectEnd(
{ clientX: 200, clientY: 200 } as MouseEvent, { clientX: 200, clientY: 200 } as MouseEvent,
{ {
@@ -313,4 +345,50 @@ describe("useCanvasConnections", () => {
targetHandle: "target-handle", 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(
<HookHarness
helperResult={{
sourceNodeId: "node-source",
targetNodeId: "node-target",
sourceHandle: undefined,
targetHandle: undefined,
}}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
/>,
);
});
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();
});
}); });

View File

@@ -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> | void;
};
const latestHookRef: {
current:
| {
applyLocalData: (next: AdjustmentData) => void;
updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void;
localData: AdjustmentData;
}
| null;
} = { current: null };
const latestOverridesRef: {
current: ReadonlyMap<string, unknown>;
} = { 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<AdjustmentData>({
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> | void;
}) {
return (
<CanvasGraphProvider nodes={[{ id: nodeId, type: "curves", data }]} edges={[]}>
{mounted ? <HookHarness nodeId={nodeId} data={data} onSave={onSave} /> : null}
<GraphProbe />
</CanvasGraphProvider>
);
}
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.8, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
latestHookRef.current?.applyLocalData({
exposure: 0.8,
label: "persisted",
});
});
expect(onSave).not.toHaveBeenCalled();
await act(async () => {
root?.render(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted={false}
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.3, label: "server" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.2, label: "persisted" }}
mounted
onSave={onSave}
/>,
);
});
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(
<TestApp
nodeId="node-1"
data={{ exposure: 0.75, label: "server-normalized" }}
mounted
onSave={onSave}
/>,
);
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(latestHookRef.current?.localData).toEqual({
exposure: 0.75,
label: "server-normalized",
});
expect(latestOverridesRef.current).toEqual(new Map());
vi.useRealTimers();
});
});

View File

@@ -122,22 +122,22 @@ export function useCanvasDeleteHandlers({
nodes, nodes,
edges, edges,
); );
const edgePromises = bridgeCreates.map((bridgeCreate) =>
runCreateEdgeMutation({
canvasId,
sourceNodeId: bridgeCreate.sourceNodeId,
targetNodeId: bridgeCreate.targetNodeId,
sourceHandle: bridgeCreate.sourceHandle,
targetHandle: bridgeCreate.targetHandle,
}),
);
void Promise.all([ void (async () => {
runBatchRemoveNodesMutation({ await runBatchRemoveNodesMutation({
nodeIds: idsToDelete as Id<"nodes">[], 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(() => { .then(() => {
// Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen. // Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen.
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält. // Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.

View File

@@ -1,17 +1,37 @@
"use client"; "use client";
import { createContext, useContext, useMemo, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { import {
buildGraphSnapshot, buildGraphSnapshot,
type CanvasGraphEdgeLike, type CanvasGraphEdgeLike,
type CanvasGraphNodeDataOverrides,
type CanvasGraphNodeLike, type CanvasGraphNodeLike,
type CanvasGraphSnapshot, type CanvasGraphSnapshot,
pruneCanvasGraphNodeDataOverrides,
} from "@/lib/canvas-render-preview"; } 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<CanvasGraphContextValue | null>(null); const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
const CanvasGraphPreviewOverridesContext =
createContext<CanvasGraphPreviewOverridesContextValue | null>(null);
export function CanvasGraphProvider({ export function CanvasGraphProvider({
nodes, nodes,
@@ -22,9 +42,88 @@ export function CanvasGraphProvider({
edges: readonly CanvasGraphEdgeLike[]; edges: readonly CanvasGraphEdgeLike[];
children: ReactNode; children: ReactNode;
}) { }) {
const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]); const [previewNodeDataOverrides, setPreviewNodeDataOverrides] =
useState<CanvasGraphNodeDataOverrides>(() => new Map());
return <CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>; 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 (
<CanvasGraphPreviewOverridesContext.Provider value={previewOverridesValue}>
<CanvasGraphContext.Provider value={value}>{children}</CanvasGraphContext.Provider>
</CanvasGraphPreviewOverridesContext.Provider>
);
} }
export function useCanvasGraph(): CanvasGraphContextValue { export function useCanvasGraph(): CanvasGraphContextValue {
@@ -35,3 +134,12 @@ export function useCanvasGraph(): CanvasGraphContextValue {
return context; return context;
} }
export function useCanvasGraphPreviewOverrides(): CanvasGraphPreviewOverridesContextValue {
const context = useContext(CanvasGraphPreviewOverridesContext);
if (!context) {
throw new Error("useCanvasGraphPreviewOverrides must be used within CanvasGraphProvider");
}
return context;
}

View File

@@ -308,6 +308,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
closeConnectionDropMenu, closeConnectionDropMenu,
handleConnectionDropPick, handleConnectionDropPick,
onConnect, onConnect,
onConnectStart,
onConnectEnd, onConnectEnd,
onReconnectStart, onReconnectStart,
onReconnect, onReconnect,
@@ -520,6 +521,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
onNodeDrag={onNodeDrag} onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
onConnect={onConnect} onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd} onConnectEnd={onConnectEnd}
onReconnect={onReconnect} onReconnect={onReconnect}
onReconnectStart={onReconnectStart} onReconnectStart={onReconnectStart}

View File

@@ -1,46 +1,18 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useMemo } from "react";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
import { import {
collectPipelineFromGraph, collectPipelineFromGraph,
getSourceImageFromGraph, getSourceImageFromGraph,
shouldFastPathPreviewPipeline,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot"; import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
const PREVIEW_PIPELINE_TYPES = new Set([ const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]);
"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;
}
export default function AdjustmentPreview({ export default function AdjustmentPreview({
nodeId, nodeId,
@@ -54,7 +26,6 @@ export default function AdjustmentPreview({
currentParams: unknown; currentParams: unknown;
}) { }) {
const graph = useCanvasGraph(); const graph = useCanvasGraph();
const lastLoggedTraceSequenceRef = useRef<number | null>(null);
const sourceUrl = useMemo( const sourceUrl = useMemo(
() => () =>
@@ -93,36 +64,21 @@ export default function AdjustmentPreview({
}); });
}, [currentParams, currentType, graph, nodeId]); }, [currentParams, currentType, graph, nodeId]);
useEffect(() => { const usesFastPreviewDebounce = shouldFastPathPreviewPipeline(
const trace = readPreviewLatencyTrace(); steps,
if (!trace) { graph.previewNodeDataOverrides,
return; );
} const shouldDeferHistogram = graph.previewNodeDataOverrides.has(nodeId);
if (lastLoggedTraceSequenceRef.current === trace.sequence) { const previewDebounceMs = usesFastPreviewDebounce ? 16 : undefined;
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 { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } = const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } =
usePipelinePreview({ usePipelinePreview({
sourceUrl, sourceUrl,
steps, steps,
nodeWidth, nodeWidth,
includeHistogram: true, includeHistogram: !shouldDeferHistogram,
debounceMs: previewDebounceMs,
// Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller // Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller
// Display-Auflösung rechnen. // Display-Auflösung rechnen.
previewScale: 0.5, previewScale: 0.5,

View File

@@ -59,6 +59,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
[], [],
); );
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({ const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
nodeId: id,
data, data,
normalize: normalizeData, normalize: normalizeData,
saveDelayMs: 16, saveDelayMs: 16,

View File

@@ -1,7 +1,11 @@
"use client"; "use client";
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; 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"] = []; const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
@@ -22,16 +26,24 @@ export default function CompareSurface({
clipWidthPercent, clipWidthPercent,
preferPreview, preferPreview,
}: CompareSurfaceProps) { }: CompareSurfaceProps) {
const graph = useCanvasGraph();
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl)); const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null; const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS; const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
const visibleFinalUrl = usePreview ? undefined : finalUrl; const visibleFinalUrl = usePreview ? undefined : finalUrl;
const previewDebounceMs = shouldFastPathPreviewPipeline(
previewSteps,
graph.previewNodeDataOverrides,
)
? 16
: undefined;
const { canvasRef, isRendering, error } = usePipelinePreview({ const { canvasRef, isRendering, error } = usePipelinePreview({
sourceUrl: previewSourceUrl, sourceUrl: previewSourceUrl,
steps: previewSteps, steps: previewSteps,
nodeWidth, nodeWidth,
includeHistogram: false, includeHistogram: false,
debounceMs: previewDebounceMs,
// Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
// halten lange Workflows spürbar reaktionsfreudiger. // halten lange Workflows spürbar reaktionsfreudiger.
previewScale: 0.5, previewScale: 0.5,

View File

@@ -59,6 +59,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
[], [],
); );
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({ const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
nodeId: id,
data, data,
normalize: normalizeData, normalize: normalizeData,
saveDelayMs: 16, saveDelayMs: 16,

View File

@@ -59,6 +59,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
[], [],
); );
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({ const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
nodeId: id,
data, data,
normalize: normalizeData, normalize: normalizeData,
saveDelayMs: 16, saveDelayMs: 16,

View File

@@ -59,6 +59,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
[], [],
); );
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({ const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
nodeId: id,
data, data,
normalize: normalizeData, normalize: normalizeData,
saveDelayMs: 16, saveDelayMs: 16,

View File

@@ -16,6 +16,7 @@ import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
import { import {
findSourceNodeFromGraph, findSourceNodeFromGraph,
resolveRenderPreviewInputFromGraph, resolveRenderPreviewInputFromGraph,
shouldFastPathPreviewPipeline,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { parseAspectRatioString } from "@/lib/image-formats"; 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 steps = renderPreviewInput.steps;
const previewDebounceMs = shouldFastPathPreviewPipeline(
steps,
graph.previewNodeDataOverrides,
)
? 16
: undefined;
const renderFingerprint = useMemo( const renderFingerprint = useMemo(
() => ({ () => ({
@@ -560,6 +567,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
sourceUrl, sourceUrl,
steps, steps,
nodeWidth: previewNodeWidth, nodeWidth: previewNodeWidth,
debounceMs: previewDebounceMs,
// Inline-Preview: bewusst kompakt halten, damit Änderungen schneller // Inline-Preview: bewusst kompakt halten, damit Änderungen schneller
// sichtbar werden, besonders in langen Graphen. // sichtbar werden, besonders in langen Graphen.
previewScale: 0.5, previewScale: 0.5,
@@ -577,6 +585,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
steps, steps,
nodeWidth: fullscreenPreviewWidth, nodeWidth: fullscreenPreviewWidth,
includeHistogram: false, includeHistogram: false,
debounceMs: previewDebounceMs,
previewScale: 0.85, previewScale: 0.85,
maxPreviewWidth: 1920, maxPreviewWidth: 1920,
maxDevicePixelRatio: 1.5, maxDevicePixelRatio: 1.5,

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCanvasGraphPreviewOverrides } from "@/components/canvas/canvas-graph-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
function hashNodeData(value: unknown): string { function hashNodeData(value: unknown): string {
@@ -21,69 +22,67 @@ function logNodeDataDebug(event: string, payload: Record<string, unknown>): void
console.info("[Canvas node debug]", event, payload); console.info("[Canvas node debug]", event, payload);
} }
type PreviewLatencyTrace = {
sequence: number;
changedAtMs: number;
nodeType: string;
origin: "applyLocalData" | "updateLocalData";
};
function writePreviewLatencyTrace(trace: Omit<PreviewLatencyTrace, "sequence">): 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<T>({ export function useNodeLocalData<T>({
nodeId,
data, data,
normalize, normalize,
saveDelayMs, saveDelayMs,
onSave, onSave,
debugLabel, debugLabel,
}: { }: {
nodeId: string;
data: unknown; data: unknown;
normalize: (value: unknown) => T; normalize: (value: unknown) => T;
saveDelayMs: number; saveDelayMs: number;
onSave: (value: T) => Promise<void> | void; onSave: (value: T) => Promise<void> | void;
debugLabel: string; debugLabel: string;
}) { }) {
const { setPreviewNodeDataOverride, clearPreviewNodeDataOverride } =
useCanvasGraphPreviewOverrides();
const [localData, setLocalDataState] = useState<T>(() => normalize(data)); const [localData, setLocalDataState] = useState<T>(() => normalize(data));
const localDataRef = useRef(localData); const localDataRef = useRef(localData);
const persistedDataRef = useRef(localData);
const hasPendingLocalChangesRef = useRef(false); const hasPendingLocalChangesRef = useRef(false);
const localChangeVersionRef = useRef(0);
const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
localDataRef.current = localData; localDataRef.current = localData;
}, [localData]); }, [localData]);
const queueSave = useDebouncedCallback(() => { 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); }, saveDelayMs);
useEffect(() => { useEffect(() => {
const incomingData = normalize(data); const incomingData = normalize(data);
persistedDataRef.current = incomingData;
const incomingHash = hashNodeData(incomingData); const incomingHash = hashNodeData(incomingData);
const localHash = hashNodeData(localDataRef.current); const localHash = hashNodeData(localDataRef.current);
if (incomingHash === localHash) { if (incomingHash === localHash) {
hasPendingLocalChangesRef.current = false; hasPendingLocalChangesRef.current = false;
clearPreviewNodeDataOverride(nodeId);
return; return;
} }
@@ -99,44 +98,46 @@ export function useNodeLocalData<T>({
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
localDataRef.current = incomingData; localDataRef.current = incomingData;
setLocalDataState(incomingData); setLocalDataState(incomingData);
clearPreviewNodeDataOverride(nodeId);
}, 0); }, 0);
return () => { return () => {
window.clearTimeout(timer); 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( const applyLocalData = useCallback(
(next: T) => { (next: T) => {
localChangeVersionRef.current += 1;
hasPendingLocalChangesRef.current = true; hasPendingLocalChangesRef.current = true;
writePreviewLatencyTrace({
changedAtMs: performance.now(),
nodeType: debugLabel,
origin: "applyLocalData",
});
localDataRef.current = next; localDataRef.current = next;
setLocalDataState(next); setLocalDataState(next);
setPreviewNodeDataOverride(nodeId, next);
queueSave(); queueSave();
}, },
[debugLabel, queueSave], [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
); );
const updateLocalData = useCallback( const updateLocalData = useCallback(
(updater: (current: T) => T) => { (updater: (current: T) => T) => {
const next = updater(localDataRef.current);
localChangeVersionRef.current += 1;
hasPendingLocalChangesRef.current = true; hasPendingLocalChangesRef.current = true;
setLocalDataState((current) => { localDataRef.current = next;
const next = updater(current); setLocalDataState(next);
writePreviewLatencyTrace({ setPreviewNodeDataOverride(nodeId, next);
changedAtMs: performance.now(), queueSave();
nodeType: debugLabel,
origin: "updateLocalData",
});
localDataRef.current = next;
queueSave();
return next;
});
}, },
[debugLabel, queueSave], [debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
); );
return { return {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; 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 type { Id } from "@/convex/_generated/dataModel";
import { import {
@@ -100,14 +100,20 @@ export function useCanvasConnections({
const [connectionDropMenu, setConnectionDropMenu] = const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null); useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null); const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
const isConnectDragActiveRef = useRef(false);
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []); const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
useEffect(() => { useEffect(() => {
connectionDropMenuRef.current = connectionDropMenu; connectionDropMenuRef.current = connectionDropMenu;
}, [connectionDropMenu]); }, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>(() => {
isConnectDragActiveRef.current = true;
}, []);
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
isConnectDragActiveRef.current = false;
const validationError = validateCanvasConnection(connection, nodes, edges); const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) { if (validationError) {
showConnectionRejectedToast(validationError); showConnectionRejectedToast(validationError);
@@ -129,6 +135,11 @@ export function useCanvasConnections({
const onConnectEnd = useCallback<OnConnectEnd>( const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => { (event, connectionState) => {
if (!isConnectDragActiveRef.current) {
return;
}
isConnectDragActiveRef.current = false;
if (isReconnectDragActiveRef.current) return; if (isReconnectDragActiveRef.current) return;
if (connectionState.isValid === true) return; if (connectionState.isValid === true) return;
const fromNode = connectionState.fromNode; const fromNode = connectionState.fromNode;
@@ -153,8 +164,8 @@ export function useCanvasConnections({
{ {
source: droppedConnection.sourceNodeId, source: droppedConnection.sourceNodeId,
target: droppedConnection.targetNodeId, target: droppedConnection.targetNodeId,
sourceHandle: droppedConnection.sourceHandle, sourceHandle: droppedConnection.sourceHandle ?? null,
targetHandle: droppedConnection.targetHandle, targetHandle: droppedConnection.targetHandle ?? null,
}, },
nodesRef.current, nodesRef.current,
edgesRef.current, edgesRef.current,
@@ -333,14 +344,15 @@ export function useCanvasConnections({
}, },
}); });
return { return {
connectionDropMenu, connectionDropMenu,
closeConnectionDropMenu, closeConnectionDropMenu,
handleConnectionDropPick, handleConnectionDropPick,
onConnect, onConnect,
onConnectEnd, onConnectStart,
onReconnectStart, onConnectEnd,
onReconnect, onReconnectStart,
onReconnectEnd, onReconnect,
}; onReconnectEnd,
};
} }

View File

@@ -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<string, unknown>,
): CanvasGraphSnapshot {
const nodesById = new Map<string, CanvasGraphNodeLike>();
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(
<CanvasGraphPreviewOverridesTestProvider
value={{ setNodePreviewOverride, clearNodePreviewOverride, hasPreviewOverrides: false }}
>
<Harness nodeId="color-1" data={{ temperature: 0 }} />
</CanvasGraphPreviewOverridesTestProvider>,
);
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(
<Harness
sourceUrl="https://cdn.example.com/source.png"
steps={createLightAdjustSteps(10)}
previewDebounceMs={16}
/>,
);
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(
<Harness
sourceUrl="https://cdn.example.com/source.png"
steps={createLightAdjustSteps(10)}
includeHistogram={false}
/>,
);
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`.

View File

@@ -1,5 +1,10 @@
import { useRef, useCallback, useEffect } from "react"; import { useRef, useCallback, useEffect } from "react";
type DebouncedCallback<Args extends unknown[]> = ((...args: Args) => void) & {
flush: () => void;
cancel: () => void;
};
/** /**
* Debounced callback — ruft `callback` erst auf, wenn `delay` ms * Debounced callback — ruft `callback` erst auf, wenn `delay` ms
* ohne erneuten Aufruf vergangen sind. Perfekt für Auto-Save. * ohne erneuten Aufruf vergangen sind. Perfekt für Auto-Save.
@@ -7,9 +12,10 @@ import { useRef, useCallback, useEffect } from "react";
export function useDebouncedCallback<Args extends unknown[]>( export function useDebouncedCallback<Args extends unknown[]>(
callback: (...args: Args) => void, callback: (...args: Args) => void,
delay: number, delay: number,
): (...args: Args) => void { ): DebouncedCallback<Args> {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef(callback); const callbackRef = useRef(callback);
const argsRef = useRef<Args | null>(null);
// Callback-Ref aktuell halten ohne neu zu rendern // Callback-Ref aktuell halten ohne neu zu rendern
useEffect(() => { useEffect(() => {
@@ -23,15 +29,49 @@ export function useDebouncedCallback<Args extends unknown[]>(
}; };
}, []); }, []);
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( const debouncedFn = useCallback(
(...args: Args) => { (...args: Args) => {
argsRef.current = args;
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
callbackRef.current(...args); timeoutRef.current = null;
const nextArgs = argsRef.current;
argsRef.current = null;
if (nextArgs) {
callbackRef.current(...nextArgs);
}
}, delay); }, delay);
}, },
[delay], [delay],
); );
return debouncedFn; const debouncedCallback = debouncedFn as DebouncedCallback<Args>;
debouncedCallback.flush = flush;
debouncedCallback.cancel = cancel;
return debouncedCallback;
} }

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts"; import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
import { import {
getLastBackendDiagnostics,
isPipelineAbortError, isPipelineAbortError,
renderPreviewWithWorkerFallback, renderPreviewWithWorkerFallback,
type PreviewRenderResult, type PreviewRenderResult,
@@ -19,50 +18,11 @@ type UsePipelinePreviewOptions = {
previewScale?: number; previewScale?: number;
maxPreviewWidth?: number; maxPreviewWidth?: number;
maxDevicePixelRatio?: number; maxDevicePixelRatio?: number;
debounceMs?: number;
}; };
const PREVIEW_RENDER_DEBOUNCE_MS = 48; 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<string, unknown>): 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( function computePreviewWidth(
nodeWidth: number, nodeWidth: number,
previewScale: number, previewScale: number,
@@ -121,6 +81,14 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
return Math.max(1, options.maxDevicePixelRatio); return Math.max(1, options.maxDevicePixelRatio);
}, [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( const previewWidth = useMemo(
() => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio), () => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio),
[maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale], [maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale],
@@ -161,23 +129,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
const currentRun = runIdRef.current + 1; const currentRun = runIdRef.current + 1;
runIdRef.current = currentRun; runIdRef.current = currentRun;
const abortController = new AbortController(); const abortController = new AbortController();
const effectStartedAtMs = performance.now();
const timer = window.setTimeout(() => { 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); setIsRendering(true);
setError(null); setError(null);
void renderPreviewWithWorkerFallback({ void renderPreviewWithWorkerFallback({
@@ -200,20 +153,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
return; return;
} }
context.putImageData(result.imageData, 0, 0); context.putImageData(result.imageData, 0, 0);
const paintedAtMs = performance.now();
setHistogram(result.histogram); setHistogram(result.histogram);
setPreviewAspectRatio(result.width / result.height); 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) => { .catch((renderError: unknown) => {
if (runIdRef.current !== currentRun) return; if (runIdRef.current !== currentRun) return;
@@ -231,7 +172,6 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
pipelineHash, pipelineHash,
previewWidth, previewWidth,
includeHistogram: options.includeHistogram, includeHistogram: options.includeHistogram,
diagnostics: getLastBackendDiagnostics(),
error: renderError, error: renderError,
}); });
} }
@@ -242,13 +182,13 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
if (runIdRef.current !== currentRun) return; if (runIdRef.current !== currentRun) return;
setIsRendering(false); setIsRendering(false);
}); });
}, PREVIEW_RENDER_DEBOUNCE_MS); }, debounceMs);
return () => { return () => {
window.clearTimeout(timer); window.clearTimeout(timer);
abortController.abort(); abortController.abort();
}; };
}, [options.includeHistogram, pipelineHash, previewWidth]); }, [debounceMs, options.includeHistogram, pipelineHash, previewWidth]);
return { return {
canvasRef, canvasRef,

View File

@@ -38,6 +38,65 @@ export type CanvasGraphSnapshot = {
incomingEdgesByTarget: ReadonlyMap<string, readonly CanvasGraphEdgeLike[]>; incomingEdgesByTarget: ReadonlyMap<string, readonly CanvasGraphEdgeLike[]>;
}; };
export type CanvasGraphNodeDataOverrides = ReadonlyMap<string, unknown>;
export function shouldFastPathPreviewPipeline(
steps: readonly Pick<PipelineStep, "nodeId">[],
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<string, unknown> | 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 RenderResolutionOption = "original" | "2x" | "custom";
type RenderFormatOption = "png" | "jpeg" | "webp"; type RenderFormatOption = "png" | "jpeg" | "webp";
@@ -135,11 +194,17 @@ export function resolveNodeImageUrl(data: unknown): string | null {
export function buildGraphSnapshot( export function buildGraphSnapshot(
nodes: readonly CanvasGraphNodeLike[], nodes: readonly CanvasGraphNodeLike[],
edges: readonly CanvasGraphEdgeLike[], edges: readonly CanvasGraphEdgeLike[],
includeTempEdges = false, options: boolean | BuildGraphSnapshotOptions = false,
): CanvasGraphSnapshot { ): CanvasGraphSnapshot {
const includeTempEdges =
typeof options === "boolean" ? options : (options.includeTempEdges ?? false);
const nodeDataOverrides = typeof options === "boolean" ? undefined : options.nodeDataOverrides;
const nodesById = new Map<string, CanvasGraphNodeLike>(); const nodesById = new Map<string, CanvasGraphNodeLike>();
for (const node of nodes) { 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<string, CanvasGraphEdgeLike[]>(); const incomingEdgesByTarget = new Map<string, CanvasGraphEdgeLike[]>();

View File

@@ -65,14 +65,6 @@ function normalizeBackendHint(value: BackendHint): string | null {
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
function logBackendRouterDebug(event: string, payload: Record<string, unknown>): void {
if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") {
return;
}
console.info("[image-pipeline backend]", event, payload);
}
export function createBackendRouter(options?: { export function createBackendRouter(options?: {
backends?: readonly ImagePipelineBackend[]; backends?: readonly ImagePipelineBackend[];
defaultBackendId?: string; defaultBackendId?: string;
@@ -131,12 +123,6 @@ export function createBackendRouter(options?: {
} }
function emitFallback(event: BackendFallbackEvent): void { function emitFallback(event: BackendFallbackEvent): void {
logBackendRouterDebug("fallback", {
reason: event.reason,
requestedBackend: event.requestedBackend,
fallbackBackend: event.fallbackBackend,
errorMessage: event.error?.message,
});
options?.onFallback?.(event); options?.onFallback?.(event);
} }
@@ -365,15 +351,6 @@ export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequ
backendHint = CPU_BACKEND_ID; 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; return backendHint;
} }

View File

@@ -4,8 +4,11 @@ import type {
ImagePipelineBackend, ImagePipelineBackend,
} from "@/lib/image-pipeline/backend/backend-types"; } from "@/lib/image-pipeline/backend/backend-types";
import { import {
normalizeColorAdjustData,
normalizeCurvesData,
normalizeDetailAdjustData, normalizeDetailAdjustData,
normalizeLightAdjustData, normalizeLightAdjustData,
type CurvePoint,
} from "@/lib/image-pipeline/adjustment-types"; } from "@/lib/image-pipeline/adjustment-types";
import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import type { PipelineStep } from "@/lib/image-pipeline/contracts";
@@ -14,12 +17,47 @@ precision mediump float;
varying vec2 vUv; varying vec2 vUv;
uniform sampler2D uSource; 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() { void main() {
vec4 color = texture2D(uSource, vUv); vec4 color = texture2D(uSource, vUv);
color.rgb = pow(max(color.rgb, vec3(0.0)), vec3(max(uGamma, 0.001))); float levelRange = max(uWhitePoint - uBlackPoint, 1.0);
gl_FragColor = color; 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; varying vec2 vUv;
uniform sampler2D uSource; 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() { void main() {
vec4 color = texture2D(uSource, vUv); vec4 color = texture2D(uSource, vUv);
color.rgb = clamp(color.rgb + uColorShift, 0.0, 1.0); vec3 hsl = rgbToHsl(color.rgb);
gl_FragColor = color; 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<SupportedPreviewStepType>([
"detail-adjust", "detail-adjust",
]); ]);
function logWebglBackendDebug(event: string, payload: Record<string, unknown>): void { function clamp(value: number, min: number, max: number): number {
if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { return Math.max(min, Math.min(max, value));
return; }
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 { function assertSupportedStep(step: PipelineStep): void {
@@ -279,52 +454,98 @@ function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer {
return quadBuffer; 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( function applyStepUniforms(
gl: WebGLRenderingContext, gl: WebGLRenderingContext,
shaderProgram: WebGLProgram, shaderProgram: WebGLProgram,
request: BackendStepRequest, request: BackendStepRequest,
): void { ): WebGLTexture[] {
const disposableTextures: WebGLTexture[] = [];
if (request.step.type === "curves") { if (request.step.type === "curves") {
const gammaLocation = gl.getUniformLocation(shaderProgram, "uGamma"); const curves = normalizeCurvesData(request.step.params);
if (gammaLocation) {
gl.uniform1f(gammaLocation, mapCurvesGamma(request.step)); 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") { if (request.step.type === "color-adjust") {
const colorShiftLocation = gl.getUniformLocation(shaderProgram, "uColorShift"); const color = normalizeColorAdjustData(request.step.params);
if (colorShiftLocation) {
const [r, g, b] = mapColorShift(request.step); const hueShiftLocation = gl.getUniformLocation(shaderProgram, "uHueShift");
gl.uniform3f(colorShiftLocation, r, g, b); 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") { if (request.step.type === "light-adjust") {
@@ -378,7 +599,7 @@ function applyStepUniforms(
if (vignetteRoundnessLocation) { if (vignetteRoundnessLocation) {
gl.uniform1f(vignetteRoundnessLocation, light.vignette.roundness); gl.uniform1f(vignetteRoundnessLocation, light.vignette.roundness);
} }
return; return disposableTextures;
} }
if (request.step.type === "detail-adjust") { if (request.step.type === "detail-adjust") {
@@ -419,6 +640,8 @@ function applyStepUniforms(
gl.uniform1f(imageWidthLocation, request.width); gl.uniform1f(imageWidthLocation, request.width);
} }
} }
return disposableTextures;
} }
function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void { function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void {
@@ -512,7 +735,7 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest)
gl.uniform1i(sourceLocation, 0); gl.uniform1i(sourceLocation, 0);
} }
applyStepUniforms(gl, shaderProgram, request); const disposableTextures = applyStepUniforms(gl, shaderProgram, request);
gl.viewport(0, 0, request.width, request.height); gl.viewport(0, 0, request.width, request.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
@@ -527,14 +750,9 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest)
gl.deleteFramebuffer(framebuffer); gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(sourceTexture); gl.deleteTexture(sourceTexture);
gl.deleteTexture(outputTexture); gl.deleteTexture(outputTexture);
for (const texture of disposableTextures) {
logWebglBackendDebug("step-complete", { gl.deleteTexture(texture);
stepType: request.step.type, }
width: request.width,
height: request.height,
totalDurationMs: performance.now() - startedAtMs,
readbackDurationMs,
});
} }
export function isWebglPreviewStepSupported(step: PipelineStep): boolean { export function isWebglPreviewStepSupported(step: PipelineStep): boolean {

View File

@@ -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<typeof useCanvasDeleteHandlers> | 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<typeof vi.fn>;
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
};
function HookHarness(props: HarnessProps) {
const deletingNodeIds = useRef(new Set<string>());
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
const handlers = useCanvasDeleteHandlers({
t: ((key: string, values?: Record<string, unknown>) =>
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<void>((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,
});
});
});

View File

@@ -85,14 +85,14 @@ export const parityTolerances: Record<ParityPipelineKey, ParityTolerance> = {
spatialRmse: 52.5, spatialRmse: 52.5,
}, },
colorAdjustPressure: { colorAdjustPressure: {
maxChannelDelta: 203, maxChannelDelta: 64,
histogramSimilarity: 0.17, histogramSimilarity: 0.5,
spatialRmse: 75.8, spatialRmse: 24,
}, },
curvesColorPressureChain: { curvesColorPressureChain: {
maxChannelDelta: 203, maxChannelDelta: 96,
histogramSimilarity: 0.18, histogramSimilarity: 0.35,
spatialRmse: 75.5, spatialRmse: 36,
}, },
}; };
@@ -402,10 +402,10 @@ function createEmptyTexture(width: number, height: number): FakeTexture {
} }
function inferShaderKind(source: string): ShaderKind { function inferShaderKind(source: string): ShaderKind {
if (source.includes("uGamma")) { if (source.includes("uInvGamma") || source.includes("uRgbLut")) {
return "curves"; return "curves";
} }
if (source.includes("uColorShift")) { if (source.includes("uHueShift") || source.includes("uVibranceBoost")) {
return "color-adjust"; return "color-adjust";
} }
if (source.includes("uExposureFactor")) { if (source.includes("uExposureFactor")) {
@@ -428,13 +428,65 @@ function toByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value * 255))); 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<string, number | [number, number, number]>,
textures: {
rgb: FakeTexture | null;
red: FakeTexture | null;
green: FakeTexture | null;
blue: FakeTexture | null;
},
): Uint8Array {
const output = new Uint8Array(input.length); 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) { 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 mappedRed = Math.pow(
const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001)); Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0),
const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001)); 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] = toByte(red);
output[index + 1] = toByte(green); output[index + 1] = toByte(green);
@@ -445,13 +497,98 @@ function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
return output; 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<string, number | [number, number, number]>,
): Uint8Array {
const output = new Uint8Array(input.length); 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) { for (let index = 0; index < input.length; index += 4) {
const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0])); const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0);
const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1])); const shiftedHue = (hsl.h + hueShift + 360) % 360;
const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2])); 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] = toByte(red);
output[index + 1] = toByte(green); output[index + 1] = toByte(green);
@@ -629,7 +766,8 @@ function createParityWebglContext(): WebGLRenderingContext {
let currentProgram: FakeProgram | null = null; let currentProgram: FakeProgram | null = null;
let currentTexture: FakeTexture | null = null; let currentTexture: FakeTexture | null = null;
let currentFramebuffer: FakeFramebuffer | null = null; let currentFramebuffer: FakeFramebuffer | null = null;
let sourceTexture: FakeTexture | null = null; let activeTextureUnit = 0;
const boundTextures = new Map<number, FakeTexture | null>();
let drawWidth = 1; let drawWidth = 1;
let drawHeight = 1; let drawHeight = 1;
@@ -693,9 +831,7 @@ function createParityWebglContext(): WebGLRenderingContext {
}, },
bindTexture(_target: number, texture: FakeTexture | null) { bindTexture(_target: number, texture: FakeTexture | null) {
currentTexture = texture; currentTexture = texture;
if (texture) { boundTextures.set(activeTextureUnit, texture);
sourceTexture = texture;
}
}, },
texParameteri() {}, texParameteri() {},
texImage2D( texImage2D(
@@ -730,7 +866,9 @@ function createParityWebglContext(): WebGLRenderingContext {
currentTexture.height = height; currentTexture.height = height;
currentTexture.data = new Uint8Array(width * height * 4); currentTexture.data = new Uint8Array(width * height * 4);
}, },
activeTexture() {}, activeTexture(textureUnit: number) {
activeTextureUnit = textureUnit - glConstants.TEXTURE0;
},
getUniformLocation(program: FakeProgram, name: string) { getUniformLocation(program: FakeProgram, name: string) {
return { return {
program, program,
@@ -774,22 +912,30 @@ function createParityWebglContext(): WebGLRenderingContext {
drawHeight = height; drawHeight = height;
}, },
drawArrays() { drawArrays() {
const sourceTexture = boundTextures.get(0) ?? null;
if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) { if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) {
throw new Error("Parity WebGL mock is missing required render state."); throw new Error("Parity WebGL mock is missing required render state.");
} }
if (currentProgram.kind === "curves") { if (currentProgram.kind === "curves") {
const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1); const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1);
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, gamma); 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; return;
} }
if (currentProgram.kind === "color-adjust") { if (currentProgram.kind === "color-adjust") {
const colorShift = currentProgram.uniforms.get("uColorShift"); currentFramebuffer.attachment.data = runColorAdjustShader(
const shift: [number, number, number] = Array.isArray(colorShift) sourceTexture.data,
? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0] currentProgram.uniforms,
: [0, 0, 0]; );
currentFramebuffer.attachment.data = runColorAdjustShader(sourceTexture.data, shift);
return; return;
} }
@@ -828,7 +974,7 @@ function createParityWebglContext(): WebGLRenderingContext {
throw new Error("Parity WebGL mock has no framebuffer attachment to read from."); 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));
}, },
}; };

View File

@@ -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 { function createColorAdjustStep(): PipelineStep {
return { return {
nodeId: "color-1", 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 { function createUnsupportedStep(): PipelineStep {
return { return {
nodeId: "light-1", nodeId: "light-1",
@@ -158,7 +210,7 @@ describe("webgl backend poc", () => {
texParameteri: vi.fn(), texParameteri: vi.fn(),
texImage2D: vi.fn(), texImage2D: vi.fn(),
activeTexture: vi.fn(), activeTexture: vi.fn(),
getUniformLocation: vi.fn(() => ({ uniform: true })), getUniformLocation: vi.fn((_program: unknown, name: string) => ({ uniform: true, name })),
uniform1i: vi.fn(), uniform1i: vi.fn(),
uniform1f: vi.fn(), uniform1f: vi.fn(),
uniform3f: vi.fn(), uniform3f: vi.fn(),
@@ -405,7 +457,7 @@ describe("webgl backend poc", () => {
.at(-1)?.index; .at(-1)?.index;
expect(lastBindBeforeDrawIndex).toBeTypeOf("number"); 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); expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture);
}); });
@@ -464,6 +516,90 @@ describe("webgl backend poc", () => {
expect(fakeGl.uniform1f).toHaveBeenCalledWith(expect.anything(), 7); 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 () => { it("downgrades compile/link failures to cpu with runtime_error reason", async () => {
const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router"); const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend"); const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");

View File

@@ -4,6 +4,7 @@ import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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"; import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types";
type ParameterSliderProps = { type ParameterSliderProps = {
@@ -110,23 +111,30 @@ describe("LightAdjustNode", () => {
const renderNode = (data: LightAdjustData) => const renderNode = (data: LightAdjustData) =>
root?.render( root?.render(
createElement(LightAdjustNode, { createElement(
id: "light-1", CanvasGraphProvider as never,
data, {
selected: false, nodes: [{ id: "light-1", type: "light-adjust", data }],
dragging: false, edges: [],
zIndex: 0, } as never,
isConnectable: true, createElement(LightAdjustNode, {
type: "light-adjust", id: "light-1",
xPos: 0, data,
yPos: 0, selected: false,
width: 320, dragging: false,
height: 300, zIndex: 0,
sourcePosition: undefined, isConnectable: true,
targetPosition: undefined, type: "light-adjust",
positionAbsoluteX: 0, xPos: 0,
positionAbsoluteY: 0, yPos: 0,
} as never), width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
),
); );
await act(async () => { await act(async () => {
@@ -183,4 +191,64 @@ describe("LightAdjustNode", () => {
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value, parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
).toBe(60); ).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",
),
);
});
}); });

View File

@@ -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<AdjustmentData>({
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");
});
});

View File

@@ -67,16 +67,19 @@ function PreviewHarness({
sourceUrl, sourceUrl,
steps, steps,
includeHistogram, includeHistogram,
debounceMs,
}: { }: {
sourceUrl: string | null; sourceUrl: string | null;
steps: PipelineStep[]; steps: PipelineStep[];
includeHistogram?: boolean; includeHistogram?: boolean;
debounceMs?: number;
}) { }) {
const { canvasRef, histogram, error, isRendering } = usePipelinePreview({ const { canvasRef, histogram, error, isRendering } = usePipelinePreview({
sourceUrl, sourceUrl,
steps, steps,
nodeWidth: 320, nodeWidth: 320,
includeHistogram, includeHistogram,
debounceMs,
}); });
useEffect(() => { 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", () => { describe("preview histogram call sites", () => {
@@ -475,7 +505,7 @@ describe("preview histogram call sites", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("keeps histogram enabled for AdjustmentPreview", async () => { it("disables histogram for fast-path AdjustmentPreview", async () => {
const hookSpy = vi.fn(() => ({ const hookSpy = vi.fn(() => ({
canvasRef: { current: null }, canvasRef: { current: null },
histogram: emptyHistogram(), histogram: emptyHistogram(),
@@ -489,11 +519,78 @@ describe("preview histogram call sites", () => {
usePipelinePreview: hookSpy, usePipelinePreview: hookSpy,
})); }));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({ 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", () => ({ vi.doMock("@/lib/canvas-render-preview", () => ({
collectPipelineFromGraph: () => [], collectPipelineFromGraph: () => [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 10 },
},
],
getSourceImageFromGraph: () => "https://cdn.example.com/source.png", getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
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<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
})); }));
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview"); const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
@@ -513,11 +610,72 @@ describe("preview histogram call sites", () => {
expect(hookSpy).toHaveBeenCalledWith( expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
includeHistogram: true, 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<string, unknown>) =>
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(() => ({ const hookSpy = vi.fn(() => ({
canvasRef: { current: null }, canvasRef: { current: null },
histogram: emptyHistogram(), histogram: emptyHistogram(),
@@ -570,14 +728,29 @@ describe("preview histogram call sites", () => {
useDebouncedCallback: (callback: () => void) => callback, useDebouncedCallback: (callback: () => void) => callback,
})); }));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({ 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", () => ({ vi.doMock("@/lib/canvas-render-preview", () => ({
resolveRenderPreviewInputFromGraph: () => ({ resolveRenderPreviewInputFromGraph: () => ({
sourceUrl: "https://cdn.example.com/source.png", sourceUrl: "https://cdn.example.com/source.png",
steps: [], steps: [
{
nodeId: "render-1-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
}), }),
findSourceNodeFromGraph: () => null, findSourceNodeFromGraph: () => null,
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
})); }));
vi.doMock("@/lib/canvas-utils", () => ({ vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null, resolveMediaAspectRatio: () => null,
@@ -616,7 +789,13 @@ describe("preview histogram call sites", () => {
nodeWidth: 320, nodeWidth: 320,
previewInput: { previewInput: {
sourceUrl: "https://cdn.example.com/source.png", sourceUrl: "https://cdn.example.com/source.png",
steps: [], steps: [
{
nodeId: "compare-step",
type: "light-adjust",
params: { brightness: 20 },
},
],
}, },
preferPreview: true, preferPreview: true,
}), }),
@@ -641,16 +820,229 @@ describe("preview histogram call sites", () => {
); );
}); });
expect(hookSpy).toHaveBeenCalledWith( expect(hookSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ expect.objectContaining({
includeHistogram: false, includeHistogram: false,
sourceUrl: "https://cdn.example.com/source.png", 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({ expect.objectContaining({
includeHistogram: false, includeHistogram: false,
sourceUrl: null, 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<string, unknown>) =>
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<typeof import("@/lib/image-pipeline/contracts")>(
"@/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,
}), }),
); );
}); });

View File

@@ -19,6 +19,7 @@ export default defineConfig({
"components/canvas/__tests__/use-canvas-drop.test.tsx", "components/canvas/__tests__/use-canvas-drop.test.tsx",
"components/canvas/__tests__/use-canvas-connections.test.tsx", "components/canvas/__tests__/use-canvas-connections.test.tsx",
"components/canvas/__tests__/use-canvas-node-interactions.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.test.ts",
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
], ],