fix(image-pipeline): make preview histogram opt-in

This commit is contained in:
Matthias
2026-04-04 11:47:04 +02:00
parent 4fa517066f
commit b650485e81
8 changed files with 312 additions and 7 deletions

View File

@@ -111,6 +111,7 @@ export default function AdjustmentPreview({
sourceUrl, sourceUrl,
steps, steps,
nodeWidth, nodeWidth,
includeHistogram: true,
// Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller // Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller
// Display-Auflösung rechnen. // Display-Auflösung rechnen.
previewScale: 0.5, previewScale: 0.5,

View File

@@ -31,6 +31,7 @@ export default function CompareSurface({
sourceUrl: previewSourceUrl, sourceUrl: previewSourceUrl,
steps: previewSteps, steps: previewSteps,
nodeWidth, nodeWidth,
includeHistogram: false,
// Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln
// halten lange Workflows spürbar reaktionsfreudiger. // halten lange Workflows spürbar reaktionsfreudiger.
previewScale: 0.5, previewScale: 0.5,

View File

@@ -614,6 +614,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null, sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null,
steps, steps,
nodeWidth: fullscreenPreviewWidth, nodeWidth: fullscreenPreviewWidth,
includeHistogram: false,
previewScale: 0.85, previewScale: 0.85,
maxPreviewWidth: 1920, maxPreviewWidth: 1920,
maxDevicePixelRatio: 1.5, maxDevicePixelRatio: 1.5,

View File

@@ -14,6 +14,7 @@ type UsePipelinePreviewOptions = {
sourceUrl: string | null; sourceUrl: string | null;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
nodeWidth: number; nodeWidth: number;
includeHistogram?: boolean;
previewScale?: number; previewScale?: number;
maxPreviewWidth?: number; maxPreviewWidth?: number;
maxDevicePixelRatio?: number; maxDevicePixelRatio?: number;
@@ -125,6 +126,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
sourceUrl, sourceUrl,
steps: stableRenderInputRef.current?.steps ?? [], steps: stableRenderInputRef.current?.steps ?? [],
previewWidth, previewWidth,
includeHistogram: options.includeHistogram,
signal: abortController.signal, signal: abortController.signal,
}) })
.then((result: PreviewRenderResult) => { .then((result: PreviewRenderResult) => {
@@ -163,7 +165,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
window.clearTimeout(timer); window.clearTimeout(timer);
abortController.abort(); abortController.abort();
}; };
}, [pipelineHash, previewWidth]); }, [options.includeHistogram, pipelineHash, previewWidth]);
return { return {
canvasRef, canvasRef,

View File

@@ -8,6 +8,7 @@ type PreviewWorkerPayload = {
sourceUrl: string; sourceUrl: string;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
previewWidth: number; previewWidth: number;
includeHistogram?: boolean;
}; };
type WorkerRequestMessage = type WorkerRequestMessage =
@@ -93,6 +94,7 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay
sourceUrl: payload.sourceUrl, sourceUrl: payload.sourceUrl,
steps: payload.steps, steps: payload.steps,
previewWidth: payload.previewWidth, previewWidth: payload.previewWidth,
includeHistogram: payload.includeHistogram,
signal: controller.signal, signal: controller.signal,
}); });

View File

@@ -1,5 +1,5 @@
import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { computeHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; import { computeHistogram, emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
import { applyPipelineStep } from "@/lib/image-pipeline/render-core"; import { applyPipelineStep } from "@/lib/image-pipeline/render-core";
import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader"; import { loadSourceBitmap } from "@/lib/image-pipeline/source-loader";
@@ -54,6 +54,7 @@ export async function renderPreview(options: {
sourceUrl: string; sourceUrl: string;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
previewWidth: number; previewWidth: number;
includeHistogram?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<PreviewRenderResult> { }): Promise<PreviewRenderResult> {
const bitmap = await loadSourceBitmap(options.sourceUrl, { const bitmap = await loadSourceBitmap(options.sourceUrl, {
@@ -82,7 +83,9 @@ export async function renderPreview(options: {
} }
} }
const histogram = computeHistogram(imageData.data); const histogram = options.includeHistogram === false
? emptyHistogram()
: computeHistogram(imageData.data);
return { return {
width, width,

View File

@@ -13,6 +13,7 @@ type PreviewWorkerPayload = {
sourceUrl: string; sourceUrl: string;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
previewWidth: number; previewWidth: number;
includeHistogram?: boolean;
}; };
type WorkerRequestMessage = type WorkerRequestMessage =
@@ -261,6 +262,7 @@ export async function renderPreviewWithWorkerFallback(options: {
sourceUrl: string; sourceUrl: string;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
previewWidth: number; previewWidth: number;
includeHistogram?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<PreviewRenderResult> { }): Promise<PreviewRenderResult> {
try { try {
@@ -270,6 +272,7 @@ export async function renderPreviewWithWorkerFallback(options: {
sourceUrl: options.sourceUrl, sourceUrl: options.sourceUrl,
steps: options.steps, steps: options.steps,
previewWidth: options.previewWidth, previewWidth: options.previewWidth,
includeHistogram: options.includeHistogram,
}, },
signal: options.signal, signal: options.signal,
}); });

View File

@@ -18,30 +18,46 @@ vi.mock("@/lib/image-pipeline/worker-client", () => ({
import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
const previewHarnessState = {
latestHistogram: emptyHistogram(),
latestError: null as string | null,
latestIsRendering: false,
};
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function PreviewHarness({ function PreviewHarness({
sourceUrl, sourceUrl,
steps, steps,
includeHistogram,
}: { }: {
sourceUrl: string | null; sourceUrl: string | null;
steps: PipelineStep[]; steps: PipelineStep[];
includeHistogram?: boolean;
}) { }) {
const { canvasRef } = usePipelinePreview({ const { canvasRef, histogram, error, isRendering } = usePipelinePreview({
sourceUrl, sourceUrl,
steps, steps,
nodeWidth: 320, nodeWidth: 320,
includeHistogram,
}); });
previewHarnessState.latestHistogram = histogram;
previewHarnessState.latestError = error;
previewHarnessState.latestIsRendering = isRendering;
return createElement("canvas", { ref: canvasRef }); return createElement("canvas", { ref: canvasRef });
} }
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("usePipelinePreview", () => { describe("usePipelinePreview", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
previewHarnessState.latestHistogram = emptyHistogram();
previewHarnessState.latestError = null;
previewHarnessState.latestIsRendering = false;
workerClientMocks.renderPreviewWithWorkerFallback.mockReset(); workerClientMocks.renderPreviewWithWorkerFallback.mockReset();
workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({ workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({
width: 120, width: 120,
@@ -119,4 +135,280 @@ describe("usePipelinePreview", () => {
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1); expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledTimes(1);
}); });
it("renders image output when histogram work is disabled", async () => {
const putImageData = vi.fn();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
putImageData,
} as unknown as CanvasRenderingContext2D);
workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValueOnce({
width: 64,
height: 32,
imageData: { data: new Uint8ClampedArray(64 * 32 * 4) },
histogram: emptyHistogram(),
});
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
includeHistogram: false,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(16);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: false,
}),
);
expect(putImageData).toHaveBeenCalledTimes(1);
expect(previewHarnessState.latestHistogram).toEqual(emptyHistogram());
});
it("keeps histogram data available when explicitly requested", async () => {
const histogram = {
red: Array.from({ length: 256 }, (_, index) => index),
green: Array.from({ length: 256 }, (_, index) => index + 1),
blue: Array.from({ length: 256 }, (_, index) => index + 2),
rgb: Array.from({ length: 256 }, (_, index) => index + 3),
};
workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValueOnce({
width: 64,
height: 32,
imageData: { data: new Uint8ClampedArray(64 * 32 * 4) },
histogram,
});
await act(async () => {
root?.render(
createElement(PreviewHarness, {
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
includeHistogram: true,
}),
);
});
await act(async () => {
vi.advanceTimersByTime(16);
await Promise.resolve();
});
expect(workerClientMocks.renderPreviewWithWorkerFallback).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: true,
}),
);
expect(previewHarnessState.latestHistogram).toEqual(histogram);
});
});
describe("preview histogram call sites", () => {
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
root = null;
container = null;
vi.resetModules();
vi.clearAllMocks();
});
it("keeps histogram enabled for AdjustmentPreview", 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: [] }),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
collectPipelineFromGraph: () => [],
getSourceImageFromGraph: () => "https://cdn.example.com/source.png",
}));
const module = await import("@/components/canvas/nodes/adjustment-preview");
const AdjustmentPreview = module.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: true,
}),
);
});
it("requests previews without histogram work in CompareSurface and fullscreen RenderNode", 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: [] }),
}));
vi.doMock("@/lib/canvas-render-preview", () => ({
resolveRenderPreviewInputFromGraph: () => ({
sourceUrl: "https://cdn.example.com/source.png",
steps: [],
}),
findSourceNodeFromGraph: () => null,
}));
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: [],
},
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).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: false,
sourceUrl: "https://cdn.example.com/source.png",
}),
);
expect(hookSpy).toHaveBeenCalledWith(
expect.objectContaining({
includeHistogram: false,
sourceUrl: null,
}),
);
});
}); });