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