feat(canvas): accelerate local previews and harden edge flows
This commit is contained in:
@@ -4,6 +4,7 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
|
||||
import { withResolvedCompareData } from "../canvas-helpers";
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
pruneCanvasGraphNodeDataOverrides,
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
@@ -100,6 +101,129 @@ describe("withResolvedCompareData", () => {
|
||||
});
|
||||
|
||||
describe("canvas preview graph helpers", () => {
|
||||
it("treats node data overrides as complete normalized objects when building a graph snapshot", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
data: {
|
||||
url: "https://cdn.example.com/persisted.png",
|
||||
previewUrl: "https://cdn.example.com/persisted-preview.png",
|
||||
label: "Persisted label",
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
{
|
||||
nodeDataOverrides: new Map([
|
||||
[
|
||||
"image-1",
|
||||
{
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/override-preview.png",
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(graph.nodesById.get("image-1")).toMatchObject({
|
||||
data: {
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/override-preview.png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes stale node data overrides for deleted nodes and persisted catch-up", () => {
|
||||
const overrides = pruneCanvasGraphNodeDataOverrides(
|
||||
[
|
||||
{
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
data: {
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/persisted-preview.png",
|
||||
label: "Persisted label",
|
||||
},
|
||||
},
|
||||
],
|
||||
new Map([
|
||||
[
|
||||
"image-1",
|
||||
{
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/local-preview.png",
|
||||
},
|
||||
],
|
||||
["deleted-node", { previewUrl: "https://cdn.example.com/stale-preview.png" }],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(overrides).toEqual(
|
||||
new Map([
|
||||
[
|
||||
"image-1",
|
||||
{
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/local-preview.png",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps already-pruned node data overrides stable", () => {
|
||||
const override = { previewUrl: "https://cdn.example.com/local-preview.png" };
|
||||
const overrides = new Map([["image-1", override]]);
|
||||
|
||||
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
|
||||
[
|
||||
{
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
data: {
|
||||
url: "https://cdn.example.com/persisted-source.png",
|
||||
previewUrl: "https://cdn.example.com/persisted-preview.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
overrides,
|
||||
);
|
||||
|
||||
expect(nextOverrides).toBe(overrides);
|
||||
});
|
||||
|
||||
it("keeps full nested overrides until persisted data fully catches up", () => {
|
||||
const override = {
|
||||
exposure: 0.8,
|
||||
adjustments: {
|
||||
shadows: 12,
|
||||
highlights: -4,
|
||||
},
|
||||
};
|
||||
|
||||
const nextOverrides = pruneCanvasGraphNodeDataOverrides(
|
||||
[
|
||||
{
|
||||
id: "curves-1",
|
||||
type: "curves",
|
||||
data: {
|
||||
exposure: 0.2,
|
||||
adjustments: {
|
||||
shadows: 0,
|
||||
highlights: -4,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
new Map([["curves-1", override]]),
|
||||
);
|
||||
|
||||
expect(nextOverrides).toEqual(new Map([["curves-1", override]]));
|
||||
});
|
||||
|
||||
it("resolves the upstream source and pipeline steps from a graph snapshot", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
@@ -139,4 +263,50 @@ describe("canvas preview graph helpers", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers local node data overrides during render preview resolution", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-1",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/persisted-source.png" },
|
||||
},
|
||||
{
|
||||
id: "curves-1",
|
||||
type: "curves",
|
||||
data: { exposure: 0.2 },
|
||||
},
|
||||
{
|
||||
id: "render-1",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-1", target: "curves-1" },
|
||||
{ source: "curves-1", target: "render-1" },
|
||||
],
|
||||
{
|
||||
nodeDataOverrides: new Map([
|
||||
["image-1", { url: "https://cdn.example.com/override-source.png" }],
|
||||
["curves-1", { exposure: 0.8 }],
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
const preview = resolveRenderPreviewInputFromGraph({
|
||||
nodeId: "render-1",
|
||||
graph,
|
||||
});
|
||||
|
||||
expect(preview.sourceUrl).toBe("https://cdn.example.com/override-source.png");
|
||||
expect(preview.steps).toEqual([
|
||||
{
|
||||
nodeId: "curves-1",
|
||||
type: "curves",
|
||||
params: { exposure: 0.8 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
|
||||
|
||||
type StoreState = {
|
||||
nodes: Array<{ id: string; type?: string; data?: unknown }>;
|
||||
edges: Array<{
|
||||
@@ -39,6 +41,17 @@ vi.mock("../nodes/compare-surface", () => ({
|
||||
|
||||
import CompareNode from "../nodes/compare-node";
|
||||
|
||||
function renderCompareNode(props: Record<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", () => {
|
||||
beforeEach(() => {
|
||||
storeState.nodes = [];
|
||||
@@ -69,8 +82,7 @@ describe("CompareNode render preview inputs", () => {
|
||||
},
|
||||
];
|
||||
|
||||
renderToStaticMarkup(
|
||||
React.createElement(CompareNode, {
|
||||
renderCompareNode({
|
||||
id: "compare-1",
|
||||
data: { leftUrl: "https://cdn.example.com/render-output.png" },
|
||||
selected: false,
|
||||
@@ -86,8 +98,7 @@ describe("CompareNode render preview inputs", () => {
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
|
||||
expect(compareSurfaceSpy).toHaveBeenCalled();
|
||||
const previewCall = compareSurfaceSpy.mock.calls.find(
|
||||
@@ -131,8 +142,7 @@ describe("CompareNode render preview inputs", () => {
|
||||
},
|
||||
];
|
||||
|
||||
renderToStaticMarkup(
|
||||
React.createElement(CompareNode, {
|
||||
renderCompareNode({
|
||||
id: "compare-1",
|
||||
data: { leftUrl: "https://cdn.example.com/render-output.png" },
|
||||
selected: false,
|
||||
@@ -148,8 +158,7 @@ describe("CompareNode render preview inputs", () => {
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
|
||||
expect(compareSurfaceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compareSurfaceSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||
|
||||
@@ -147,6 +147,14 @@ describe("useCanvasConnections", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 400, clientY: 260 } as MouseEvent,
|
||||
{
|
||||
@@ -192,6 +200,14 @@ describe("useCanvasConnections", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 123, clientY: 456 } as MouseEvent,
|
||||
{
|
||||
@@ -244,6 +260,14 @@ describe("useCanvasConnections", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 300, clientY: 210 } as MouseEvent,
|
||||
{
|
||||
@@ -288,6 +312,14 @@ describe("useCanvasConnections", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-source",
|
||||
handleId: "target-handle",
|
||||
handleType: "target",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 200, clientY: 200 } as MouseEvent,
|
||||
{
|
||||
@@ -313,4 +345,50 @@ describe("useCanvasConnections", () => {
|
||||
targetHandle: "target-handle",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores onConnectEnd when no connect drag is active", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
448
components/canvas/__tests__/use-node-local-data.test.tsx
Normal file
448
components/canvas/__tests__/use-node-local-data.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -122,22 +122,22 @@ export function useCanvasDeleteHandlers({
|
||||
nodes,
|
||||
edges,
|
||||
);
|
||||
const edgePromises = bridgeCreates.map((bridgeCreate) =>
|
||||
runCreateEdgeMutation({
|
||||
|
||||
void (async () => {
|
||||
await runBatchRemoveNodesMutation({
|
||||
nodeIds: idsToDelete as Id<"nodes">[],
|
||||
});
|
||||
|
||||
for (const bridgeCreate of bridgeCreates) {
|
||||
await runCreateEdgeMutation({
|
||||
canvasId,
|
||||
sourceNodeId: bridgeCreate.sourceNodeId,
|
||||
targetNodeId: bridgeCreate.targetNodeId,
|
||||
sourceHandle: bridgeCreate.sourceHandle,
|
||||
targetHandle: bridgeCreate.targetHandle,
|
||||
}),
|
||||
);
|
||||
|
||||
void Promise.all([
|
||||
runBatchRemoveNodesMutation({
|
||||
nodeIds: idsToDelete as Id<"nodes">[],
|
||||
}),
|
||||
...edgePromises,
|
||||
])
|
||||
});
|
||||
}
|
||||
})()
|
||||
.then(() => {
|
||||
// Erfolg bedeutet hier nur: Mutation/Queue wurde angenommen.
|
||||
// Den Delete-Lock erst lösen, wenn Convex-Snapshot die Node wirklich nicht mehr enthält.
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
buildGraphSnapshot,
|
||||
type CanvasGraphEdgeLike,
|
||||
type CanvasGraphNodeDataOverrides,
|
||||
type CanvasGraphNodeLike,
|
||||
type CanvasGraphSnapshot,
|
||||
pruneCanvasGraphNodeDataOverrides,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
type CanvasGraphContextValue = CanvasGraphSnapshot;
|
||||
type CanvasGraphContextValue = CanvasGraphSnapshot & {
|
||||
previewNodeDataOverrides: CanvasGraphNodeDataOverrides;
|
||||
};
|
||||
|
||||
type CanvasGraphPreviewOverridesContextValue = {
|
||||
setPreviewNodeDataOverride: (nodeId: string, data: unknown) => void;
|
||||
clearPreviewNodeDataOverride: (nodeId: string) => void;
|
||||
clearPreviewNodeDataOverrides: () => void;
|
||||
};
|
||||
|
||||
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
|
||||
const CanvasGraphPreviewOverridesContext =
|
||||
createContext<CanvasGraphPreviewOverridesContextValue | null>(null);
|
||||
|
||||
export function CanvasGraphProvider({
|
||||
nodes,
|
||||
@@ -22,9 +42,88 @@ export function CanvasGraphProvider({
|
||||
edges: readonly CanvasGraphEdgeLike[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => buildGraphSnapshot(nodes, edges), [edges, nodes]);
|
||||
const [previewNodeDataOverrides, setPreviewNodeDataOverrides] =
|
||||
useState<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 {
|
||||
@@ -35,3 +134,12 @@ export function useCanvasGraph(): CanvasGraphContextValue {
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useCanvasGraphPreviewOverrides(): CanvasGraphPreviewOverridesContextValue {
|
||||
const context = useContext(CanvasGraphPreviewOverridesContext);
|
||||
if (!context) {
|
||||
throw new Error("useCanvasGraphPreviewOverrides must be used within CanvasGraphProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
closeConnectionDropMenu,
|
||||
handleConnectionDropPick,
|
||||
onConnect,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onReconnectStart,
|
||||
onReconnect,
|
||||
@@ -520,6 +521,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
|
||||
@@ -1,46 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
|
||||
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||
import {
|
||||
collectPipelineFromGraph,
|
||||
getSourceImageFromGraph,
|
||||
shouldFastPathPreviewPipeline,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
|
||||
|
||||
const PREVIEW_PIPELINE_TYPES = new Set([
|
||||
"curves",
|
||||
"color-adjust",
|
||||
"light-adjust",
|
||||
"detail-adjust",
|
||||
]);
|
||||
|
||||
type PreviewLatencyTrace = {
|
||||
sequence: number;
|
||||
changedAtMs: number;
|
||||
nodeType: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
function readPreviewLatencyTrace(): PreviewLatencyTrace | null {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null;
|
||||
}
|
||||
const PREVIEW_PIPELINE_TYPES = new Set(["curves", "color-adjust", "light-adjust", "detail-adjust"]);
|
||||
|
||||
export default function AdjustmentPreview({
|
||||
nodeId,
|
||||
@@ -54,7 +26,6 @@ export default function AdjustmentPreview({
|
||||
currentParams: unknown;
|
||||
}) {
|
||||
const graph = useCanvasGraph();
|
||||
const lastLoggedTraceSequenceRef = useRef<number | null>(null);
|
||||
|
||||
const sourceUrl = useMemo(
|
||||
() =>
|
||||
@@ -93,36 +64,21 @@ export default function AdjustmentPreview({
|
||||
});
|
||||
}, [currentParams, currentType, graph, nodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const trace = readPreviewLatencyTrace();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
const usesFastPreviewDebounce = shouldFastPathPreviewPipeline(
|
||||
steps,
|
||||
graph.previewNodeDataOverrides,
|
||||
);
|
||||
const shouldDeferHistogram = graph.previewNodeDataOverrides.has(nodeId);
|
||||
|
||||
if (lastLoggedTraceSequenceRef.current === trace.sequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastLoggedTraceSequenceRef.current = trace.sequence;
|
||||
|
||||
console.info("[Preview latency] downstream-graph-visible", {
|
||||
nodeId,
|
||||
nodeType: currentType,
|
||||
sourceNodeType: trace.nodeType,
|
||||
sourceOrigin: trace.origin,
|
||||
sinceChangeMs: performance.now() - trace.changedAtMs,
|
||||
pipelineDepth: steps.length,
|
||||
stepTypes: steps.map((step) => step.type),
|
||||
hasSource: Boolean(sourceUrl),
|
||||
});
|
||||
}, [currentType, nodeId, sourceUrl, steps]);
|
||||
const previewDebounceMs = usesFastPreviewDebounce ? 16 : undefined;
|
||||
|
||||
const { canvasRef, histogram, isRendering, hasSource, previewAspectRatio, error } =
|
||||
usePipelinePreview({
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth,
|
||||
includeHistogram: true,
|
||||
includeHistogram: !shouldDeferHistogram,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller
|
||||
// Display-Auflösung rechnen.
|
||||
previewScale: 0.5,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
||||
import type { RenderPreviewInput } from "@/lib/canvas-render-preview";
|
||||
import {
|
||||
shouldFastPathPreviewPipeline,
|
||||
type RenderPreviewInput,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
|
||||
const EMPTY_STEPS: RenderPreviewInput["steps"] = [];
|
||||
|
||||
@@ -22,16 +26,24 @@ export default function CompareSurface({
|
||||
clipWidthPercent,
|
||||
preferPreview,
|
||||
}: CompareSurfaceProps) {
|
||||
const graph = useCanvasGraph();
|
||||
const usePreview = Boolean(previewInput && (preferPreview || !finalUrl));
|
||||
const previewSourceUrl = usePreview ? previewInput?.sourceUrl ?? null : null;
|
||||
const previewSteps = usePreview ? previewInput?.steps ?? EMPTY_STEPS : EMPTY_STEPS;
|
||||
const visibleFinalUrl = usePreview ? undefined : finalUrl;
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(
|
||||
previewSteps,
|
||||
graph.previewNodeDataOverrides,
|
||||
)
|
||||
? 16
|
||||
: undefined;
|
||||
|
||||
const { canvasRef, isRendering, error } = usePipelinePreview({
|
||||
sourceUrl: previewSourceUrl,
|
||||
steps: previewSteps,
|
||||
nodeWidth,
|
||||
includeHistogram: false,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
|
||||
// halten lange Workflows spürbar reaktionsfreudiger.
|
||||
previewScale: 0.5,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
|
||||
nodeId: id,
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useCanvasGraph } from "@/components/canvas/canvas-graph-context";
|
||||
import {
|
||||
findSourceNodeFromGraph,
|
||||
resolveRenderPreviewInputFromGraph,
|
||||
shouldFastPathPreviewPipeline,
|
||||
} from "@/lib/canvas-render-preview";
|
||||
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||
import { parseAspectRatioString } from "@/lib/image-formats";
|
||||
@@ -495,6 +496,12 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
);
|
||||
|
||||
const steps = renderPreviewInput.steps;
|
||||
const previewDebounceMs = shouldFastPathPreviewPipeline(
|
||||
steps,
|
||||
graph.previewNodeDataOverrides,
|
||||
)
|
||||
? 16
|
||||
: undefined;
|
||||
|
||||
const renderFingerprint = useMemo(
|
||||
() => ({
|
||||
@@ -560,6 +567,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth: previewNodeWidth,
|
||||
debounceMs: previewDebounceMs,
|
||||
// Inline-Preview: bewusst kompakt halten, damit Änderungen schneller
|
||||
// sichtbar werden, besonders in langen Graphen.
|
||||
previewScale: 0.5,
|
||||
@@ -577,6 +585,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
||||
steps,
|
||||
nodeWidth: fullscreenPreviewWidth,
|
||||
includeHistogram: false,
|
||||
debounceMs: previewDebounceMs,
|
||||
previewScale: 0.85,
|
||||
maxPreviewWidth: 1920,
|
||||
maxDevicePixelRatio: 1.5,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCanvasGraphPreviewOverrides } from "@/components/canvas/canvas-graph-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
|
||||
function hashNodeData(value: unknown): string {
|
||||
@@ -21,69 +22,67 @@ function logNodeDataDebug(event: string, payload: Record<string, unknown>): void
|
||||
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>({
|
||||
nodeId,
|
||||
data,
|
||||
normalize,
|
||||
saveDelayMs,
|
||||
onSave,
|
||||
debugLabel,
|
||||
}: {
|
||||
nodeId: string;
|
||||
data: unknown;
|
||||
normalize: (value: unknown) => T;
|
||||
saveDelayMs: number;
|
||||
onSave: (value: T) => Promise<void> | void;
|
||||
debugLabel: string;
|
||||
}) {
|
||||
const { setPreviewNodeDataOverride, clearPreviewNodeDataOverride } =
|
||||
useCanvasGraphPreviewOverrides();
|
||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||
const localDataRef = useRef(localData);
|
||||
const persistedDataRef = useRef(localData);
|
||||
const hasPendingLocalChangesRef = useRef(false);
|
||||
const localChangeVersionRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void onSave(localDataRef.current);
|
||||
const savedValue = localDataRef.current;
|
||||
const savedVersion = localChangeVersionRef.current;
|
||||
|
||||
Promise.resolve(onSave(savedValue))
|
||||
.then(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMountedRef.current || savedVersion !== localChangeVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
localDataRef.current = persistedDataRef.current;
|
||||
setLocalDataState(persistedDataRef.current);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
});
|
||||
}, saveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingData = normalize(data);
|
||||
persistedDataRef.current = incomingData;
|
||||
const incomingHash = hashNodeData(incomingData);
|
||||
const localHash = hashNodeData(localDataRef.current);
|
||||
|
||||
if (incomingHash === localHash) {
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,44 +98,46 @@ export function useNodeLocalData<T>({
|
||||
const timer = window.setTimeout(() => {
|
||||
localDataRef.current = incomingData;
|
||||
setLocalDataState(incomingData);
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data, debugLabel, normalize]);
|
||||
}, [clearPreviewNodeDataOverride, data, debugLabel, nodeId, normalize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
queueSave.cancel();
|
||||
clearPreviewNodeDataOverride(nodeId);
|
||||
};
|
||||
}, [clearPreviewNodeDataOverride, nodeId, queueSave]);
|
||||
|
||||
const applyLocalData = useCallback(
|
||||
(next: T) => {
|
||||
localChangeVersionRef.current += 1;
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
writePreviewLatencyTrace({
|
||||
changedAtMs: performance.now(),
|
||||
nodeType: debugLabel,
|
||||
origin: "applyLocalData",
|
||||
});
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
},
|
||||
[debugLabel, queueSave],
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
const updateLocalData = useCallback(
|
||||
(updater: (current: T) => T) => {
|
||||
const next = updater(localDataRef.current);
|
||||
|
||||
localChangeVersionRef.current += 1;
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
setLocalDataState((current) => {
|
||||
const next = updater(current);
|
||||
writePreviewLatencyTrace({
|
||||
changedAtMs: performance.now(),
|
||||
nodeType: debugLabel,
|
||||
origin: "updateLocalData",
|
||||
});
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
setPreviewNodeDataOverride(nodeId, next);
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[debugLabel, queueSave],
|
||||
[debugLabel, nodeId, queueSave, setPreviewNodeDataOverride],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react";
|
||||
import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd, OnConnectStart } from "@xyflow/react";
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import {
|
||||
@@ -100,14 +100,20 @@ export function useCanvasConnections({
|
||||
const [connectionDropMenu, setConnectionDropMenu] =
|
||||
useState<ConnectionDropMenuState | null>(null);
|
||||
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
|
||||
const isConnectDragActiveRef = useRef(false);
|
||||
const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
connectionDropMenuRef.current = connectionDropMenu;
|
||||
}, [connectionDropMenu]);
|
||||
|
||||
const onConnectStart = useCallback<OnConnectStart>(() => {
|
||||
isConnectDragActiveRef.current = true;
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
isConnectDragActiveRef.current = false;
|
||||
const validationError = validateCanvasConnection(connection, nodes, edges);
|
||||
if (validationError) {
|
||||
showConnectionRejectedToast(validationError);
|
||||
@@ -129,6 +135,11 @@ export function useCanvasConnections({
|
||||
|
||||
const onConnectEnd = useCallback<OnConnectEnd>(
|
||||
(event, connectionState) => {
|
||||
if (!isConnectDragActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectDragActiveRef.current = false;
|
||||
if (isReconnectDragActiveRef.current) return;
|
||||
if (connectionState.isValid === true) return;
|
||||
const fromNode = connectionState.fromNode;
|
||||
@@ -153,8 +164,8 @@ export function useCanvasConnections({
|
||||
{
|
||||
source: droppedConnection.sourceNodeId,
|
||||
target: droppedConnection.targetNodeId,
|
||||
sourceHandle: droppedConnection.sourceHandle,
|
||||
targetHandle: droppedConnection.targetHandle,
|
||||
sourceHandle: droppedConnection.sourceHandle ?? null,
|
||||
targetHandle: droppedConnection.targetHandle ?? null,
|
||||
},
|
||||
nodesRef.current,
|
||||
edgesRef.current,
|
||||
@@ -338,6 +349,7 @@ export function useCanvasConnections({
|
||||
closeConnectionDropMenu,
|
||||
handleConnectionDropPick,
|
||||
onConnect,
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
onReconnectStart,
|
||||
onReconnect,
|
||||
|
||||
371
docs/plans/2026-04-05-preview-graph-architecture.md
Normal file
371
docs/plans/2026-04-05-preview-graph-architecture.md
Normal 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`.
|
||||
@@ -1,5 +1,10 @@
|
||||
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
|
||||
* 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[]>(
|
||||
callback: (...args: Args) => void,
|
||||
delay: number,
|
||||
): (...args: Args) => void {
|
||||
): DebouncedCallback<Args> {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const callbackRef = useRef(callback);
|
||||
const argsRef = useRef<Args | null>(null);
|
||||
|
||||
// Callback-Ref aktuell halten ohne neu zu rendern
|
||||
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(
|
||||
(...args: Args) => {
|
||||
argsRef.current = args;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
timeoutRef.current = null;
|
||||
const nextArgs = argsRef.current;
|
||||
argsRef.current = null;
|
||||
if (nextArgs) {
|
||||
callbackRef.current(...nextArgs);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
return debouncedFn;
|
||||
const debouncedCallback = debouncedFn as DebouncedCallback<Args>;
|
||||
debouncedCallback.flush = flush;
|
||||
debouncedCallback.cancel = cancel;
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import {
|
||||
getLastBackendDiagnostics,
|
||||
isPipelineAbortError,
|
||||
renderPreviewWithWorkerFallback,
|
||||
type PreviewRenderResult,
|
||||
@@ -19,50 +18,11 @@ type UsePipelinePreviewOptions = {
|
||||
previewScale?: number;
|
||||
maxPreviewWidth?: number;
|
||||
maxDevicePixelRatio?: number;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const PREVIEW_RENDER_DEBOUNCE_MS = 48;
|
||||
|
||||
type PreviewLatencyTrace = {
|
||||
sequence: number;
|
||||
changedAtMs: number;
|
||||
nodeType: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
function readPreviewLatencyTrace(): PreviewLatencyTrace | null {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null;
|
||||
}
|
||||
|
||||
function logPreviewLatency(event: string, payload: Record<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(
|
||||
nodeWidth: number,
|
||||
previewScale: number,
|
||||
@@ -121,6 +81,14 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
return Math.max(1, options.maxDevicePixelRatio);
|
||||
}, [options.maxDevicePixelRatio]);
|
||||
|
||||
const debounceMs = useMemo(() => {
|
||||
if (typeof options.debounceMs !== "number" || !Number.isFinite(options.debounceMs)) {
|
||||
return PREVIEW_RENDER_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(options.debounceMs));
|
||||
}, [options.debounceMs]);
|
||||
|
||||
const previewWidth = useMemo(
|
||||
() => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio),
|
||||
[maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale],
|
||||
@@ -161,23 +129,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
const currentRun = runIdRef.current + 1;
|
||||
runIdRef.current = currentRun;
|
||||
const abortController = new AbortController();
|
||||
const effectStartedAtMs = performance.now();
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const requestStartedAtMs = performance.now();
|
||||
const trace = readPreviewLatencyTrace();
|
||||
|
||||
logPreviewLatency("request-start", {
|
||||
currentRun,
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
includeHistogram: options.includeHistogram !== false,
|
||||
debounceWaitMs: requestStartedAtMs - effectStartedAtMs,
|
||||
sinceChangeMs: trace ? requestStartedAtMs - trace.changedAtMs : null,
|
||||
sourceNodeType: trace?.nodeType ?? null,
|
||||
sourceOrigin: trace?.origin ?? null,
|
||||
});
|
||||
|
||||
setIsRendering(true);
|
||||
setError(null);
|
||||
void renderPreviewWithWorkerFallback({
|
||||
@@ -200,20 +153,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
return;
|
||||
}
|
||||
context.putImageData(result.imageData, 0, 0);
|
||||
const paintedAtMs = performance.now();
|
||||
setHistogram(result.histogram);
|
||||
setPreviewAspectRatio(result.width / result.height);
|
||||
|
||||
logPreviewLatency("paint-end", {
|
||||
currentRun,
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
imageWidth: result.width,
|
||||
imageHeight: result.height,
|
||||
requestDurationMs: paintedAtMs - requestStartedAtMs,
|
||||
sinceChangeMs: trace ? paintedAtMs - trace.changedAtMs : null,
|
||||
diagnostics: getLastBackendDiagnostics(),
|
||||
});
|
||||
})
|
||||
.catch((renderError: unknown) => {
|
||||
if (runIdRef.current !== currentRun) return;
|
||||
@@ -231,7 +172,6 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
diagnostics: getLastBackendDiagnostics(),
|
||||
error: renderError,
|
||||
});
|
||||
}
|
||||
@@ -242,13 +182,13 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
if (runIdRef.current !== currentRun) return;
|
||||
setIsRendering(false);
|
||||
});
|
||||
}, PREVIEW_RENDER_DEBOUNCE_MS);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
abortController.abort();
|
||||
};
|
||||
}, [options.includeHistogram, pipelineHash, previewWidth]);
|
||||
}, [debounceMs, options.includeHistogram, pipelineHash, previewWidth]);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
|
||||
@@ -38,6 +38,65 @@ export type CanvasGraphSnapshot = {
|
||||
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 RenderFormatOption = "png" | "jpeg" | "webp";
|
||||
|
||||
@@ -135,11 +194,17 @@ export function resolveNodeImageUrl(data: unknown): string | null {
|
||||
export function buildGraphSnapshot(
|
||||
nodes: readonly CanvasGraphNodeLike[],
|
||||
edges: readonly CanvasGraphEdgeLike[],
|
||||
includeTempEdges = false,
|
||||
options: boolean | BuildGraphSnapshotOptions = false,
|
||||
): CanvasGraphSnapshot {
|
||||
const includeTempEdges =
|
||||
typeof options === "boolean" ? options : (options.includeTempEdges ?? false);
|
||||
const nodeDataOverrides = typeof options === "boolean" ? undefined : options.nodeDataOverrides;
|
||||
const nodesById = new Map<string, CanvasGraphNodeLike>();
|
||||
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[]>();
|
||||
|
||||
@@ -65,14 +65,6 @@ function normalizeBackendHint(value: BackendHint): string | 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?: {
|
||||
backends?: readonly ImagePipelineBackend[];
|
||||
defaultBackendId?: string;
|
||||
@@ -131,12 +123,6 @@ export function createBackendRouter(options?: {
|
||||
}
|
||||
|
||||
function emitFallback(event: BackendFallbackEvent): void {
|
||||
logBackendRouterDebug("fallback", {
|
||||
reason: event.reason,
|
||||
requestedBackend: event.requestedBackend,
|
||||
fallbackBackend: event.fallbackBackend,
|
||||
errorMessage: event.error?.message,
|
||||
});
|
||||
options?.onFallback?.(event);
|
||||
}
|
||||
|
||||
@@ -365,15 +351,6 @@ export function getPreviewBackendHintForSteps(steps: readonly PreviewBackendRequ
|
||||
backendHint = CPU_BACKEND_ID;
|
||||
}
|
||||
|
||||
logBackendRouterDebug("preview-backend-hint", {
|
||||
backendHint,
|
||||
stepTypes: steps.map((step) => step.type),
|
||||
webglAvailable: rolloutState.webglAvailable,
|
||||
webglEnabled: rolloutState.webglEnabled,
|
||||
wasmAvailable: rolloutState.wasmAvailable,
|
||||
wasmEnabled: rolloutState.wasmEnabled,
|
||||
});
|
||||
|
||||
return backendHint;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ import type {
|
||||
ImagePipelineBackend,
|
||||
} from "@/lib/image-pipeline/backend/backend-types";
|
||||
import {
|
||||
normalizeColorAdjustData,
|
||||
normalizeCurvesData,
|
||||
normalizeDetailAdjustData,
|
||||
normalizeLightAdjustData,
|
||||
type CurvePoint,
|
||||
} from "@/lib/image-pipeline/adjustment-types";
|
||||
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
|
||||
@@ -14,12 +17,47 @@ precision mediump float;
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uSource;
|
||||
uniform float uGamma;
|
||||
uniform sampler2D uRgbLut;
|
||||
uniform sampler2D uRedLut;
|
||||
uniform sampler2D uGreenLut;
|
||||
uniform sampler2D uBlueLut;
|
||||
uniform float uBlackPoint;
|
||||
uniform float uWhitePoint;
|
||||
uniform float uInvGamma;
|
||||
uniform float uChannelMode;
|
||||
|
||||
float sampleLut(sampler2D lut, float value) {
|
||||
return texture2D(lut, vec2(clamp(value, 0.0, 1.0), 0.5)).r;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(uSource, vUv);
|
||||
color.rgb = pow(max(color.rgb, vec3(0.0)), vec3(max(uGamma, 0.001)));
|
||||
gl_FragColor = color;
|
||||
float levelRange = max(uWhitePoint - uBlackPoint, 1.0);
|
||||
vec3 leveled = clamp((color.rgb * 255.0 - vec3(uBlackPoint)) / levelRange, 0.0, 1.0);
|
||||
vec3 mapped = pow(max(leveled, vec3(0.0)), vec3(max(uInvGamma, 0.001)));
|
||||
|
||||
vec3 rgbCurve = vec3(
|
||||
sampleLut(uRgbLut, mapped.r),
|
||||
sampleLut(uRgbLut, mapped.g),
|
||||
sampleLut(uRgbLut, mapped.b)
|
||||
);
|
||||
|
||||
vec3 result = rgbCurve;
|
||||
if (uChannelMode < 0.5) {
|
||||
result = vec3(
|
||||
sampleLut(uRedLut, rgbCurve.r),
|
||||
sampleLut(uGreenLut, rgbCurve.g),
|
||||
sampleLut(uBlueLut, rgbCurve.b)
|
||||
);
|
||||
} else if (uChannelMode < 1.5) {
|
||||
result.r = sampleLut(uRedLut, rgbCurve.r);
|
||||
} else if (uChannelMode < 2.5) {
|
||||
result.g = sampleLut(uGreenLut, rgbCurve.g);
|
||||
} else {
|
||||
result.b = sampleLut(uBlueLut, rgbCurve.b);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(result, color.a);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -28,12 +66,84 @@ precision mediump float;
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uSource;
|
||||
uniform vec3 uColorShift;
|
||||
uniform float uHueShift;
|
||||
uniform float uSaturationFactor;
|
||||
uniform float uLuminanceShift;
|
||||
uniform float uTemperatureShift;
|
||||
uniform float uTintShift;
|
||||
uniform float uVibranceBoost;
|
||||
|
||||
vec3 rgbToHsl(vec3 color) {
|
||||
float maxChannel = max(max(color.r, color.g), color.b);
|
||||
float minChannel = min(min(color.r, color.g), color.b);
|
||||
float delta = maxChannel - minChannel;
|
||||
float lightness = (maxChannel + minChannel) * 0.5;
|
||||
|
||||
if (delta == 0.0) {
|
||||
return vec3(0.0, 0.0, lightness);
|
||||
}
|
||||
|
||||
float saturation = delta / (1.0 - abs(2.0 * lightness - 1.0));
|
||||
float hue;
|
||||
|
||||
if (maxChannel == color.r) {
|
||||
hue = mod((color.g - color.b) / delta, 6.0);
|
||||
} else if (maxChannel == color.g) {
|
||||
hue = (color.b - color.r) / delta + 2.0;
|
||||
} else {
|
||||
hue = (color.r - color.g) / delta + 4.0;
|
||||
}
|
||||
|
||||
hue *= 60.0;
|
||||
if (hue < 0.0) {
|
||||
hue += 360.0;
|
||||
}
|
||||
|
||||
return vec3(hue, saturation, lightness);
|
||||
}
|
||||
|
||||
vec3 hslToRgb(float hue, float saturation, float lightness) {
|
||||
float chroma = (1.0 - abs(2.0 * lightness - 1.0)) * saturation;
|
||||
float x = chroma * (1.0 - abs(mod(hue / 60.0, 2.0) - 1.0));
|
||||
float m = lightness - chroma * 0.5;
|
||||
vec3 rgbPrime;
|
||||
|
||||
if (hue < 60.0) {
|
||||
rgbPrime = vec3(chroma, x, 0.0);
|
||||
} else if (hue < 120.0) {
|
||||
rgbPrime = vec3(x, chroma, 0.0);
|
||||
} else if (hue < 180.0) {
|
||||
rgbPrime = vec3(0.0, chroma, x);
|
||||
} else if (hue < 240.0) {
|
||||
rgbPrime = vec3(0.0, x, chroma);
|
||||
} else if (hue < 300.0) {
|
||||
rgbPrime = vec3(x, 0.0, chroma);
|
||||
} else {
|
||||
rgbPrime = vec3(chroma, 0.0, x);
|
||||
}
|
||||
|
||||
return clamp(rgbPrime + vec3(m), 0.0, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(uSource, vUv);
|
||||
color.rgb = clamp(color.rgb + uColorShift, 0.0, 1.0);
|
||||
gl_FragColor = color;
|
||||
vec3 hsl = rgbToHsl(color.rgb);
|
||||
float shiftedHue = mod(hsl.x + uHueShift + 360.0, 360.0);
|
||||
float shiftedSaturation = clamp(hsl.y * uSaturationFactor, 0.0, 1.0);
|
||||
float shiftedLuminance = clamp(hsl.z + uLuminanceShift, 0.0, 1.0);
|
||||
float saturationDelta = (1.0 - hsl.y) * uVibranceBoost;
|
||||
vec3 vivid = hslToRgb(
|
||||
shiftedHue,
|
||||
clamp(shiftedSaturation + saturationDelta, 0.0, 1.0),
|
||||
shiftedLuminance
|
||||
);
|
||||
|
||||
vec3 shiftedBytes = vivid * 255.0;
|
||||
shiftedBytes.r += uTemperatureShift;
|
||||
shiftedBytes.g += uTintShift;
|
||||
shiftedBytes.b -= uTemperatureShift + uTintShift * 0.3;
|
||||
|
||||
gl_FragColor = vec4(clamp(shiftedBytes / 255.0, 0.0, 1.0), color.a);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -172,12 +282,77 @@ const SUPPORTED_PREVIEW_STEP_TYPES = new Set<SupportedPreviewStepType>([
|
||||
"detail-adjust",
|
||||
]);
|
||||
|
||||
function logWebglBackendDebug(event: string, payload: Record<string, unknown>): void {
|
||||
if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") {
|
||||
return;
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function toByte(value: number): number {
|
||||
return clamp(Math.round(value), 0, 255);
|
||||
}
|
||||
|
||||
function buildCurveLut(points: CurvePoint[]): Uint8Array {
|
||||
const lut = new Uint8Array(256);
|
||||
const normalized = [...points].sort((left, right) => left.x - right.x);
|
||||
|
||||
for (let input = 0; input < 256; input += 1) {
|
||||
const first = normalized[0] ?? { x: 0, y: 0 };
|
||||
const last = normalized[normalized.length - 1] ?? { x: 255, y: 255 };
|
||||
if (input <= first.x) {
|
||||
lut[input] = toByte(first.y);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.info("[image-pipeline webgl]", event, payload);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
function createLutTexture(
|
||||
gl: WebGLRenderingContext,
|
||||
lut: Uint8Array,
|
||||
textureUnit: number,
|
||||
): WebGLTexture {
|
||||
const texture = gl.createTexture();
|
||||
if (!texture) {
|
||||
throw new Error("WebGL LUT texture allocation failed.");
|
||||
}
|
||||
|
||||
const rgba = new Uint8Array(256 * 4);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
const value = lut[index] ?? 0;
|
||||
const offset = index * 4;
|
||||
rgba[offset] = value;
|
||||
rgba[offset + 1] = value;
|
||||
rgba[offset + 2] = value;
|
||||
rgba[offset + 3] = 255;
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0 + textureUnit);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
function assertSupportedStep(step: PipelineStep): void {
|
||||
@@ -279,52 +454,98 @@ function createQuadBuffer(gl: WebGLRenderingContext): WebGLBuffer {
|
||||
return quadBuffer;
|
||||
}
|
||||
|
||||
function mapCurvesGamma(step: PipelineStep): number {
|
||||
const gamma = (step.params as { levels?: { gamma?: unknown } })?.levels?.gamma;
|
||||
if (typeof gamma === "number" && Number.isFinite(gamma)) {
|
||||
return Math.max(gamma, 0.001);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function mapColorShift(step: PipelineStep): [number, number, number] {
|
||||
const params = step.params as {
|
||||
hsl?: { luminance?: unknown };
|
||||
temperature?: unknown;
|
||||
tint?: unknown;
|
||||
};
|
||||
|
||||
const luminance = typeof params?.hsl?.luminance === "number" ? params.hsl.luminance : 0;
|
||||
const temperature = typeof params?.temperature === "number" ? params.temperature : 0;
|
||||
const tint = typeof params?.tint === "number" ? params.tint : 0;
|
||||
|
||||
return [
|
||||
(luminance + temperature) / 255,
|
||||
(luminance + tint) / 255,
|
||||
(luminance - temperature) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
function applyStepUniforms(
|
||||
gl: WebGLRenderingContext,
|
||||
shaderProgram: WebGLProgram,
|
||||
request: BackendStepRequest,
|
||||
): void {
|
||||
): WebGLTexture[] {
|
||||
const disposableTextures: WebGLTexture[] = [];
|
||||
|
||||
if (request.step.type === "curves") {
|
||||
const gammaLocation = gl.getUniformLocation(shaderProgram, "uGamma");
|
||||
if (gammaLocation) {
|
||||
gl.uniform1f(gammaLocation, mapCurvesGamma(request.step));
|
||||
const curves = normalizeCurvesData(request.step.params);
|
||||
|
||||
const blackPointLocation = gl.getUniformLocation(shaderProgram, "uBlackPoint");
|
||||
if (blackPointLocation) {
|
||||
gl.uniform1f(blackPointLocation, curves.levels.blackPoint);
|
||||
}
|
||||
return;
|
||||
|
||||
const whitePointLocation = gl.getUniformLocation(shaderProgram, "uWhitePoint");
|
||||
if (whitePointLocation) {
|
||||
gl.uniform1f(whitePointLocation, curves.levels.whitePoint);
|
||||
}
|
||||
|
||||
const invGammaLocation = gl.getUniformLocation(shaderProgram, "uInvGamma");
|
||||
if (invGammaLocation) {
|
||||
gl.uniform1f(invGammaLocation, 1 / Math.max(curves.levels.gamma, 0.001));
|
||||
}
|
||||
|
||||
const channelModeLocation = gl.getUniformLocation(shaderProgram, "uChannelMode");
|
||||
if (channelModeLocation) {
|
||||
const channelMode =
|
||||
curves.channelMode === "red"
|
||||
? 1
|
||||
: curves.channelMode === "green"
|
||||
? 2
|
||||
: curves.channelMode === "blue"
|
||||
? 3
|
||||
: 0;
|
||||
gl.uniform1f(channelModeLocation, channelMode);
|
||||
}
|
||||
|
||||
const lutBindings = [
|
||||
{ uniform: "uRgbLut", unit: 1, lut: buildCurveLut(curves.points.rgb) },
|
||||
{ uniform: "uRedLut", unit: 2, lut: buildCurveLut(curves.points.red) },
|
||||
{ uniform: "uGreenLut", unit: 3, lut: buildCurveLut(curves.points.green) },
|
||||
{ uniform: "uBlueLut", unit: 4, lut: buildCurveLut(curves.points.blue) },
|
||||
] as const;
|
||||
|
||||
for (const binding of lutBindings) {
|
||||
const texture = createLutTexture(gl, binding.lut, binding.unit);
|
||||
disposableTextures.push(texture);
|
||||
const location = gl.getUniformLocation(shaderProgram, binding.uniform);
|
||||
if (location) {
|
||||
gl.uniform1i(location, binding.unit);
|
||||
}
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
return disposableTextures;
|
||||
}
|
||||
|
||||
if (request.step.type === "color-adjust") {
|
||||
const colorShiftLocation = gl.getUniformLocation(shaderProgram, "uColorShift");
|
||||
if (colorShiftLocation) {
|
||||
const [r, g, b] = mapColorShift(request.step);
|
||||
gl.uniform3f(colorShiftLocation, r, g, b);
|
||||
const color = normalizeColorAdjustData(request.step.params);
|
||||
|
||||
const hueShiftLocation = gl.getUniformLocation(shaderProgram, "uHueShift");
|
||||
if (hueShiftLocation) {
|
||||
gl.uniform1f(hueShiftLocation, color.hsl.hue);
|
||||
}
|
||||
return;
|
||||
|
||||
const saturationFactorLocation = gl.getUniformLocation(shaderProgram, "uSaturationFactor");
|
||||
if (saturationFactorLocation) {
|
||||
gl.uniform1f(saturationFactorLocation, 1 + color.hsl.saturation / 100);
|
||||
}
|
||||
|
||||
const luminanceShiftLocation = gl.getUniformLocation(shaderProgram, "uLuminanceShift");
|
||||
if (luminanceShiftLocation) {
|
||||
gl.uniform1f(luminanceShiftLocation, color.hsl.luminance / 100);
|
||||
}
|
||||
|
||||
const temperatureShiftLocation = gl.getUniformLocation(shaderProgram, "uTemperatureShift");
|
||||
if (temperatureShiftLocation) {
|
||||
gl.uniform1f(temperatureShiftLocation, color.temperature * 0.6);
|
||||
}
|
||||
|
||||
const tintShiftLocation = gl.getUniformLocation(shaderProgram, "uTintShift");
|
||||
if (tintShiftLocation) {
|
||||
gl.uniform1f(tintShiftLocation, color.tint * 0.4);
|
||||
}
|
||||
|
||||
const vibranceBoostLocation = gl.getUniformLocation(shaderProgram, "uVibranceBoost");
|
||||
if (vibranceBoostLocation) {
|
||||
gl.uniform1f(vibranceBoostLocation, color.vibrance / 100);
|
||||
}
|
||||
|
||||
return disposableTextures;
|
||||
}
|
||||
|
||||
if (request.step.type === "light-adjust") {
|
||||
@@ -378,7 +599,7 @@ function applyStepUniforms(
|
||||
if (vignetteRoundnessLocation) {
|
||||
gl.uniform1f(vignetteRoundnessLocation, light.vignette.roundness);
|
||||
}
|
||||
return;
|
||||
return disposableTextures;
|
||||
}
|
||||
|
||||
if (request.step.type === "detail-adjust") {
|
||||
@@ -419,6 +640,8 @@ function applyStepUniforms(
|
||||
gl.uniform1f(imageWidthLocation, request.width);
|
||||
}
|
||||
}
|
||||
|
||||
return disposableTextures;
|
||||
}
|
||||
|
||||
function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest): void {
|
||||
@@ -512,7 +735,7 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest)
|
||||
gl.uniform1i(sourceLocation, 0);
|
||||
}
|
||||
|
||||
applyStepUniforms(gl, shaderProgram, request);
|
||||
const disposableTextures = applyStepUniforms(gl, shaderProgram, request);
|
||||
|
||||
gl.viewport(0, 0, request.width, request.height);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
@@ -527,14 +750,9 @@ function runStepOnGpu(context: WebglBackendContext, request: BackendStepRequest)
|
||||
gl.deleteFramebuffer(framebuffer);
|
||||
gl.deleteTexture(sourceTexture);
|
||||
gl.deleteTexture(outputTexture);
|
||||
|
||||
logWebglBackendDebug("step-complete", {
|
||||
stepType: request.step.type,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
totalDurationMs: performance.now() - startedAtMs,
|
||||
readbackDurationMs,
|
||||
});
|
||||
for (const texture of disposableTextures) {
|
||||
gl.deleteTexture(texture);
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebglPreviewStepSupported(step: PipelineStep): boolean {
|
||||
|
||||
135
tests/canvas-delete-handlers.test.ts
Normal file
135
tests/canvas-delete-handlers.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,14 +85,14 @@ export const parityTolerances: Record<ParityPipelineKey, ParityTolerance> = {
|
||||
spatialRmse: 52.5,
|
||||
},
|
||||
colorAdjustPressure: {
|
||||
maxChannelDelta: 203,
|
||||
histogramSimilarity: 0.17,
|
||||
spatialRmse: 75.8,
|
||||
maxChannelDelta: 64,
|
||||
histogramSimilarity: 0.5,
|
||||
spatialRmse: 24,
|
||||
},
|
||||
curvesColorPressureChain: {
|
||||
maxChannelDelta: 203,
|
||||
histogramSimilarity: 0.18,
|
||||
spatialRmse: 75.5,
|
||||
maxChannelDelta: 96,
|
||||
histogramSimilarity: 0.35,
|
||||
spatialRmse: 36,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -402,10 +402,10 @@ function createEmptyTexture(width: number, height: number): FakeTexture {
|
||||
}
|
||||
|
||||
function inferShaderKind(source: string): ShaderKind {
|
||||
if (source.includes("uGamma")) {
|
||||
if (source.includes("uInvGamma") || source.includes("uRgbLut")) {
|
||||
return "curves";
|
||||
}
|
||||
if (source.includes("uColorShift")) {
|
||||
if (source.includes("uHueShift") || source.includes("uVibranceBoost")) {
|
||||
return "color-adjust";
|
||||
}
|
||||
if (source.includes("uExposureFactor")) {
|
||||
@@ -428,13 +428,65 @@ function toByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
|
||||
function sampleLutTexture(texture: FakeTexture | null, value: number): number {
|
||||
if (!texture) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const index = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
return texture.data[index * 4] / 255;
|
||||
}
|
||||
|
||||
function runCurvesShader(
|
||||
input: Uint8Array,
|
||||
uniforms: Map<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 blackPoint = Number(uniforms.get("uBlackPoint") ?? 0);
|
||||
const whitePoint = Number(uniforms.get("uWhitePoint") ?? 255);
|
||||
const invGamma = Number(uniforms.get("uInvGamma") ?? 1);
|
||||
const channelMode = Number(uniforms.get("uChannelMode") ?? 0);
|
||||
const levelRange = Math.max(1, whitePoint - blackPoint);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.pow(Math.max(toNormalized(input[index]), 0), Math.max(gamma, 0.001));
|
||||
const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001));
|
||||
const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001));
|
||||
const mappedRed = Math.pow(
|
||||
Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
const mappedGreen = Math.pow(
|
||||
Math.max(Math.min(((input[index + 1] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
const mappedBlue = Math.pow(
|
||||
Math.max(Math.min(((input[index + 2] - blackPoint) / levelRange), 1), 0),
|
||||
Math.max(invGamma, 0.001),
|
||||
);
|
||||
|
||||
const rgbRed = sampleLutTexture(textures.rgb, mappedRed);
|
||||
const rgbGreen = sampleLutTexture(textures.rgb, mappedGreen);
|
||||
const rgbBlue = sampleLutTexture(textures.rgb, mappedBlue);
|
||||
|
||||
let red = rgbRed;
|
||||
let green = rgbGreen;
|
||||
let blue = rgbBlue;
|
||||
|
||||
if (channelMode < 0.5) {
|
||||
red = sampleLutTexture(textures.red, rgbRed);
|
||||
green = sampleLutTexture(textures.green, rgbGreen);
|
||||
blue = sampleLutTexture(textures.blue, rgbBlue);
|
||||
} else if (channelMode < 1.5) {
|
||||
red = sampleLutTexture(textures.red, rgbRed);
|
||||
} else if (channelMode < 2.5) {
|
||||
green = sampleLutTexture(textures.green, rgbGreen);
|
||||
} else {
|
||||
blue = sampleLutTexture(textures.blue, rgbBlue);
|
||||
}
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
@@ -445,13 +497,98 @@ function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
|
||||
return output;
|
||||
}
|
||||
|
||||
function runColorAdjustShader(input: Uint8Array, shift: [number, number, number]): Uint8Array {
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const delta = max - min;
|
||||
const l = (max + min) / 2;
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1));
|
||||
let h = 0;
|
||||
if (max === rn) {
|
||||
h = ((gn - bn) / delta) % 6;
|
||||
} else if (max === gn) {
|
||||
h = (bn - rn) / delta + 2;
|
||||
} else {
|
||||
h = (rn - gn) / delta + 4;
|
||||
}
|
||||
|
||||
h *= 60;
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let rp = 0;
|
||||
let gp = 0;
|
||||
let bp = 0;
|
||||
|
||||
if (h < 60) {
|
||||
rp = c;
|
||||
gp = x;
|
||||
} else if (h < 120) {
|
||||
rp = x;
|
||||
gp = c;
|
||||
} else if (h < 180) {
|
||||
gp = c;
|
||||
bp = x;
|
||||
} else if (h < 240) {
|
||||
gp = x;
|
||||
bp = c;
|
||||
} else if (h < 300) {
|
||||
rp = x;
|
||||
bp = c;
|
||||
} else {
|
||||
rp = c;
|
||||
bp = x;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.max(0, Math.min(255, Math.round((rp + m) * 255))),
|
||||
g: Math.max(0, Math.min(255, Math.round((gp + m) * 255))),
|
||||
b: Math.max(0, Math.min(255, Math.round((bp + m) * 255))),
|
||||
};
|
||||
}
|
||||
|
||||
function runColorAdjustShader(
|
||||
input: Uint8Array,
|
||||
uniforms: Map<string, number | [number, number, number]>,
|
||||
): Uint8Array {
|
||||
const output = new Uint8Array(input.length);
|
||||
const hueShift = Number(uniforms.get("uHueShift") ?? 0);
|
||||
const saturationFactor = Number(uniforms.get("uSaturationFactor") ?? 1);
|
||||
const luminanceShift = Number(uniforms.get("uLuminanceShift") ?? 0);
|
||||
const temperatureShift = Number(uniforms.get("uTemperatureShift") ?? 0);
|
||||
const tintShift = Number(uniforms.get("uTintShift") ?? 0);
|
||||
const vibranceBoost = Number(uniforms.get("uVibranceBoost") ?? 0);
|
||||
|
||||
for (let index = 0; index < input.length; index += 4) {
|
||||
const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0]));
|
||||
const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1]));
|
||||
const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2]));
|
||||
const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0);
|
||||
const shiftedHue = (hsl.h + hueShift + 360) % 360;
|
||||
const shiftedSaturation = Math.max(0, Math.min(1, hsl.s * saturationFactor));
|
||||
const shiftedLuminance = Math.max(0, Math.min(1, hsl.l + luminanceShift));
|
||||
const saturationDelta = (1 - hsl.s) * vibranceBoost;
|
||||
const vivid = hslToRgb(
|
||||
shiftedHue,
|
||||
Math.max(0, Math.min(1, shiftedSaturation + saturationDelta)),
|
||||
shiftedLuminance,
|
||||
);
|
||||
|
||||
const red = Math.max(0, Math.min(1, (vivid.r + temperatureShift) / 255));
|
||||
const green = Math.max(0, Math.min(1, (vivid.g + tintShift) / 255));
|
||||
const blue = Math.max(0, Math.min(1, (vivid.b - temperatureShift - tintShift * 0.3) / 255));
|
||||
|
||||
output[index] = toByte(red);
|
||||
output[index + 1] = toByte(green);
|
||||
@@ -629,7 +766,8 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
let currentProgram: FakeProgram | null = null;
|
||||
let currentTexture: FakeTexture | null = null;
|
||||
let currentFramebuffer: FakeFramebuffer | null = null;
|
||||
let sourceTexture: FakeTexture | null = null;
|
||||
let activeTextureUnit = 0;
|
||||
const boundTextures = new Map<number, FakeTexture | null>();
|
||||
let drawWidth = 1;
|
||||
let drawHeight = 1;
|
||||
|
||||
@@ -693,9 +831,7 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
},
|
||||
bindTexture(_target: number, texture: FakeTexture | null) {
|
||||
currentTexture = texture;
|
||||
if (texture) {
|
||||
sourceTexture = texture;
|
||||
}
|
||||
boundTextures.set(activeTextureUnit, texture);
|
||||
},
|
||||
texParameteri() {},
|
||||
texImage2D(
|
||||
@@ -730,7 +866,9 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
currentTexture.height = height;
|
||||
currentTexture.data = new Uint8Array(width * height * 4);
|
||||
},
|
||||
activeTexture() {},
|
||||
activeTexture(textureUnit: number) {
|
||||
activeTextureUnit = textureUnit - glConstants.TEXTURE0;
|
||||
},
|
||||
getUniformLocation(program: FakeProgram, name: string) {
|
||||
return {
|
||||
program,
|
||||
@@ -774,22 +912,30 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
drawHeight = height;
|
||||
},
|
||||
drawArrays() {
|
||||
const sourceTexture = boundTextures.get(0) ?? null;
|
||||
if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) {
|
||||
throw new Error("Parity WebGL mock is missing required render state.");
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "curves") {
|
||||
const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1);
|
||||
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, gamma);
|
||||
const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1);
|
||||
const redUnit = Number(currentProgram.uniforms.get("uRedLut") ?? 2);
|
||||
const greenUnit = Number(currentProgram.uniforms.get("uGreenLut") ?? 3);
|
||||
const blueUnit = Number(currentProgram.uniforms.get("uBlueLut") ?? 4);
|
||||
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, currentProgram.uniforms, {
|
||||
rgb: boundTextures.get(rgbUnit) ?? null,
|
||||
red: boundTextures.get(redUnit) ?? null,
|
||||
green: boundTextures.get(greenUnit) ?? null,
|
||||
blue: boundTextures.get(blueUnit) ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentProgram.kind === "color-adjust") {
|
||||
const colorShift = currentProgram.uniforms.get("uColorShift");
|
||||
const shift: [number, number, number] = Array.isArray(colorShift)
|
||||
? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0]
|
||||
: [0, 0, 0];
|
||||
currentFramebuffer.attachment.data = runColorAdjustShader(sourceTexture.data, shift);
|
||||
currentFramebuffer.attachment.data = runColorAdjustShader(
|
||||
sourceTexture.data,
|
||||
currentProgram.uniforms,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -828,7 +974,7 @@ function createParityWebglContext(): WebGLRenderingContext {
|
||||
throw new Error("Parity WebGL mock has no framebuffer attachment to read from.");
|
||||
}
|
||||
|
||||
output.set(currentFramebuffer.attachment.data);
|
||||
output.set(currentFramebuffer.attachment.data.subarray(0, output.length));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,41 @@ function createCurvesStep(): PipelineStep {
|
||||
};
|
||||
}
|
||||
|
||||
function createCurvesPressureStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "curves-pressure-1",
|
||||
type: "curves",
|
||||
params: {
|
||||
channelMode: "master",
|
||||
levels: {
|
||||
blackPoint: 12,
|
||||
whitePoint: 232,
|
||||
gamma: 2.5,
|
||||
},
|
||||
points: {
|
||||
rgb: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 64, y: 52 },
|
||||
{ x: 196, y: 228 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
red: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
green: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
blue: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 255, y: 255 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createColorAdjustStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "color-1",
|
||||
@@ -55,6 +90,23 @@ function createColorAdjustStep(): PipelineStep {
|
||||
};
|
||||
}
|
||||
|
||||
function createColorAdjustPressureStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "color-pressure-1",
|
||||
type: "color-adjust",
|
||||
params: {
|
||||
hsl: {
|
||||
hue: 48,
|
||||
saturation: 64,
|
||||
luminance: 18,
|
||||
},
|
||||
temperature: 24,
|
||||
tint: -28,
|
||||
vibrance: 52,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUnsupportedStep(): PipelineStep {
|
||||
return {
|
||||
nodeId: "light-1",
|
||||
@@ -158,7 +210,7 @@ describe("webgl backend poc", () => {
|
||||
texParameteri: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
activeTexture: vi.fn(),
|
||||
getUniformLocation: vi.fn(() => ({ uniform: true })),
|
||||
getUniformLocation: vi.fn((_program: unknown, name: string) => ({ uniform: true, name })),
|
||||
uniform1i: vi.fn(),
|
||||
uniform1f: vi.fn(),
|
||||
uniform3f: vi.fn(),
|
||||
@@ -405,7 +457,7 @@ describe("webgl backend poc", () => {
|
||||
.at(-1)?.index;
|
||||
|
||||
expect(lastBindBeforeDrawIndex).toBeTypeOf("number");
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toBe(sourceTexture);
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toStrictEqual(sourceTexture);
|
||||
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture);
|
||||
});
|
||||
|
||||
@@ -464,6 +516,90 @@ describe("webgl backend poc", () => {
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(expect.anything(), 7);
|
||||
});
|
||||
|
||||
it("passes curves levels uniforms for non-default curves settings", async () => {
|
||||
const fakeGl = createFakeWebglContext({
|
||||
readbackPixels: new Uint8Array([11, 22, 33, 255]),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
|
||||
if (contextId === "webgl") {
|
||||
return fakeGl;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
const backend = createWebglPreviewBackend();
|
||||
|
||||
backend.runPreviewStep({
|
||||
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
|
||||
step: createCurvesPressureStep(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uBlackPoint" }),
|
||||
12,
|
||||
);
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uWhitePoint" }),
|
||||
232,
|
||||
);
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uInvGamma" }),
|
||||
0.4,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes hue, saturation, luminance, temperature, tint, and vibrance uniforms", async () => {
|
||||
const fakeGl = createFakeWebglContext({
|
||||
readbackPixels: new Uint8Array([11, 22, 33, 255]),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
|
||||
if (contextId === "webgl") {
|
||||
return fakeGl;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
const backend = createWebglPreviewBackend();
|
||||
|
||||
backend.runPreviewStep({
|
||||
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
|
||||
step: createColorAdjustPressureStep(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
const uniform1fCalls = vi.mocked(fakeGl.uniform1f).mock.calls;
|
||||
|
||||
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "uHueShift" }),
|
||||
48,
|
||||
);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uSaturationFactor" }),
|
||||
expect.closeTo(1.64, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uLuminanceShift" }),
|
||||
expect.closeTo(0.18, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uTemperatureShift" }),
|
||||
expect.closeTo(14.4, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uTintShift" }),
|
||||
expect.closeTo(-11.2, 5),
|
||||
]);
|
||||
expect(uniform1fCalls).toContainEqual([
|
||||
expect.objectContaining({ name: "uVibranceBoost" }),
|
||||
expect.closeTo(0.52, 5),
|
||||
]);
|
||||
});
|
||||
|
||||
it("downgrades compile/link failures to cpu with runtime_error reason", async () => {
|
||||
const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
|
||||
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
|
||||
import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types";
|
||||
|
||||
type ParameterSliderProps = {
|
||||
@@ -110,6 +111,12 @@ describe("LightAdjustNode", () => {
|
||||
|
||||
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,
|
||||
@@ -127,6 +134,7 @@ describe("LightAdjustNode", () => {
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -183,4 +191,64 @@ describe("LightAdjustNode", () => {
|
||||
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
it("does not trigger a render-phase CanvasGraphProvider update while dragging sliders", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const staleData: LightAdjustData = {
|
||||
...DEFAULT_LIGHT_ADJUST_DATA,
|
||||
vignette: {
|
||||
...DEFAULT_LIGHT_ADJUST_DATA.vignette,
|
||||
},
|
||||
};
|
||||
|
||||
const renderNode = (data: LightAdjustData) =>
|
||||
root?.render(
|
||||
createElement(
|
||||
CanvasGraphProvider as never,
|
||||
{
|
||||
nodes: [{ id: "light-1", type: "light-adjust", data }],
|
||||
edges: [],
|
||||
} as never,
|
||||
createElement(LightAdjustNode, {
|
||||
id: "light-1",
|
||||
data,
|
||||
selected: false,
|
||||
dragging: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
type: "light-adjust",
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
width: 320,
|
||||
height: 300,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
} as never),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
renderNode({ ...staleData, vignette: { ...staleData.vignette } });
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
const sliderProps = parameterSliderState.latestProps;
|
||||
expect(sliderProps).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
sliderProps?.onChange(
|
||||
sliderProps.values.map((entry) =>
|
||||
entry.id === "brightness" ? { ...entry, value: 35 } : entry,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Cannot update a component (`CanvasGraphProvider`) while rendering a different component",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
95
tests/use-node-local-data-order.test.ts
Normal file
95
tests/use-node-local-data-order.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -67,16 +67,19 @@ function PreviewHarness({
|
||||
sourceUrl,
|
||||
steps,
|
||||
includeHistogram,
|
||||
debounceMs,
|
||||
}: {
|
||||
sourceUrl: string | null;
|
||||
steps: PipelineStep[];
|
||||
includeHistogram?: boolean;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
const { canvasRef, histogram, error, isRendering } = usePipelinePreview({
|
||||
sourceUrl,
|
||||
steps,
|
||||
nodeWidth: 320,
|
||||
includeHistogram,
|
||||
debounceMs,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -453,6 +456,33 @@ describe("usePipelinePreview", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports a faster debounce override for local preview updates", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
createElement(PreviewHarness, {
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: createLightAdjustSteps(10),
|
||||
includeHistogram: false,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(0);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview histogram call sites", () => {
|
||||
@@ -475,7 +505,7 @@ describe("preview histogram call sites", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps histogram enabled for AdjustmentPreview", async () => {
|
||||
it("disables histogram for fast-path AdjustmentPreview", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
@@ -489,11 +519,78 @@ describe("preview histogram call sites", () => {
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({ nodes: [], edges: [] }),
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["light-1", { brightness: 10 }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: () => [],
|
||||
collectPipelineFromGraph: () => [
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<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");
|
||||
@@ -513,11 +610,72 @@ describe("preview histogram call sites", () => {
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeHistogram: true,
|
||||
debounceMs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests previews without histogram work in CompareSurface and fullscreen RenderNode", async () => {
|
||||
it("keeps histogram enabled for downstream AdjustmentPreview fast path", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
isRendering: false,
|
||||
hasSource: true,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.doMock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["upstream-node", { brightness: 10 }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
collectPipelineFromGraph: () => [
|
||||
{
|
||||
nodeId: "upstream-node",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
{
|
||||
nodeId: "light-1",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<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(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
@@ -570,14 +728,29 @@ describe("preview histogram call sites", () => {
|
||||
useDebouncedCallback: (callback: () => void) => callback,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({ nodes: [], edges: [] }),
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([
|
||||
["compare-step", { brightness: 20 }],
|
||||
["render-1-pipeline", { format: "png" }],
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
resolveRenderPreviewInputFromGraph: () => ({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
findSourceNodeFromGraph: () => null,
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
|
||||
steps.some((step) => overrides.has(step.nodeId)),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-utils", () => ({
|
||||
resolveMediaAspectRatio: () => null,
|
||||
@@ -616,7 +789,13 @@ describe("preview histogram call sites", () => {
|
||||
nodeWidth: 320,
|
||||
previewInput: {
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-step",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
preferPreview: true,
|
||||
}),
|
||||
@@ -641,16 +820,229 @@ describe("preview histogram call sites", () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "compare-step",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 20 },
|
||||
},
|
||||
],
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenCalledWith(
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: "render-1-pipeline",
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
expect(hookSpy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
includeHistogram: false,
|
||||
sourceUrl: null,
|
||||
debounceMs: 16,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fast-path CompareSurface or RenderNode for unrelated overrides", async () => {
|
||||
const hookSpy = vi.fn(() => ({
|
||||
canvasRef: { current: null },
|
||||
histogram: emptyHistogram(),
|
||||
isRendering: false,
|
||||
hasSource: true,
|
||||
previewAspectRatio: 1,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
vi.doMock("@/hooks/use-pipeline-preview", () => ({
|
||||
usePipelinePreview: hookSpy,
|
||||
}));
|
||||
vi.doMock("@xyflow/react", () => ({
|
||||
Handle: () => null,
|
||||
Position: { Left: "left", Right: "right" },
|
||||
}));
|
||||
vi.doMock("convex/react", () => ({
|
||||
useMutation: () => vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("lucide-react", () => ({
|
||||
AlertCircle: () => null,
|
||||
ArrowDown: () => null,
|
||||
CheckCircle2: () => null,
|
||||
CloudUpload: () => null,
|
||||
Loader2: () => null,
|
||||
Maximize2: () => null,
|
||||
X: () => null,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
}));
|
||||
vi.doMock("@/components/canvas/nodes/adjustment-controls", () => ({
|
||||
SliderRow: () => null,
|
||||
}));
|
||||
vi.doMock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
|
||||
SelectValue: () => null,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-sync-context", () => ({
|
||||
useCanvasSync: () => ({
|
||||
queueNodeDataUpdate: vi.fn(async () => undefined),
|
||||
queueNodeResize: vi.fn(async () => undefined),
|
||||
status: { isOffline: false },
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/hooks/use-debounced-callback", () => ({
|
||||
useDebouncedCallback: (callback: () => void) => callback,
|
||||
}));
|
||||
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
|
||||
useCanvasGraph: () => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
previewNodeDataOverrides: new Map([["unrelated-node", { format: "png" }]]),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/canvas-render-preview", () => ({
|
||||
resolveRenderPreviewInputFromGraph: ({ nodeId }: { nodeId: string }) => ({
|
||||
sourceUrl: "https://cdn.example.com/source.png",
|
||||
steps: [
|
||||
{
|
||||
nodeId: `${nodeId}-pipeline`,
|
||||
type: "light-adjust",
|
||||
params: { brightness: 10 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
findSourceNodeFromGraph: () => null,
|
||||
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
"components/canvas/__tests__/use-canvas-drop.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-connections.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user