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

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

View File

@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import React, { act, useEffect, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasDeleteHandlers } from "@/components/canvas/canvas-delete-handlers";
vi.mock("@/lib/toast", () => ({
toast: {
info: vi.fn(),
warning: vi.fn(),
},
}));
const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">;
const latestHandlersRef: {
current: ReturnType<typeof useCanvasDeleteHandlers> | null;
} = { current: null };
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HarnessProps = {
nodes: RFNode[];
edges: RFEdge[];
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
};
function HookHarness(props: HarnessProps) {
const deletingNodeIds = useRef(new Set<string>());
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
const handlers = useCanvasDeleteHandlers({
t: ((key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key) as never,
canvasId: asCanvasId("canvas-1"),
nodes: props.nodes,
edges: props.edges,
deletingNodeIds,
setAssetBrowserTargetNodeId,
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
runCreateEdgeMutation: props.runCreateEdgeMutation,
runRemoveEdgeMutation: vi.fn(async () => undefined),
});
useEffect(() => {
latestHandlersRef.current = handlers;
}, [handlers]);
return null;
}
describe("useCanvasDeleteHandlers", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
latestHandlersRef.current = null;
vi.clearAllMocks();
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
});
it("creates bridge edges only after batch node removal resolves", async () => {
let resolveBatchRemove: (() => void) | null = null;
const runBatchRemoveNodesMutation = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveBatchRemove = resolve;
}),
);
const runCreateEdgeMutation = vi.fn(async () => undefined);
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
const deletedNode: RFNode = {
id: "node-color",
type: "color-adjust",
position: { x: 200, y: 0 },
data: {},
};
const renderNode: RFNode = { id: "node-render", type: "render", position: { x: 400, y: 0 }, data: {} };
const edges: RFEdge[] = [
{ id: "edge-in", source: "node-image", target: "node-color" },
{ id: "edge-out", source: "node-color", target: "node-render" },
];
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(HookHarness, {
nodes: [imageNode, deletedNode, renderNode],
edges,
runBatchRemoveNodesMutation,
runCreateEdgeMutation,
}),
);
});
await act(async () => {
latestHandlersRef.current?.onNodesDelete([deletedNode]);
});
expect(runBatchRemoveNodesMutation).toHaveBeenCalledWith({
nodeIds: ["node-color"],
});
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
await act(async () => {
resolveBatchRemove?.();
await Promise.resolve();
});
expect(runCreateEdgeMutation).toHaveBeenCalledWith({
canvasId: "canvas-1",
sourceNodeId: "node-image",
targetNodeId: "node-render",
sourceHandle: undefined,
targetHandle: undefined,
});
});
});

View File

@@ -85,14 +85,14 @@ export const parityTolerances: Record<ParityPipelineKey, ParityTolerance> = {
spatialRmse: 52.5,
},
colorAdjustPressure: {
maxChannelDelta: 203,
histogramSimilarity: 0.17,
spatialRmse: 75.8,
maxChannelDelta: 64,
histogramSimilarity: 0.5,
spatialRmse: 24,
},
curvesColorPressureChain: {
maxChannelDelta: 203,
histogramSimilarity: 0.18,
spatialRmse: 75.5,
maxChannelDelta: 96,
histogramSimilarity: 0.35,
spatialRmse: 36,
},
};
@@ -402,10 +402,10 @@ function createEmptyTexture(width: number, height: number): FakeTexture {
}
function inferShaderKind(source: string): ShaderKind {
if (source.includes("uGamma")) {
if (source.includes("uInvGamma") || source.includes("uRgbLut")) {
return "curves";
}
if (source.includes("uColorShift")) {
if (source.includes("uHueShift") || source.includes("uVibranceBoost")) {
return "color-adjust";
}
if (source.includes("uExposureFactor")) {
@@ -428,13 +428,65 @@ function toByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value * 255)));
}
function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
function sampleLutTexture(texture: FakeTexture | null, value: number): number {
if (!texture) {
return value;
}
const index = Math.max(0, Math.min(255, Math.round(value * 255)));
return texture.data[index * 4] / 255;
}
function runCurvesShader(
input: Uint8Array,
uniforms: Map<string, number | [number, number, number]>,
textures: {
rgb: FakeTexture | null;
red: FakeTexture | null;
green: FakeTexture | null;
blue: FakeTexture | null;
},
): Uint8Array {
const output = new Uint8Array(input.length);
const blackPoint = Number(uniforms.get("uBlackPoint") ?? 0);
const whitePoint = Number(uniforms.get("uWhitePoint") ?? 255);
const invGamma = Number(uniforms.get("uInvGamma") ?? 1);
const channelMode = Number(uniforms.get("uChannelMode") ?? 0);
const levelRange = Math.max(1, whitePoint - blackPoint);
for (let index = 0; index < input.length; index += 4) {
const red = Math.pow(Math.max(toNormalized(input[index]), 0), Math.max(gamma, 0.001));
const green = Math.pow(Math.max(toNormalized(input[index + 1]), 0), Math.max(gamma, 0.001));
const blue = Math.pow(Math.max(toNormalized(input[index + 2]), 0), Math.max(gamma, 0.001));
const mappedRed = Math.pow(
Math.max(Math.min(((input[index] - blackPoint) / levelRange), 1), 0),
Math.max(invGamma, 0.001),
);
const mappedGreen = Math.pow(
Math.max(Math.min(((input[index + 1] - blackPoint) / levelRange), 1), 0),
Math.max(invGamma, 0.001),
);
const mappedBlue = Math.pow(
Math.max(Math.min(((input[index + 2] - blackPoint) / levelRange), 1), 0),
Math.max(invGamma, 0.001),
);
const rgbRed = sampleLutTexture(textures.rgb, mappedRed);
const rgbGreen = sampleLutTexture(textures.rgb, mappedGreen);
const rgbBlue = sampleLutTexture(textures.rgb, mappedBlue);
let red = rgbRed;
let green = rgbGreen;
let blue = rgbBlue;
if (channelMode < 0.5) {
red = sampleLutTexture(textures.red, rgbRed);
green = sampleLutTexture(textures.green, rgbGreen);
blue = sampleLutTexture(textures.blue, rgbBlue);
} else if (channelMode < 1.5) {
red = sampleLutTexture(textures.red, rgbRed);
} else if (channelMode < 2.5) {
green = sampleLutTexture(textures.green, rgbGreen);
} else {
blue = sampleLutTexture(textures.blue, rgbBlue);
}
output[index] = toByte(red);
output[index + 1] = toByte(green);
@@ -445,13 +497,98 @@ function runCurvesShader(input: Uint8Array, gamma: number): Uint8Array {
return output;
}
function runColorAdjustShader(input: Uint8Array, shift: [number, number, number]): Uint8Array {
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h = 0;
if (max === rn) {
h = ((gn - bn) / delta) % 6;
} else if (max === gn) {
h = (bn - rn) / delta + 2;
} else {
h = (rn - gn) / delta + 4;
}
h *= 60;
if (h < 0) {
h += 360;
}
return { h, s, l };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let rp = 0;
let gp = 0;
let bp = 0;
if (h < 60) {
rp = c;
gp = x;
} else if (h < 120) {
rp = x;
gp = c;
} else if (h < 180) {
gp = c;
bp = x;
} else if (h < 240) {
gp = x;
bp = c;
} else if (h < 300) {
rp = x;
bp = c;
} else {
rp = c;
bp = x;
}
return {
r: Math.max(0, Math.min(255, Math.round((rp + m) * 255))),
g: Math.max(0, Math.min(255, Math.round((gp + m) * 255))),
b: Math.max(0, Math.min(255, Math.round((bp + m) * 255))),
};
}
function runColorAdjustShader(
input: Uint8Array,
uniforms: Map<string, number | [number, number, number]>,
): Uint8Array {
const output = new Uint8Array(input.length);
const hueShift = Number(uniforms.get("uHueShift") ?? 0);
const saturationFactor = Number(uniforms.get("uSaturationFactor") ?? 1);
const luminanceShift = Number(uniforms.get("uLuminanceShift") ?? 0);
const temperatureShift = Number(uniforms.get("uTemperatureShift") ?? 0);
const tintShift = Number(uniforms.get("uTintShift") ?? 0);
const vibranceBoost = Number(uniforms.get("uVibranceBoost") ?? 0);
for (let index = 0; index < input.length; index += 4) {
const red = Math.max(0, Math.min(1, toNormalized(input[index]) + shift[0]));
const green = Math.max(0, Math.min(1, toNormalized(input[index + 1]) + shift[1]));
const blue = Math.max(0, Math.min(1, toNormalized(input[index + 2]) + shift[2]));
const hsl = rgbToHsl(input[index] ?? 0, input[index + 1] ?? 0, input[index + 2] ?? 0);
const shiftedHue = (hsl.h + hueShift + 360) % 360;
const shiftedSaturation = Math.max(0, Math.min(1, hsl.s * saturationFactor));
const shiftedLuminance = Math.max(0, Math.min(1, hsl.l + luminanceShift));
const saturationDelta = (1 - hsl.s) * vibranceBoost;
const vivid = hslToRgb(
shiftedHue,
Math.max(0, Math.min(1, shiftedSaturation + saturationDelta)),
shiftedLuminance,
);
const red = Math.max(0, Math.min(1, (vivid.r + temperatureShift) / 255));
const green = Math.max(0, Math.min(1, (vivid.g + tintShift) / 255));
const blue = Math.max(0, Math.min(1, (vivid.b - temperatureShift - tintShift * 0.3) / 255));
output[index] = toByte(red);
output[index + 1] = toByte(green);
@@ -629,7 +766,8 @@ function createParityWebglContext(): WebGLRenderingContext {
let currentProgram: FakeProgram | null = null;
let currentTexture: FakeTexture | null = null;
let currentFramebuffer: FakeFramebuffer | null = null;
let sourceTexture: FakeTexture | null = null;
let activeTextureUnit = 0;
const boundTextures = new Map<number, FakeTexture | null>();
let drawWidth = 1;
let drawHeight = 1;
@@ -693,9 +831,7 @@ function createParityWebglContext(): WebGLRenderingContext {
},
bindTexture(_target: number, texture: FakeTexture | null) {
currentTexture = texture;
if (texture) {
sourceTexture = texture;
}
boundTextures.set(activeTextureUnit, texture);
},
texParameteri() {},
texImage2D(
@@ -730,7 +866,9 @@ function createParityWebglContext(): WebGLRenderingContext {
currentTexture.height = height;
currentTexture.data = new Uint8Array(width * height * 4);
},
activeTexture() {},
activeTexture(textureUnit: number) {
activeTextureUnit = textureUnit - glConstants.TEXTURE0;
},
getUniformLocation(program: FakeProgram, name: string) {
return {
program,
@@ -774,22 +912,30 @@ function createParityWebglContext(): WebGLRenderingContext {
drawHeight = height;
},
drawArrays() {
const sourceTexture = boundTextures.get(0) ?? null;
if (!currentProgram || !sourceTexture || !currentFramebuffer?.attachment) {
throw new Error("Parity WebGL mock is missing required render state.");
}
if (currentProgram.kind === "curves") {
const gamma = Number(currentProgram.uniforms.get("uGamma") ?? 1);
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, gamma);
const rgbUnit = Number(currentProgram.uniforms.get("uRgbLut") ?? 1);
const redUnit = Number(currentProgram.uniforms.get("uRedLut") ?? 2);
const greenUnit = Number(currentProgram.uniforms.get("uGreenLut") ?? 3);
const blueUnit = Number(currentProgram.uniforms.get("uBlueLut") ?? 4);
currentFramebuffer.attachment.data = runCurvesShader(sourceTexture.data, currentProgram.uniforms, {
rgb: boundTextures.get(rgbUnit) ?? null,
red: boundTextures.get(redUnit) ?? null,
green: boundTextures.get(greenUnit) ?? null,
blue: boundTextures.get(blueUnit) ?? null,
});
return;
}
if (currentProgram.kind === "color-adjust") {
const colorShift = currentProgram.uniforms.get("uColorShift");
const shift: [number, number, number] = Array.isArray(colorShift)
? [colorShift[0] ?? 0, colorShift[1] ?? 0, colorShift[2] ?? 0]
: [0, 0, 0];
currentFramebuffer.attachment.data = runColorAdjustShader(sourceTexture.data, shift);
currentFramebuffer.attachment.data = runColorAdjustShader(
sourceTexture.data,
currentProgram.uniforms,
);
return;
}
@@ -828,7 +974,7 @@ function createParityWebglContext(): WebGLRenderingContext {
throw new Error("Parity WebGL mock has no framebuffer attachment to read from.");
}
output.set(currentFramebuffer.attachment.data);
output.set(currentFramebuffer.attachment.data.subarray(0, output.length));
},
};

View File

@@ -38,6 +38,41 @@ function createCurvesStep(): PipelineStep {
};
}
function createCurvesPressureStep(): PipelineStep {
return {
nodeId: "curves-pressure-1",
type: "curves",
params: {
channelMode: "master",
levels: {
blackPoint: 12,
whitePoint: 232,
gamma: 2.5,
},
points: {
rgb: [
{ x: 0, y: 0 },
{ x: 64, y: 52 },
{ x: 196, y: 228 },
{ x: 255, y: 255 },
],
red: [
{ x: 0, y: 0 },
{ x: 255, y: 255 },
],
green: [
{ x: 0, y: 0 },
{ x: 255, y: 255 },
],
blue: [
{ x: 0, y: 0 },
{ x: 255, y: 255 },
],
},
},
};
}
function createColorAdjustStep(): PipelineStep {
return {
nodeId: "color-1",
@@ -55,6 +90,23 @@ function createColorAdjustStep(): PipelineStep {
};
}
function createColorAdjustPressureStep(): PipelineStep {
return {
nodeId: "color-pressure-1",
type: "color-adjust",
params: {
hsl: {
hue: 48,
saturation: 64,
luminance: 18,
},
temperature: 24,
tint: -28,
vibrance: 52,
},
};
}
function createUnsupportedStep(): PipelineStep {
return {
nodeId: "light-1",
@@ -158,7 +210,7 @@ describe("webgl backend poc", () => {
texParameteri: vi.fn(),
texImage2D: vi.fn(),
activeTexture: vi.fn(),
getUniformLocation: vi.fn(() => ({ uniform: true })),
getUniformLocation: vi.fn((_program: unknown, name: string) => ({ uniform: true, name })),
uniform1i: vi.fn(),
uniform1f: vi.fn(),
uniform3f: vi.fn(),
@@ -405,7 +457,7 @@ describe("webgl backend poc", () => {
.at(-1)?.index;
expect(lastBindBeforeDrawIndex).toBeTypeOf("number");
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toBe(sourceTexture);
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).toStrictEqual(sourceTexture);
expect(bindTextureCalls[lastBindBeforeDrawIndex as number]?.[1]).not.toBe(outputTexture);
});
@@ -464,6 +516,90 @@ describe("webgl backend poc", () => {
expect(fakeGl.uniform1f).toHaveBeenCalledWith(expect.anything(), 7);
});
it("passes curves levels uniforms for non-default curves settings", async () => {
const fakeGl = createFakeWebglContext({
readbackPixels: new Uint8Array([11, 22, 33, 255]),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
if (contextId === "webgl") {
return fakeGl;
}
return null;
});
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
const backend = createWebglPreviewBackend();
backend.runPreviewStep({
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
step: createCurvesPressureStep(),
width: 1,
height: 1,
});
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
expect.objectContaining({ name: "uBlackPoint" }),
12,
);
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
expect.objectContaining({ name: "uWhitePoint" }),
232,
);
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
expect.objectContaining({ name: "uInvGamma" }),
0.4,
);
});
it("passes hue, saturation, luminance, temperature, tint, and vibrance uniforms", async () => {
const fakeGl = createFakeWebglContext({
readbackPixels: new Uint8Array([11, 22, 33, 255]),
});
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation((contextId) => {
if (contextId === "webgl") {
return fakeGl;
}
return null;
});
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");
const backend = createWebglPreviewBackend();
backend.runPreviewStep({
pixels: new Uint8ClampedArray([200, 100, 50, 255]),
step: createColorAdjustPressureStep(),
width: 1,
height: 1,
});
const uniform1fCalls = vi.mocked(fakeGl.uniform1f).mock.calls;
expect(fakeGl.uniform1f).toHaveBeenCalledWith(
expect.objectContaining({ name: "uHueShift" }),
48,
);
expect(uniform1fCalls).toContainEqual([
expect.objectContaining({ name: "uSaturationFactor" }),
expect.closeTo(1.64, 5),
]);
expect(uniform1fCalls).toContainEqual([
expect.objectContaining({ name: "uLuminanceShift" }),
expect.closeTo(0.18, 5),
]);
expect(uniform1fCalls).toContainEqual([
expect.objectContaining({ name: "uTemperatureShift" }),
expect.closeTo(14.4, 5),
]);
expect(uniform1fCalls).toContainEqual([
expect.objectContaining({ name: "uTintShift" }),
expect.closeTo(-11.2, 5),
]);
expect(uniform1fCalls).toContainEqual([
expect.objectContaining({ name: "uVibranceBoost" }),
expect.closeTo(0.52, 5),
]);
});
it("downgrades compile/link failures to cpu with runtime_error reason", async () => {
const { createBackendRouter } = await import("@/lib/image-pipeline/backend/backend-router");
const { createWebglPreviewBackend } = await import("@/lib/image-pipeline/backend/webgl/webgl-backend");

View File

@@ -4,6 +4,7 @@ import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-context";
import { DEFAULT_LIGHT_ADJUST_DATA, type LightAdjustData } from "@/lib/image-pipeline/adjustment-types";
type ParameterSliderProps = {
@@ -110,23 +111,30 @@ describe("LightAdjustNode", () => {
const renderNode = (data: LightAdjustData) =>
root?.render(
createElement(LightAdjustNode, {
id: "light-1",
data,
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "light-adjust",
xPos: 0,
yPos: 0,
width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
createElement(
CanvasGraphProvider as never,
{
nodes: [{ id: "light-1", type: "light-adjust", data }],
edges: [],
} as never,
createElement(LightAdjustNode, {
id: "light-1",
data,
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "light-adjust",
xPos: 0,
yPos: 0,
width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
),
);
await act(async () => {
@@ -183,4 +191,64 @@ describe("LightAdjustNode", () => {
parameterSliderState.latestProps?.values.find((entry) => entry.id === "brightness")?.value,
).toBe(60);
});
it("does not trigger a render-phase CanvasGraphProvider update while dragging sliders", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const staleData: LightAdjustData = {
...DEFAULT_LIGHT_ADJUST_DATA,
vignette: {
...DEFAULT_LIGHT_ADJUST_DATA.vignette,
},
};
const renderNode = (data: LightAdjustData) =>
root?.render(
createElement(
CanvasGraphProvider as never,
{
nodes: [{ id: "light-1", type: "light-adjust", data }],
edges: [],
} as never,
createElement(LightAdjustNode, {
id: "light-1",
data,
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "light-adjust",
xPos: 0,
yPos: 0,
width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
),
);
await act(async () => {
renderNode({ ...staleData, vignette: { ...staleData.vignette } });
vi.runOnlyPendingTimers();
});
const sliderProps = parameterSliderState.latestProps;
expect(sliderProps).not.toBeNull();
await act(async () => {
sliderProps?.onChange(
sliderProps.values.map((entry) =>
entry.id === "brightness" ? { ...entry, value: 35 } : entry,
),
);
});
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining(
"Cannot update a component (`CanvasGraphProvider`) while rendering a different component",
),
);
});
});

View File

@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const canvasGraphMock = vi.hoisted(() => ({
clearPreviewNodeDataOverride: vi.fn(),
setPreviewNodeDataOverride: vi.fn(),
}));
vi.mock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraphPreviewOverrides: () => canvasGraphMock,
}));
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
type AdjustmentData = {
exposure: number;
};
const latestHookRef: {
current:
| {
updateLocalData: (updater: (current: AdjustmentData) => AdjustmentData) => void;
}
| null;
} = { current: null };
function HookHarness() {
const { updateLocalData } = useNodeLocalData<AdjustmentData>({
nodeId: "node-1",
data: { exposure: 0.2 },
normalize: (value) => ({ ...(value as AdjustmentData) }),
saveDelayMs: 1000,
onSave: async () => undefined,
debugLabel: "light-adjust",
});
useEffect(() => {
latestHookRef.current = { updateLocalData };
return () => {
latestHookRef.current = null;
};
}, [updateLocalData]);
return null;
}
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useNodeLocalData ordering", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
latestHookRef.current = null;
canvasGraphMock.clearPreviewNodeDataOverride.mockReset();
canvasGraphMock.setPreviewNodeDataOverride.mockReset();
});
it("does not write preview overrides from inside the local state updater", async () => {
let overrideWriteStack = "";
canvasGraphMock.setPreviewNodeDataOverride.mockImplementation(() => {
overrideWriteStack = new Error().stack ?? "";
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness));
});
await act(async () => {
latestHookRef.current?.updateLocalData((current) => ({
...current,
exposure: 0.8,
}));
});
expect(overrideWriteStack).not.toContain("basicStateReducer");
expect(overrideWriteStack).not.toContain("updateReducerImpl");
});
});

View File

@@ -67,16 +67,19 @@ function PreviewHarness({
sourceUrl,
steps,
includeHistogram,
debounceMs,
}: {
sourceUrl: string | null;
steps: PipelineStep[];
includeHistogram?: boolean;
debounceMs?: number;
}) {
const { canvasRef, histogram, error, isRendering } = usePipelinePreview({
sourceUrl,
steps,
nodeWidth: 320,
includeHistogram,
debounceMs,
});
useEffect(() => {
@@ -453,6 +456,33 @@ describe("usePipelinePreview", () => {
}),
);
});
it("supports a faster debounce override for local preview updates", async () => {
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: createLightAdjustSteps(10),
includeHistogram: false,
debounceMs: 16,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(15);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(0);
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
});
});
describe("preview histogram call sites", () => {
@@ -475,7 +505,7 @@ describe("preview histogram call sites", () => {
vi.clearAllMocks();
});
it("keeps histogram enabled for AdjustmentPreview", async () => {
it("disables histogram for fast-path AdjustmentPreview", async () => {
const hookSpy = vi.fn(() => ({
canvasRef: { current: null },
histogram: emptyHistogram(),
@@ -489,11 +519,78 @@ describe("preview histogram call sites", () => {
usePipelinePreview: hookSpy,
}));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => ({ nodes: [], edges: [] }),
useCanvasGraph: () => ({
nodes: [],
edges: [],
previewNodeDataOverrides: new Map([["light-1", { brightness: 10 }]]),
}),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
collectPipelineFromGraph: () => [],
collectPipelineFromGraph: () => [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 10 },
},
],
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
}));
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
const AdjustmentPreview = adjustmentPreviewModule.default;
await act(async () => {
root?.render(
createElement(AdjustmentPreview, {
nodeId: "light-1",
nodeWidth: 320,
currentType: "light-adjust",
currentParams: { brightness: 10 },
}),
);
});
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: false,
debounceMs: 16,
}),
);
});
it("does not fast-path AdjustmentPreview when overrides belong to another pipeline", async () => {
const hookSpy = vi.fn(() => ({
canvasRef: { current: null },
histogram: emptyHistogram(),
isRendering: false,
hasSource: true,
previewAspectRatio: 1,
error: null,
}));
vi.doMock("@/hooks/use-pipeline-preview", () => ({
usePipelinePreview: hookSpy,
}));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => ({
nodes: [],
edges: [],
previewNodeDataOverrides: new Map([["other-node", { brightness: 10 }]]),
}),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
collectPipelineFromGraph: () => [
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 10 },
},
],
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
}));
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
@@ -513,11 +610,72 @@ describe("preview histogram call sites", () => {
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: true,
debounceMs: undefined,
}),
);
});
it("requests previews without histogram work in CompareSurface and fullscreen RenderNode", async () => {
it("keeps histogram enabled for downstream AdjustmentPreview fast path", async () => {
const hookSpy = vi.fn(() => ({
canvasRef: { current: null },
histogram: emptyHistogram(),
isRendering: false,
hasSource: true,
previewAspectRatio: 1,
error: null,
}));
vi.doMock("@/hooks/use-pipeline-preview", () => ({
usePipelinePreview: hookSpy,
}));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => ({
nodes: [],
edges: [],
previewNodeDataOverrides: new Map([["upstream-node", { brightness: 10 }]]),
}),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
collectPipelineFromGraph: () => [
{
nodeId: "upstream-node",
type: "light-adjust",
params: { brightness: 10 },
},
{
nodeId: "light-1",
type: "light-adjust",
params: { brightness: 20 },
},
],
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
}));
const adjustmentPreviewModule = await import("@/components/canvas/nodes/adjustment-preview");
const AdjustmentPreview = adjustmentPreviewModule.default;
await act(async () => {
root?.render(
createElement(AdjustmentPreview, {
nodeId: "light-1",
nodeWidth: 320,
currentType: "light-adjust",
currentParams: { brightness: 20 },
}),
);
});
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: true,
debounceMs: 16,
}),
);
});
it("requests fast preview rendering without histogram work in CompareSurface and RenderNode", async () => {
const hookSpy = vi.fn(() => ({
canvasRef: { current: null },
histogram: emptyHistogram(),
@@ -570,14 +728,29 @@ describe("preview histogram call sites", () => {
useDebouncedCallback: (callback: () => void) => callback,
}));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => ({ nodes: [], edges: [] }),
useCanvasGraph: () => ({
nodes: [],
edges: [],
previewNodeDataOverrides: new Map([
["compare-step", { brightness: 20 }],
["render-1-pipeline", { format: "png" }],
]),
}),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
resolveRenderPreviewInputFromGraph: () => ({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
steps: [
{
nodeId: "render-1-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
}),
findSourceNodeFromGraph: () => null,
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
}));
vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null,
@@ -616,7 +789,13 @@ describe("preview histogram call sites", () => {
nodeWidth: 320,
previewInput: {
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
steps: [
{
nodeId: "compare-step",
type: "light-adjust",
params: { brightness: 20 },
},
],
},
preferPreview: true,
}),
@@ -641,16 +820,229 @@ describe("preview histogram call sites", () => {
);
});
expect(hookSpy).toHaveBeenCalledWith(
expect(hookSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
includeHistogram: false,
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "compare-step",
type: "light-adjust",
params: { brightness: 20 },
},
],
debounceMs: 16,
}),
);
expect(hookSpy).toHaveBeenCalledWith(
expect(hookSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "render-1-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
debounceMs: 16,
}),
);
expect(hookSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
includeHistogram: false,
sourceUrl: null,
debounceMs: 16,
}),
);
});
it("does not fast-path CompareSurface or RenderNode for unrelated overrides", async () => {
const hookSpy = vi.fn(() => ({
canvasRef: { current: null },
histogram: emptyHistogram(),
isRendering: false,
hasSource: true,
previewAspectRatio: 1,
error: null,
}));
vi.doMock("@/hooks/use-pipeline-preview", () => ({
usePipelinePreview: hookSpy,
}));
vi.doMock("@xyflow/react", () => ({
Handle: () => null,
Position: { Left: "left", Right: "right" },
}));
vi.doMock("convex/react", () => ({
useMutation: () => vi.fn(async () => undefined),
}));
vi.doMock("lucide-react", () => ({
AlertCircle: () => null,
ArrowDown: () => null,
CheckCircle2: () => null,
CloudUpload: () => null,
Loader2: () => null,
Maximize2: () => null,
X: () => null,
}));
vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
}));
vi.doMock("@/components/canvas/nodes/adjustment-controls", () => ({
SliderRow: () => null,
}));
vi.doMock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectItem: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectTrigger: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
SelectValue: () => null,
}));
vi.doMock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: vi.fn(async () => undefined),
queueNodeResize: vi.fn(async () => undefined),
status: { isOffline: false },
}),
}));
vi.doMock("@/hooks/use-debounced-callback", () => ({
useDebouncedCallback: (callback: () => void) => callback,
}));
vi.doMock("@/components/canvas/canvas-graph-context", () => ({
useCanvasGraph: () => ({
nodes: [],
edges: [],
previewNodeDataOverrides: new Map([["unrelated-node", { format: "png" }]]),
}),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
resolveRenderPreviewInputFromGraph: ({ nodeId }: { nodeId: string }) => ({
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: `${nodeId}-pipeline`,
type: "light-adjust",
params: { brightness: 10 },
},
],
}),
findSourceNodeFromGraph: () => null,
shouldFastPathPreviewPipeline: (steps: PipelineStep[], overrides: Map<string, unknown>) =>
steps.some((step) => overrides.has(step.nodeId)),
}));
vi.doMock("@/lib/canvas-utils", () => ({
resolveMediaAspectRatio: () => null,
}));
vi.doMock("@/lib/image-formats", () => ({
parseAspectRatioString: () => ({ w: 1, h: 1 }),
}));
vi.doMock("@/lib/image-pipeline/contracts", async () => {
const actual = await vi.importActual<typeof import("@/lib/image-pipeline/contracts")>(
"@/lib/image-pipeline/contracts",
);
return {
...actual,
hashPipeline: () => "pipeline-hash",
};
});
vi.doMock("@/lib/image-pipeline/worker-client", () => ({
isPipelineAbortError: () => false,
renderFullWithWorkerFallback: vi.fn(),
}));
vi.doMock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
DialogContent: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
DialogTitle: ({ children }: { children: React.ReactNode }) => createElement("div", null, children),
}));
const compareSurfaceModule = await import("@/components/canvas/nodes/compare-surface");
const CompareSurface = compareSurfaceModule.default;
const renderNodeModule = await import("@/components/canvas/nodes/render-node");
const RenderNode = renderNodeModule.default;
await act(async () => {
root?.render(
createElement("div", null,
createElement(CompareSurface, {
nodeWidth: 320,
previewInput: {
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "compare-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
},
preferPreview: true,
}),
createElement(RenderNode, {
id: "render-1",
data: {},
selected: false,
dragging: false,
zIndex: 0,
isConnectable: true,
type: "render",
xPos: 0,
yPos: 0,
width: 320,
height: 300,
sourcePosition: undefined,
targetPosition: undefined,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
} as never),
),
);
});
expect(hookSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
includeHistogram: false,
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "compare-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
debounceMs: undefined,
}),
);
expect(hookSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sourceUrl: "https://cdn.example.com/source.png",
steps: [
{
nodeId: "render-1-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
debounceMs: undefined,
}),
);
expect(hookSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
includeHistogram: false,
sourceUrl: null,
steps: [
{
nodeId: "render-1-pipeline",
type: "light-adjust",
params: { brightness: 10 },
},
],
debounceMs: undefined,
}),
);
});