fix(image-pipeline): preserve worker errors and skip aborted histograms
This commit is contained in:
@@ -7,8 +7,9 @@ import { usePipelinePreview } from "@/hooks/use-pipeline-preview";
|
|||||||
import {
|
import {
|
||||||
collectPipelineFromGraph,
|
collectPipelineFromGraph,
|
||||||
getSourceImageFromGraph,
|
getSourceImageFromGraph,
|
||||||
type PipelineStep,
|
|
||||||
} from "@/lib/canvas-render-preview";
|
} 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([
|
const PREVIEW_PIPELINE_TYPES = new Set([
|
||||||
"curves",
|
"curves",
|
||||||
@@ -17,45 +18,6 @@ const PREVIEW_PIPELINE_TYPES = new Set([
|
|||||||
"detail-adjust",
|
"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({
|
export default function AdjustmentPreview({
|
||||||
nodeId,
|
nodeId,
|
||||||
nodeWidth,
|
nodeWidth,
|
||||||
@@ -119,26 +81,14 @@ export default function AdjustmentPreview({
|
|||||||
maxDevicePixelRatio: 1.25,
|
maxDevicePixelRatio: 1.25,
|
||||||
});
|
});
|
||||||
|
|
||||||
const histogramSeries = useMemo(() => {
|
const histogramPlot = useMemo(() => {
|
||||||
const red = compactHistogram(histogram.red, 64);
|
return buildHistogramPlot(histogram, {
|
||||||
const green = compactHistogram(histogram.green, 64);
|
points: 64,
|
||||||
const blue = compactHistogram(histogram.blue, 64);
|
width: 96,
|
||||||
const rgb = compactHistogram(histogram.rgb, 64);
|
height: 44,
|
||||||
const max = Math.max(1, ...red, ...green, ...blue, ...rgb);
|
});
|
||||||
return { red, green, blue, rgb, max };
|
|
||||||
}, [histogram.blue, histogram.green, histogram.red, histogram.rgb]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div
|
<div
|
||||||
@@ -169,7 +119,7 @@ export default function AdjustmentPreview({
|
|||||||
aria-label="Histogramm als RGB-Linienkurven"
|
aria-label="Histogramm als RGB-Linienkurven"
|
||||||
>
|
>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.rgb}
|
points={histogramPlot.polylines.rgb}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(248, 250, 252, 0.9)"
|
stroke="rgba(248, 250, 252, 0.9)"
|
||||||
strokeWidth={1.6}
|
strokeWidth={1.6}
|
||||||
@@ -177,7 +127,7 @@ export default function AdjustmentPreview({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.red}
|
points={histogramPlot.polylines.red}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(248, 113, 113, 0.9)"
|
stroke="rgba(248, 113, 113, 0.9)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
@@ -185,7 +135,7 @@ export default function AdjustmentPreview({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.green}
|
points={histogramPlot.polylines.green}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(74, 222, 128, 0.85)"
|
stroke="rgba(74, 222, 128, 0.85)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
@@ -193,7 +143,7 @@ export default function AdjustmentPreview({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.blue}
|
points={histogramPlot.polylines.blue}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(96, 165, 250, 0.88)"
|
stroke="rgba(96, 165, 250, 0.88)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
||||||
import { parseAspectRatioString } from "@/lib/image-formats";
|
import { parseAspectRatioString } from "@/lib/image-formats";
|
||||||
import { hashPipeline } from "@/lib/image-pipeline/contracts";
|
import { hashPipeline } from "@/lib/image-pipeline/contracts";
|
||||||
|
import { buildHistogramPlot } from "@/lib/image-pipeline/histogram-plot";
|
||||||
import {
|
import {
|
||||||
isPipelineAbortError,
|
isPipelineAbortError,
|
||||||
renderFullWithWorkerFallback,
|
renderFullWithWorkerFallback,
|
||||||
@@ -361,45 +362,6 @@ function extensionForFormat(format: RenderFormatOption): string {
|
|||||||
return format === "jpeg" ? "jpg" : format;
|
return format === "jpeg" ? "jpg" : format;
|
||||||
}
|
}
|
||||||
|
|
||||||
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(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadBlobToConvex(args: {
|
async function uploadBlobToConvex(args: {
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
@@ -682,26 +644,14 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
});
|
});
|
||||||
}, [hasSource, height, id, queueNodeResize, targetAspectRatio, width]);
|
}, [hasSource, height, id, queueNodeResize, targetAspectRatio, width]);
|
||||||
|
|
||||||
const histogramSeries = useMemo(() => {
|
const histogramPlot = useMemo(() => {
|
||||||
const red = compactHistogram(histogram.red, 64);
|
return buildHistogramPlot(histogram, {
|
||||||
const green = compactHistogram(histogram.green, 64);
|
points: 64,
|
||||||
const blue = compactHistogram(histogram.blue, 64);
|
width: 96,
|
||||||
const rgb = compactHistogram(histogram.rgb, 64);
|
height: 44,
|
||||||
const max = Math.max(1, ...red, ...green, ...blue, ...rgb);
|
});
|
||||||
return { red, green, blue, rgb, max };
|
|
||||||
}, [histogram.blue, histogram.green, histogram.red, histogram.rgb]);
|
}, [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 =
|
const canRender =
|
||||||
hasSource &&
|
hasSource &&
|
||||||
!isRendering &&
|
!isRendering &&
|
||||||
@@ -1267,7 +1217,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
aria-label="Histogramm als RGB-Linienkurven"
|
aria-label="Histogramm als RGB-Linienkurven"
|
||||||
>
|
>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.rgb}
|
points={histogramPlot.polylines.rgb}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(248, 250, 252, 0.9)"
|
stroke="rgba(248, 250, 252, 0.9)"
|
||||||
strokeWidth={1.6}
|
strokeWidth={1.6}
|
||||||
@@ -1275,7 +1225,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.red}
|
points={histogramPlot.polylines.red}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(248, 113, 113, 0.9)"
|
stroke="rgba(248, 113, 113, 0.9)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
@@ -1283,7 +1233,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.green}
|
points={histogramPlot.polylines.green}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(74, 222, 128, 0.85)"
|
stroke="rgba(74, 222, 128, 0.85)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
@@ -1291,7 +1241,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<polyline
|
<polyline
|
||||||
points={histogramPolylines.blue}
|
points={histogramPlot.polylines.blue}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(96, 165, 250, 0.88)"
|
stroke="rgba(96, 165, 250, 0.88)"
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
|
|||||||
97
lib/image-pipeline/histogram-plot.ts
Normal file
97
lib/image-pipeline/histogram-plot.ts
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ export type PreviewRenderResult = {
|
|||||||
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
|
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||||
type PreviewContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
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 {
|
function createPreviewContext(width: number, height: number): PreviewContext {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -63,9 +69,7 @@ export async function renderPreview(options: {
|
|||||||
const width = Math.max(1, Math.round(options.previewWidth));
|
const width = Math.max(1, Math.round(options.previewWidth));
|
||||||
const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width));
|
const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width));
|
||||||
|
|
||||||
if (options.signal?.aborted) {
|
throwIfAborted(options.signal);
|
||||||
throw new DOMException("The operation was aborted.", "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = createPreviewContext(width, height);
|
const context = createPreviewContext(width, height);
|
||||||
|
|
||||||
@@ -78,11 +82,11 @@ export async function renderPreview(options: {
|
|||||||
});
|
});
|
||||||
await yieldToMainOrWorkerLoop();
|
await yieldToMainOrWorkerLoop();
|
||||||
|
|
||||||
if (options.signal?.aborted) {
|
throwIfAborted(options.signal);
|
||||||
throw new DOMException("The operation was aborted.", "AbortError");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
const histogram = options.includeHistogram === false
|
const histogram = options.includeHistogram === false
|
||||||
? emptyHistogram()
|
? emptyHistogram()
|
||||||
: computeHistogram(imageData.data);
|
: computeHistogram(imageData.data);
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ function isAbortError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkerFailure(error: Error): void {
|
function handleWorkerFailure(error: Error): void {
|
||||||
workerInitError = error;
|
const normalized =
|
||||||
|
error instanceof WorkerUnavailableError ? error : new WorkerUnavailableError(error.message);
|
||||||
|
workerInitError = normalized;
|
||||||
|
|
||||||
if (workerInstance) {
|
if (workerInstance) {
|
||||||
workerInstance.terminate();
|
workerInstance.terminate();
|
||||||
@@ -107,11 +109,15 @@ function handleWorkerFailure(error: Error): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||||
pending.reject(error);
|
pending.reject(normalized);
|
||||||
pendingRequests.delete(requestId);
|
pendingRequests.delete(requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldFallbackToMainThread(error: unknown): error is WorkerUnavailableError {
|
||||||
|
return error instanceof WorkerUnavailableError;
|
||||||
|
}
|
||||||
|
|
||||||
function getWorker(): Worker {
|
function getWorker(): Worker {
|
||||||
if (typeof window === "undefined" || typeof Worker === "undefined") {
|
if (typeof window === "undefined" || typeof Worker === "undefined") {
|
||||||
throw new WorkerUnavailableError("Worker API is not available.");
|
throw new WorkerUnavailableError("Worker API is not available.");
|
||||||
@@ -281,6 +287,10 @@ export async function renderPreviewWithWorkerFallback(options: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shouldFallbackToMainThread(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return await renderPreview(options);
|
return await renderPreview(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +309,10 @@ export async function renderFullWithWorkerFallback(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shouldFallbackToMainThread(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return await renderFull(options);
|
return await renderFull(options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
tests/preview-renderer.test.ts
Normal file
87
tests/preview-renderer.test.ts
Normal file
@@ -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<typeof import("@/lib/image-pipeline/histogram")>(
|
||||||
|
"@/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
188
tests/worker-client.test.ts
Normal file
188
tests/worker-client.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user