From b650485e812b12a2d6f5c90a480128c744df0319 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 11:47:04 +0200 Subject: [PATCH] fix(image-pipeline): make preview histogram opt-in --- .../canvas/nodes/adjustment-preview.tsx | 1 + components/canvas/nodes/compare-surface.tsx | 1 + components/canvas/nodes/render-node.tsx | 1 + hooks/use-pipeline-preview.ts | 4 +- lib/image-pipeline/image-pipeline.worker.ts | 2 + lib/image-pipeline/preview-renderer.ts | 7 +- lib/image-pipeline/worker-client.ts | 3 + tests/use-pipeline-preview.test.ts | 300 +++++++++++++++++- 8 files changed, 312 insertions(+), 7 deletions(-) diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx index 0b10523..786aefa 100644 --- a/components/canvas/nodes/adjustment-preview.tsx +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -111,6 +111,7 @@ export default function AdjustmentPreview({ sourceUrl, steps, nodeWidth, + includeHistogram: true, // Die Vorschau muss in-Node gut lesbar bleiben, aber nicht in voller // Display-Auflösung rechnen. previewScale: 0.5, diff --git a/components/canvas/nodes/compare-surface.tsx b/components/canvas/nodes/compare-surface.tsx index 94f25ad..15f27a0 100644 --- a/components/canvas/nodes/compare-surface.tsx +++ b/components/canvas/nodes/compare-surface.tsx @@ -31,6 +31,7 @@ export default function CompareSurface({ sourceUrl: previewSourceUrl, steps: previewSteps, nodeWidth, + includeHistogram: false, // Compare-Nodes zeigen nur eine kompakte Live-Ansicht; kleinere Kacheln // halten lange Workflows spürbar reaktionsfreudiger. previewScale: 0.5, diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 9bd9171..6e1620a 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -614,6 +614,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr sourceUrl: isFullscreenOpen && sourceUrl ? sourceUrl : null, steps, nodeWidth: fullscreenPreviewWidth, + includeHistogram: false, previewScale: 0.85, maxPreviewWidth: 1920, maxDevicePixelRatio: 1.5, diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts index d536104..3e80f4b 100644 --- a/hooks/use-pipeline-preview.ts +++ b/hooks/use-pipeline-preview.ts @@ -14,6 +14,7 @@ type UsePipelinePreviewOptions = { sourceUrl: string | null; steps: readonly PipelineStep[]; nodeWidth: number; + includeHistogram?: boolean; previewScale?: number; maxPreviewWidth?: number; maxDevicePixelRatio?: number; @@ -125,6 +126,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { sourceUrl, steps: stableRenderInputRef.current?.steps ?? [], previewWidth, + includeHistogram: options.includeHistogram, signal: abortController.signal, }) .then((result: PreviewRenderResult) => { @@ -163,7 +165,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { window.clearTimeout(timer); abortController.abort(); }; - }, [pipelineHash, previewWidth]); + }, [options.includeHistogram, pipelineHash, previewWidth]); return { canvasRef, diff --git a/lib/image-pipeline/image-pipeline.worker.ts b/lib/image-pipeline/image-pipeline.worker.ts index ff78272..c6256a1 100644 --- a/lib/image-pipeline/image-pipeline.worker.ts +++ b/lib/image-pipeline/image-pipeline.worker.ts @@ -8,6 +8,7 @@ type PreviewWorkerPayload = { sourceUrl: string; steps: readonly PipelineStep[]; previewWidth: number; + includeHistogram?: boolean; }; type WorkerRequestMessage = @@ -93,6 +94,7 @@ async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPay sourceUrl: payload.sourceUrl, steps: payload.steps, previewWidth: payload.previewWidth, + includeHistogram: payload.includeHistogram, signal: controller.signal, }); diff --git a/lib/image-pipeline/preview-renderer.ts b/lib/image-pipeline/preview-renderer.ts index ce6e504..be7bcfe 100644 --- a/lib/image-pipeline/preview-renderer.ts +++ b/lib/image-pipeline/preview-renderer.ts @@ -1,5 +1,5 @@ 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 { loadSourceBitmap } from "@/lib/image-pipeline/source-loader"; @@ -54,6 +54,7 @@ export async function renderPreview(options: { sourceUrl: string; steps: readonly PipelineStep[]; previewWidth: number; + includeHistogram?: boolean; signal?: AbortSignal; }): Promise { 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 { width, diff --git a/lib/image-pipeline/worker-client.ts b/lib/image-pipeline/worker-client.ts index 48d7d7f..70b98c9 100644 --- a/lib/image-pipeline/worker-client.ts +++ b/lib/image-pipeline/worker-client.ts @@ -13,6 +13,7 @@ type PreviewWorkerPayload = { sourceUrl: string; steps: readonly PipelineStep[]; previewWidth: number; + includeHistogram?: boolean; }; type WorkerRequestMessage = @@ -261,6 +262,7 @@ export async function renderPreviewWithWorkerFallback(options: { sourceUrl: string; steps: readonly PipelineStep[]; previewWidth: number; + includeHistogram?: boolean; signal?: AbortSignal; }): Promise { try { @@ -270,6 +272,7 @@ export async function renderPreviewWithWorkerFallback(options: { sourceUrl: options.sourceUrl, steps: options.steps, previewWidth: options.previewWidth, + includeHistogram: options.includeHistogram, }, signal: options.signal, }); diff --git a/tests/use-pipeline-preview.test.ts b/tests/use-pipeline-preview.test.ts index 780e985..c90fab8 100644 --- a/tests/use-pipeline-preview.test.ts +++ b/tests/use-pipeline-preview.test.ts @@ -18,30 +18,46 @@ vi.mock("@/lib/image-pipeline/worker-client", () => ({ 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({ sourceUrl, steps, + includeHistogram, }: { sourceUrl: string | null; steps: PipelineStep[]; + includeHistogram?: boolean; }) { - const { canvasRef } = usePipelinePreview({ + const { canvasRef, histogram, error, isRendering } = usePipelinePreview({ sourceUrl, steps, nodeWidth: 320, + includeHistogram, }); + previewHarnessState.latestHistogram = histogram; + previewHarnessState.latestError = error; + previewHarnessState.latestIsRendering = isRendering; + return createElement("canvas", { ref: canvasRef }); } (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; describe("usePipelinePreview", () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - beforeEach(() => { vi.useFakeTimers(); + previewHarnessState.latestHistogram = emptyHistogram(); + previewHarnessState.latestError = null; + previewHarnessState.latestIsRendering = false; workerClientMocks.renderPreviewWithWorkerFallback.mockReset(); workerClientMocks.renderPreviewWithWorkerFallback.mockResolvedValue({ width: 120, @@ -119,4 +135,280 @@ describe("usePipelinePreview", () => { 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( + "@/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, + }), + ); + }); });