diff --git a/components/canvas/nodes/adjustment-preview.tsx b/components/canvas/nodes/adjustment-preview.tsx index 786aefa..21b9e33 100644 --- a/components/canvas/nodes/adjustment-preview.tsx +++ b/components/canvas/nodes/adjustment-preview.tsx @@ -7,8 +7,9 @@ import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { collectPipelineFromGraph, getSourceImageFromGraph, - type PipelineStep, } from "@/lib/canvas-render-preview"; +import type { PipelineStep } from "@/lib/image-pipeline/contracts"; +import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot"; const PREVIEW_PIPELINE_TYPES = new Set([ "curves", @@ -17,45 +18,6 @@ const PREVIEW_PIPELINE_TYPES = new Set([ "detail-adjust", ]); -function compactHistogram(values: readonly number[], points = 64): number[] { - if (points <= 0) { - return []; - } - - if (values.length === 0) { - return Array.from({ length: points }, () => 0); - } - - const bucket = values.length / points; - const compacted: number[] = []; - for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { - let sum = 0; - const start = Math.floor(pointIndex * bucket); - const end = Math.min(values.length, Math.floor((pointIndex + 1) * bucket) || start + 1); - for (let index = start; index < end; index += 1) { - sum += values[index] ?? 0; - } - compacted.push(sum); - } - return compacted; -} - -function histogramPolyline(values: readonly number[], maxValue: number, width: number, height: number): string { - if (values.length === 0) { - return ""; - } - - const divisor = Math.max(1, values.length - 1); - return values - .map((value, index) => { - const x = (index / divisor) * width; - const normalized = maxValue > 0 ? value / maxValue : 0; - const y = height - normalized * height; - return `${x.toFixed(2)},${y.toFixed(2)}`; - }) - .join(" "); -} - export default function AdjustmentPreview({ nodeId, nodeWidth, @@ -119,26 +81,14 @@ export default function AdjustmentPreview({ maxDevicePixelRatio: 1.25, }); - const histogramSeries = useMemo(() => { - const red = compactHistogram(histogram.red, 64); - const green = compactHistogram(histogram.green, 64); - const blue = compactHistogram(histogram.blue, 64); - const rgb = compactHistogram(histogram.rgb, 64); - const max = Math.max(1, ...red, ...green, ...blue, ...rgb); - return { red, green, blue, rgb, max }; + const histogramPlot = useMemo(() => { + return buildHistogramPlot(histogram, { + points: 64, + width: 96, + height: 44, + }); }, [histogram.blue, histogram.green, histogram.red, histogram.rgb]); - const histogramPolylines = useMemo(() => { - const width = 96; - const height = 44; - return { - red: histogramPolyline(histogramSeries.red, histogramSeries.max, width, height), - green: histogramPolyline(histogramSeries.green, histogramSeries.max, width, height), - blue: histogramPolyline(histogramSeries.blue, histogramSeries.max, width, height), - rgb: histogramPolyline(histogramSeries.rgb, histogramSeries.max, width, height), - }; - }, [histogramSeries.blue, histogramSeries.green, histogramSeries.max, histogramSeries.red, histogramSeries.rgb]); - return (
0); - } - - const bucket = values.length / points; - const compacted: number[] = []; - for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { - let sum = 0; - const start = Math.floor(pointIndex * bucket); - const end = Math.min(values.length, Math.floor((pointIndex + 1) * bucket) || start + 1); - for (let index = start; index < end; index += 1) { - sum += values[index] ?? 0; - } - compacted.push(sum); - } - return compacted; -} - -function histogramPolyline(values: readonly number[], maxValue: number, width: number, height: number): string { - if (values.length === 0) { - return ""; - } - - const divisor = Math.max(1, values.length - 1); - return values - .map((value, index) => { - const x = (index / divisor) * width; - const normalized = maxValue > 0 ? value / maxValue : 0; - const y = height - normalized * height; - return `${x.toFixed(2)},${y.toFixed(2)}`; - }) - .join(" "); -} - async function uploadBlobToConvex(args: { uploadUrl: string; blob: Blob; @@ -682,26 +644,14 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr }); }, [hasSource, height, id, queueNodeResize, targetAspectRatio, width]); - const histogramSeries = useMemo(() => { - const red = compactHistogram(histogram.red, 64); - const green = compactHistogram(histogram.green, 64); - const blue = compactHistogram(histogram.blue, 64); - const rgb = compactHistogram(histogram.rgb, 64); - const max = Math.max(1, ...red, ...green, ...blue, ...rgb); - return { red, green, blue, rgb, max }; + const histogramPlot = useMemo(() => { + return buildHistogramPlot(histogram, { + points: 64, + width: 96, + height: 44, + }); }, [histogram.blue, histogram.green, histogram.red, histogram.rgb]); - const histogramPolylines = useMemo(() => { - const width = 96; - const height = 44; - return { - red: histogramPolyline(histogramSeries.red, histogramSeries.max, width, height), - green: histogramPolyline(histogramSeries.green, histogramSeries.max, width, height), - blue: histogramPolyline(histogramSeries.blue, histogramSeries.max, width, height), - rgb: histogramPolyline(histogramSeries.rgb, histogramSeries.max, width, height), - }; - }, [histogramSeries.blue, histogramSeries.green, histogramSeries.max, histogramSeries.red, histogramSeries.rgb]); - const canRender = hasSource && !isRendering && @@ -1267,7 +1217,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr aria-label="Histogramm als RGB-Linienkurven" > ; + +type HistogramPlotOptions = { + points?: number; + width: number; + height: number; +}; + +export type HistogramPlot = { + series: { + red: number[]; + green: number[]; + blue: number[]; + rgb: number[]; + max: number; + }; + polylines: { + red: string; + green: string; + blue: string; + rgb: string; + }; +}; + +function compactHistogram(values: readonly number[], points: number): number[] { + if (points <= 0) { + return []; + } + + if (values.length === 0) { + return Array.from({ length: points }, () => 0); + } + + const bucket = values.length / points; + const compacted: number[] = []; + for (let pointIndex = 0; pointIndex < points; pointIndex += 1) { + let sum = 0; + const start = Math.floor(pointIndex * bucket); + const end = Math.min(values.length, Math.floor((pointIndex + 1) * bucket) || start + 1); + for (let index = start; index < end; index += 1) { + sum += values[index] ?? 0; + } + compacted.push(sum); + } + return compacted; +} + +function histogramPolyline( + values: readonly number[], + maxValue: number, + width: number, + height: number, +): string { + if (values.length === 0) { + return ""; + } + + const divisor = Math.max(1, values.length - 1); + return values + .map((value, index) => { + const x = (index / divisor) * width; + const normalized = maxValue > 0 ? value / maxValue : 0; + const y = height - normalized * height; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +export function buildHistogramPlot( + histogram: HistogramChannels, + options: HistogramPlotOptions, +): HistogramPlot { + const points = options.points ?? 64; + const red = compactHistogram(histogram.red, points); + const green = compactHistogram(histogram.green, points); + const blue = compactHistogram(histogram.blue, points); + const rgb = compactHistogram(histogram.rgb, points); + const max = Math.max(1, ...red, ...green, ...blue, ...rgb); + + return { + series: { + red, + green, + blue, + rgb, + max, + }, + polylines: { + red: histogramPolyline(red, max, options.width, options.height), + green: histogramPolyline(green, max, options.width, options.height), + blue: histogramPolyline(blue, max, options.width, options.height), + rgb: histogramPolyline(rgb, max, options.width, options.height), + }, + }; +} diff --git a/lib/image-pipeline/preview-renderer.ts b/lib/image-pipeline/preview-renderer.ts index be7bcfe..7910386 100644 --- a/lib/image-pipeline/preview-renderer.ts +++ b/lib/image-pipeline/preview-renderer.ts @@ -13,6 +13,12 @@ export type PreviewRenderResult = { type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas; type PreviewContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } +} + function createPreviewContext(width: number, height: number): PreviewContext { if (typeof document !== "undefined") { const canvas = document.createElement("canvas"); @@ -63,9 +69,7 @@ export async function renderPreview(options: { const width = Math.max(1, Math.round(options.previewWidth)); const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width)); - if (options.signal?.aborted) { - throw new DOMException("The operation was aborted.", "AbortError"); - } + throwIfAborted(options.signal); const context = createPreviewContext(width, height); @@ -78,11 +82,11 @@ export async function renderPreview(options: { }); await yieldToMainOrWorkerLoop(); - if (options.signal?.aborted) { - throw new DOMException("The operation was aborted.", "AbortError"); - } + throwIfAborted(options.signal); } + throwIfAborted(options.signal); + const histogram = options.includeHistogram === false ? emptyHistogram() : computeHistogram(imageData.data); diff --git a/lib/image-pipeline/worker-client.ts b/lib/image-pipeline/worker-client.ts index 70b98c9..92f065e 100644 --- a/lib/image-pipeline/worker-client.ts +++ b/lib/image-pipeline/worker-client.ts @@ -99,7 +99,9 @@ function isAbortError(error: unknown): boolean { } function handleWorkerFailure(error: Error): void { - workerInitError = error; + const normalized = + error instanceof WorkerUnavailableError ? error : new WorkerUnavailableError(error.message); + workerInitError = normalized; if (workerInstance) { workerInstance.terminate(); @@ -107,11 +109,15 @@ function handleWorkerFailure(error: Error): void { } for (const [requestId, pending] of pendingRequests.entries()) { - pending.reject(error); + pending.reject(normalized); pendingRequests.delete(requestId); } } +function shouldFallbackToMainThread(error: unknown): error is WorkerUnavailableError { + return error instanceof WorkerUnavailableError; +} + function getWorker(): Worker { if (typeof window === "undefined" || typeof Worker === "undefined") { throw new WorkerUnavailableError("Worker API is not available."); @@ -281,6 +287,10 @@ export async function renderPreviewWithWorkerFallback(options: { throw error; } + if (!shouldFallbackToMainThread(error)) { + throw error; + } + return await renderPreview(options); } } @@ -299,6 +309,10 @@ export async function renderFullWithWorkerFallback( throw error; } + if (!shouldFallbackToMainThread(error)) { + throw error; + } + return await renderFull(options); } } diff --git a/tests/preview-renderer.test.ts b/tests/preview-renderer.test.ts new file mode 100644 index 0000000..1361dbe --- /dev/null +++ b/tests/preview-renderer.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { emptyHistogram } from "@/lib/image-pipeline/histogram"; + +const histogramMocks = vi.hoisted(() => ({ + computeHistogram: vi.fn(), +})); + +const renderCoreMocks = vi.hoisted(() => ({ + applyPipelineStep: vi.fn(), +})); + +const sourceLoaderMocks = vi.hoisted(() => ({ + loadSourceBitmap: vi.fn(), +})); + +vi.mock("@/lib/image-pipeline/histogram", async () => { + const actual = await vi.importActual( + "@/lib/image-pipeline/histogram", + ); + return { + ...actual, + computeHistogram: histogramMocks.computeHistogram, + }; +}); + +vi.mock("@/lib/image-pipeline/render-core", () => ({ + applyPipelineStep: renderCoreMocks.applyPipelineStep, +})); + +vi.mock("@/lib/image-pipeline/source-loader", () => ({ + loadSourceBitmap: sourceLoaderMocks.loadSourceBitmap, +})); + +describe("preview-renderer cancellation", () => { + beforeEach(() => { + vi.resetModules(); + histogramMocks.computeHistogram.mockReset(); + renderCoreMocks.applyPipelineStep.mockReset(); + sourceLoaderMocks.loadSourceBitmap.mockReset(); + histogramMocks.computeHistogram.mockReturnValue(emptyHistogram()); + sourceLoaderMocks.loadSourceBitmap.mockResolvedValue({ width: 1, height: 1 }); + renderCoreMocks.applyPipelineStep.mockImplementation(() => {}); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) })), + } as unknown as CanvasRenderingContext2D); + vi.stubGlobal("requestAnimationFrame", ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof requestAnimationFrame); + }); + + it("skips histogram work when cancellation lands after step application", async () => { + const { renderPreview } = await import("@/lib/image-pipeline/preview-renderer"); + + let abortedReads = 0; + const signal = { + get aborted() { + abortedReads += 1; + return abortedReads >= 3; + }, + } as AbortSignal; + + await expect( + renderPreview({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [ + { + nodeId: "light-1", + type: "light-adjust", + params: { exposure: 0.1 }, + }, + ], + previewWidth: 1, + includeHistogram: true, + signal, + }), + ).rejects.toMatchObject({ + name: "AbortError", + }); + + expect(histogramMocks.computeHistogram).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/worker-client.test.ts b/tests/worker-client.test.ts new file mode 100644 index 0000000..01cd252 --- /dev/null +++ b/tests/worker-client.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { emptyHistogram } from "@/lib/image-pipeline/histogram"; +import type { RenderFullResult } from "@/lib/image-pipeline/render-types"; + +const previewRendererMocks = vi.hoisted(() => ({ + renderPreview: vi.fn(), +})); + +const bridgeMocks = vi.hoisted(() => ({ + renderFull: vi.fn(), +})); + +vi.mock("@/lib/image-pipeline/preview-renderer", () => ({ + renderPreview: previewRendererMocks.renderPreview, +})); + +vi.mock("@/lib/image-pipeline/bridge", () => ({ + renderFull: bridgeMocks.renderFull, +})); + +function createFullResult(): RenderFullResult { + return { + blob: new Blob(["rendered"]), + width: 32, + height: 32, + mimeType: "image/png", + format: "png", + quality: null, + sizeBytes: 8, + sourceWidth: 32, + sourceHeight: 32, + wasSizeClamped: false, + }; +} + +type WorkerMessage = + | { + kind: "preview" | "full"; + requestId: number; + } + | { + kind: "cancel"; + requestId: number; + }; + +type FakeWorkerBehavior = (worker: FakeWorker, message: WorkerMessage) => void; + +class FakeWorker { + static behavior: FakeWorkerBehavior = () => {}; + + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessageerror: (() => void) | null = null; + terminated = false; + + postMessage(message: WorkerMessage): void { + FakeWorker.behavior(this, message); + } + + terminate(): void { + this.terminated = true; + } +} + +describe("worker-client fallbacks", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + previewRendererMocks.renderPreview.mockReset(); + bridgeMocks.renderFull.mockReset(); + previewRendererMocks.renderPreview.mockResolvedValue({ + width: 16, + height: 16, + imageData: { data: new Uint8ClampedArray(16 * 16 * 4) }, + histogram: emptyHistogram(), + }); + bridgeMocks.renderFull.mockResolvedValue(createFullResult()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not fall back to main-thread preview rendering for deterministic worker errors", async () => { + FakeWorker.behavior = (worker, message) => { + if (message.kind === "cancel") { + return; + } + + queueMicrotask(() => { + worker.onmessage?.({ + data: { + kind: "error", + requestId: message.requestId, + payload: { + name: "RenderPipelineError", + message: "Deterministic worker preview failure", + }, + }, + } as MessageEvent); + }); + }; + vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker); + + const { renderPreviewWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client"); + + await expect( + renderPreviewWithWorkerFallback({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [], + previewWidth: 128, + }), + ).rejects.toMatchObject({ + name: "RenderPipelineError", + message: "Deterministic worker preview failure", + }); + + expect(previewRendererMocks.renderPreview).not.toHaveBeenCalled(); + }); + + it("does not fall back to main-thread full rendering for deterministic worker errors", async () => { + FakeWorker.behavior = (worker, message) => { + if (message.kind === "cancel") { + return; + } + + queueMicrotask(() => { + worker.onmessage?.({ + data: { + kind: "error", + requestId: message.requestId, + payload: { + name: "RenderPipelineError", + message: "Deterministic worker full render failure", + }, + }, + } as MessageEvent); + }); + }; + vi.stubGlobal("Worker", FakeWorker as unknown as typeof Worker); + + const { renderFullWithWorkerFallback } = await import("@/lib/image-pipeline/worker-client"); + + await expect( + renderFullWithWorkerFallback({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [], + render: { + resolution: "original", + format: "png", + }, + }), + ).rejects.toMatchObject({ + name: "RenderPipelineError", + message: "Deterministic worker full render failure", + }); + + expect(bridgeMocks.renderFull).not.toHaveBeenCalled(); + }); + + it("still falls back to the main thread when the Worker API is unavailable", async () => { + vi.stubGlobal("Worker", undefined); + + const workerClient = await import("@/lib/image-pipeline/worker-client"); + + const previewResult = await workerClient.renderPreviewWithWorkerFallback({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [], + previewWidth: 128, + }); + const fullResult = await workerClient.renderFullWithWorkerFallback({ + sourceUrl: "https://cdn.example.com/source.png", + steps: [], + render: { + resolution: "original", + format: "png", + }, + }); + + expect(previewRendererMocks.renderPreview).toHaveBeenCalledTimes(1); + expect(bridgeMocks.renderFull).toHaveBeenCalledTimes(1); + expect(previewResult.width).toBe(16); + expect(fullResult.format).toBe("png"); + }); +});