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");
+ });
+});