fix(image-pipeline): preserve worker errors and skip aborted histograms

This commit is contained in:
Matthias
2026-04-04 11:56:38 +02:00
parent b650485e81
commit d73db3a612
7 changed files with 421 additions and 131 deletions

View File

@@ -0,0 +1,97 @@
import type { HistogramData } from "@/lib/image-pipeline/histogram";
type HistogramChannels = Pick<HistogramData, "red" | "green" | "blue" | "rgb">;
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),
},
};
}

View File

@@ -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);

View File

@@ -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);
}
}