feat(canvas): move image pipeline rendering off main thread with worker fallback

This commit is contained in:
2026-04-03 19:17:42 +02:00
parent 7e1a77c38c
commit 7e87a74df9
9 changed files with 652 additions and 25 deletions

View File

@@ -16,7 +16,10 @@ import { resolveRenderPreviewInput } from "@/lib/canvas-render-preview";
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 { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts"; import { getSourceImage, hashPipeline } from "@/lib/image-pipeline/contracts";
import { bridge } from "@/lib/image-pipeline/bridge"; import {
isPipelineAbortError,
renderFullWithWorkerFallback,
} from "@/lib/image-pipeline/worker-client";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@@ -441,10 +444,18 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
const localDataRef = useRef(localData); const localDataRef = useRef(localData);
const renderRunIdRef = useRef(0); const renderRunIdRef = useRef(0);
const renderAbortControllerRef = useRef<AbortController | null>(null);
const menuButtonRef = useRef<HTMLButtonElement | null>(null); const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const menuPanelRef = useRef<HTMLDivElement | null>(null); const menuPanelRef = useRef<HTMLDivElement | null>(null);
const lastAppliedAspectRatioRef = useRef<number | null>(null); const lastAppliedAspectRatioRef = useRef<number | null>(null);
useEffect(() => {
return () => {
renderAbortControllerRef.current?.abort();
renderAbortControllerRef.current = null;
};
}, []);
useEffect(() => { useEffect(() => {
localDataRef.current = localData; localDataRef.current = localData;
}, [localData]); }, [localData]);
@@ -763,6 +774,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
renderRunIdRef.current += 1; renderRunIdRef.current += 1;
const runId = renderRunIdRef.current; const runId = renderRunIdRef.current;
renderAbortControllerRef.current?.abort();
const abortController = new AbortController();
renderAbortControllerRef.current = abortController;
setIsRendering(true); setIsRendering(true);
try { try {
@@ -778,7 +792,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality : null, jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality : null,
}); });
const renderResult = await bridge.renderFull({ const renderResult = await renderFullWithWorkerFallback({
sourceUrl, sourceUrl,
steps, steps,
render: { render: {
@@ -796,6 +810,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
jpegQuality: jpegQuality:
activeData.format === "jpeg" ? activeData.jpegQuality / 100 : undefined, activeData.format === "jpeg" ? activeData.jpegQuality / 100 : undefined,
}, },
signal: abortController.signal,
}); });
if (runId !== renderRunIdRef.current) return; if (runId !== renderRunIdRef.current) return;
@@ -928,6 +943,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
} }
} catch (error: unknown) { } catch (error: unknown) {
if (runId !== renderRunIdRef.current) return; if (runId !== renderRunIdRef.current) return;
if (isPipelineAbortError(error)) {
return;
}
const message = error instanceof Error ? error.message : "Render failed"; const message = error instanceof Error ? error.message : "Render failed";
logRenderDebug("render-error", { logRenderDebug("render-error", {
@@ -944,6 +962,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr
await persistImmediately(next); await persistImmediately(next);
} finally { } finally {
if (runId === renderRunIdRef.current) { if (runId === renderRunIdRef.current) {
if (renderAbortControllerRef.current === abortController) {
renderAbortControllerRef.current = null;
}
setIsRendering(false); setIsRendering(false);
} }
} }

View File

@@ -5,9 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts"; import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
import { import {
renderPreview, isPipelineAbortError,
renderPreviewWithWorkerFallback,
type PreviewRenderResult, type PreviewRenderResult,
} from "@/lib/image-pipeline/preview-renderer"; } from "@/lib/image-pipeline/worker-client";
type UsePipelinePreviewOptions = { type UsePipelinePreviewOptions = {
sourceUrl: string | null; sourceUrl: string | null;
@@ -78,14 +79,16 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
const currentRun = runIdRef.current + 1; const currentRun = runIdRef.current + 1;
runIdRef.current = currentRun; runIdRef.current = currentRun;
const abortController = new AbortController();
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setIsRendering(true); setIsRendering(true);
setError(null); setError(null);
void renderPreview({ void renderPreviewWithWorkerFallback({
sourceUrl, sourceUrl,
steps: options.steps, steps: options.steps,
previewWidth, previewWidth,
signal: abortController.signal,
}) })
.then((result: PreviewRenderResult) => { .then((result: PreviewRenderResult) => {
if (runIdRef.current !== currentRun) return; if (runIdRef.current !== currentRun) return;
@@ -105,6 +108,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
}) })
.catch((renderError: unknown) => { .catch((renderError: unknown) => {
if (runIdRef.current !== currentRun) return; if (runIdRef.current !== currentRun) return;
if (isPipelineAbortError(renderError)) return;
const message = const message =
renderError instanceof Error renderError instanceof Error
? renderError.message ? renderError.message
@@ -119,6 +124,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
return () => { return () => {
window.clearTimeout(timer); window.clearTimeout(timer);
abortController.abort();
}; };
}, [options.sourceUrl, options.steps, pipelineHash, previewWidth]); }, [options.sourceUrl, options.steps, pipelineHash, previewWidth]);

View File

@@ -93,7 +93,9 @@ function resolveMimeType(format: RenderFormat): string {
} }
export async function renderFull(options: RenderFullOptions): Promise<RenderFullResult> { export async function renderFull(options: RenderFullOptions): Promise<RenderFullResult> {
const bitmap = await loadSourceBitmap(options.sourceUrl); const { signal } = options;
const bitmap = await loadSourceBitmap(options.sourceUrl, { signal });
const resolvedSize = resolveRenderSize({ const resolvedSize = resolveRenderSize({
sourceWidth: bitmap.width, sourceWidth: bitmap.width,
sourceHeight: bitmap.height, sourceHeight: bitmap.height,
@@ -111,7 +113,15 @@ export async function renderFull(options: RenderFullOptions): Promise<RenderFull
options.steps, options.steps,
resolvedSize.width, resolvedSize.width,
resolvedSize.height, resolvedSize.height,
{
shouldAbort: () => Boolean(signal?.aborted),
},
); );
if (signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
context.putImageData(imageData, 0, 0); context.putImageData(imageData, 0, 0);
const mimeType = resolveMimeType(options.render.format); const mimeType = resolveMimeType(options.render.format);

View File

@@ -0,0 +1,166 @@
import { renderFull } from "@/lib/image-pipeline/bridge";
import { renderPreview } from "@/lib/image-pipeline/preview-renderer";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import type { HistogramData } from "@/lib/image-pipeline/histogram";
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
type PreviewWorkerPayload = {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
};
type WorkerRequestMessage =
| {
kind: "preview";
requestId: number;
payload: PreviewWorkerPayload;
}
| {
kind: "full";
requestId: number;
payload: RenderFullOptions;
}
| {
kind: "cancel";
requestId: number;
};
type WorkerResultPreviewPayload = {
width: number;
height: number;
histogram: HistogramData;
pixels: ArrayBuffer;
};
type WorkerResponseMessage =
| {
kind: "preview-result";
requestId: number;
payload: WorkerResultPreviewPayload;
}
| {
kind: "full-result";
requestId: number;
payload: RenderFullResult;
}
| {
kind: "error";
requestId: number;
payload: {
name: string;
message: string;
};
};
type WorkerScope = {
postMessage: (message: WorkerResponseMessage, transfer?: Transferable[]) => void;
onmessage: ((event: MessageEvent<WorkerRequestMessage>) => void) | null;
};
const workerScope = self as unknown as WorkerScope;
const runningControllers = new Map<number, AbortController>();
function postMessageSafe(message: WorkerResponseMessage, transfer?: Transferable[]): void {
if (transfer) {
workerScope.postMessage(message, transfer);
return;
}
workerScope.postMessage(message);
}
function normalizeErrorPayload(error: unknown): { name: string; message: string } {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
};
}
return {
name: "Error",
message: "Image pipeline worker failed",
};
}
async function handlePreviewRequest(requestId: number, payload: PreviewWorkerPayload): Promise<void> {
const controller = new AbortController();
runningControllers.set(requestId, controller);
try {
const result = await renderPreview({
sourceUrl: payload.sourceUrl,
steps: payload.steps,
previewWidth: payload.previewWidth,
signal: controller.signal,
});
const pixels = result.imageData.data.buffer;
postMessageSafe(
{
kind: "preview-result",
requestId,
payload: {
width: result.width,
height: result.height,
histogram: result.histogram,
pixels,
},
},
[pixels],
);
} catch (error: unknown) {
postMessageSafe({
kind: "error",
requestId,
payload: normalizeErrorPayload(error),
});
} finally {
runningControllers.delete(requestId);
}
}
async function handleFullRequest(requestId: number, payload: RenderFullOptions): Promise<void> {
const controller = new AbortController();
runningControllers.set(requestId, controller);
try {
const result = await renderFull({
...payload,
signal: controller.signal,
});
postMessageSafe({
kind: "full-result",
requestId,
payload: result,
});
} catch (error: unknown) {
postMessageSafe({
kind: "error",
requestId,
payload: normalizeErrorPayload(error),
});
} finally {
runningControllers.delete(requestId);
}
}
workerScope.onmessage = (event: MessageEvent<WorkerRequestMessage>) => {
const message = event.data;
if (message.kind === "cancel") {
runningControllers.get(message.requestId)?.abort();
return;
}
if (message.kind === "preview") {
void handlePreviewRequest(message.requestId, message.payload);
return;
}
void handleFullRequest(message.requestId, message.payload);
};
export {};

View File

@@ -10,31 +10,76 @@ export type PreviewRenderResult = {
histogram: HistogramData; histogram: HistogramData;
}; };
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
type PreviewContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
function createPreviewContext(width: number, height: number): PreviewContext {
if (typeof document !== "undefined") {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Preview renderer could not create 2D context.");
}
return context;
}
if (typeof OffscreenCanvas !== "undefined") {
const canvas: PreviewCanvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Preview renderer could not create offscreen 2D context.");
}
return context;
}
throw new Error("Preview rendering is not available in this environment.");
}
async function yieldToMainOrWorkerLoop(): Promise<void> {
await new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(() => resolve(), 0);
});
}
export async function renderPreview(options: { export async function renderPreview(options: {
sourceUrl: string; sourceUrl: string;
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
previewWidth: number; previewWidth: number;
signal?: AbortSignal;
}): Promise<PreviewRenderResult> { }): Promise<PreviewRenderResult> {
const bitmap = await loadSourceBitmap(options.sourceUrl); const bitmap = await loadSourceBitmap(options.sourceUrl, {
signal: options.signal,
});
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));
const canvas = document.createElement("canvas"); if (options.signal?.aborted) {
canvas.width = width; throw new DOMException("The operation was aborted.", "AbortError");
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Preview renderer could not create 2D context.");
} }
const context = createPreviewContext(width, height);
context.drawImage(bitmap, 0, 0, width, height); context.drawImage(bitmap, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height); const imageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < options.steps.length; index += 1) { for (let index = 0; index < options.steps.length; index += 1) {
applyPipelineStep(imageData.data, options.steps[index]!, width, height); applyPipelineStep(imageData.data, options.steps[index]!, width, height, {
await new Promise<void>((resolve) => { shouldAbort: () => Boolean(options.signal?.aborted),
requestAnimationFrame(() => resolve());
}); });
await yieldToMainOrWorkerLoop();
if (options.signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
} }
const histogram = computeHistogram(imageData.data); const histogram = computeHistogram(imageData.data);

View File

@@ -7,6 +7,20 @@ import {
type CurvePoint, type CurvePoint,
} from "@/lib/image-pipeline/adjustment-types"; } from "@/lib/image-pipeline/adjustment-types";
type PipelineExecutionOptions = {
shouldAbort?: () => boolean;
};
function throwIfAborted(options: PipelineExecutionOptions | undefined): void {
if (options?.shouldAbort?.()) {
throw new DOMException("The operation was aborted.", "AbortError");
}
}
function shouldCheckAbort(index: number): boolean {
return index % 4096 === 0;
}
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
@@ -100,7 +114,11 @@ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: n
}; };
} }
function applyCurves(pixels: Uint8ClampedArray, params: unknown): void { function applyCurves(
pixels: Uint8ClampedArray,
params: unknown,
options?: PipelineExecutionOptions,
): void {
const curves = normalizeCurvesData(params); const curves = normalizeCurvesData(params);
const rgbLut = buildLut(curves.points.rgb); const rgbLut = buildLut(curves.points.rgb);
const redLut = buildLut(curves.points.red); const redLut = buildLut(curves.points.red);
@@ -112,6 +130,10 @@ function applyCurves(pixels: Uint8ClampedArray, params: unknown): void {
const invGamma = 1 / curves.levels.gamma; const invGamma = 1 / curves.levels.gamma;
for (let index = 0; index < pixels.length; index += 4) { for (let index = 0; index < pixels.length; index += 4) {
if (shouldCheckAbort(index)) {
throwIfAborted(options);
}
const applyLevels = (value: number) => { const applyLevels = (value: number) => {
const normalized = clamp((value - curves.levels.blackPoint) / levelRange, 0, 1); const normalized = clamp((value - curves.levels.blackPoint) / levelRange, 0, 1);
return toByte(Math.pow(normalized, invGamma) * 255); return toByte(Math.pow(normalized, invGamma) * 255);
@@ -143,13 +165,21 @@ function applyCurves(pixels: Uint8ClampedArray, params: unknown): void {
} }
} }
function applyColorAdjust(pixels: Uint8ClampedArray, params: unknown): void { function applyColorAdjust(
pixels: Uint8ClampedArray,
params: unknown,
options?: PipelineExecutionOptions,
): void {
const color = normalizeColorAdjustData(params); const color = normalizeColorAdjustData(params);
const saturationFactor = 1 + color.hsl.saturation / 100; const saturationFactor = 1 + color.hsl.saturation / 100;
const luminanceShift = color.hsl.luminance / 100; const luminanceShift = color.hsl.luminance / 100;
const hueShift = color.hsl.hue; const hueShift = color.hsl.hue;
for (let index = 0; index < pixels.length; index += 4) { for (let index = 0; index < pixels.length; index += 4) {
if (shouldCheckAbort(index)) {
throwIfAborted(options);
}
const currentRed = pixels[index] ?? 0; const currentRed = pixels[index] ?? 0;
const currentGreen = pixels[index + 1] ?? 0; const currentGreen = pixels[index + 1] ?? 0;
const currentBlue = pixels[index + 2] ?? 0; const currentBlue = pixels[index + 2] ?? 0;
@@ -180,6 +210,7 @@ function applyLightAdjust(
params: unknown, params: unknown,
width: number, width: number,
height: number, height: number,
options?: PipelineExecutionOptions,
): void { ): void {
const light = normalizeLightAdjustData(params); const light = normalizeLightAdjustData(params);
const exposureFactor = Math.pow(2, light.exposure / 2); const exposureFactor = Math.pow(2, light.exposure / 2);
@@ -190,6 +221,10 @@ function applyLightAdjust(
const centerY = height / 2; const centerY = height / 2;
for (let y = 0; y < height; y += 1) { for (let y = 0; y < height; y += 1) {
if (y % 64 === 0) {
throwIfAborted(options);
}
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4; const index = (y * width + x) * 4;
let red = pixels[index] ?? 0; let red = pixels[index] ?? 0;
@@ -238,7 +273,11 @@ function pseudoNoise(seed: number): number {
return x - Math.floor(x); return x - Math.floor(x);
} }
function applyDetailAdjust(pixels: Uint8ClampedArray, params: unknown): void { function applyDetailAdjust(
pixels: Uint8ClampedArray,
params: unknown,
options?: PipelineExecutionOptions,
): void {
const detail = normalizeDetailAdjustData(params); const detail = normalizeDetailAdjustData(params);
const sharpenBoost = detail.sharpen.amount / 500; const sharpenBoost = detail.sharpen.amount / 500;
const clarityBoost = detail.clarity / 100; const clarityBoost = detail.clarity / 100;
@@ -248,6 +287,10 @@ function applyDetailAdjust(pixels: Uint8ClampedArray, params: unknown): void {
const grainScale = Math.max(0.5, detail.grain.size); const grainScale = Math.max(0.5, detail.grain.size);
for (let index = 0; index < pixels.length; index += 4) { for (let index = 0; index < pixels.length; index += 4) {
if (shouldCheckAbort(index)) {
throwIfAborted(options);
}
let red = pixels[index] ?? 0; let red = pixels[index] ?? 0;
let green = pixels[index + 1] ?? 0; let green = pixels[index + 1] ?? 0;
let blue = pixels[index + 2] ?? 0; let blue = pixels[index + 2] ?? 0;
@@ -293,21 +336,24 @@ export function applyPipelineStep(
step: PipelineStep<string, unknown>, step: PipelineStep<string, unknown>,
width: number, width: number,
height: number, height: number,
options?: PipelineExecutionOptions,
): void { ): void {
throwIfAborted(options);
if (step.type === "curves") { if (step.type === "curves") {
applyCurves(pixels, step.params); applyCurves(pixels, step.params, options);
return; return;
} }
if (step.type === "color-adjust") { if (step.type === "color-adjust") {
applyColorAdjust(pixels, step.params); applyColorAdjust(pixels, step.params, options);
return; return;
} }
if (step.type === "light-adjust") { if (step.type === "light-adjust") {
applyLightAdjust(pixels, step.params, width, height); applyLightAdjust(pixels, step.params, width, height, options);
return; return;
} }
if (step.type === "detail-adjust") { if (step.type === "detail-adjust") {
applyDetailAdjust(pixels, step.params); applyDetailAdjust(pixels, step.params, options);
} }
} }
@@ -316,8 +362,9 @@ export function applyPipelineSteps(
steps: readonly PipelineStep[], steps: readonly PipelineStep[],
width: number, width: number,
height: number, height: number,
options?: PipelineExecutionOptions,
): void { ): void {
for (let index = 0; index < steps.length; index += 1) { for (let index = 0; index < steps.length; index += 1) {
applyPipelineStep(pixels, steps[index]!, width, height); applyPipelineStep(pixels, steps[index]!, width, height, options);
} }
} }

View File

@@ -36,6 +36,7 @@ export type RenderFullOptions = {
steps: readonly PipelineStep[]; steps: readonly PipelineStep[];
render: RenderOptions; render: RenderOptions;
limits?: RenderSizeLimits; limits?: RenderSizeLimits;
signal?: AbortSignal;
}; };
export type RenderFullResult = { export type RenderFullResult = {

View File

@@ -1,6 +1,19 @@
const imageBitmapCache = new Map<string, Promise<ImageBitmap>>(); const imageBitmapCache = new Map<string, Promise<ImageBitmap>>();
export async function loadSourceBitmap(sourceUrl: string): Promise<ImageBitmap> { type LoadSourceBitmapOptions = {
signal?: AbortSignal;
};
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
}
export async function loadSourceBitmap(
sourceUrl: string,
options: LoadSourceBitmapOptions = {},
): Promise<ImageBitmap> {
if (!sourceUrl || sourceUrl.trim().length === 0) { if (!sourceUrl || sourceUrl.trim().length === 0) {
throw new Error("Render sourceUrl is required."); throw new Error("Render sourceUrl is required.");
} }
@@ -9,6 +22,19 @@ export async function loadSourceBitmap(sourceUrl: string): Promise<ImageBitmap>
throw new Error("ImageBitmap is not available in this environment."); throw new Error("ImageBitmap is not available in this environment.");
} }
throwIfAborted(options.signal);
if (options.signal) {
const response = await fetch(sourceUrl, { signal: options.signal });
if (!response.ok) {
throw new Error(`Render source failed: ${response.status}`);
}
const blob = await response.blob();
throwIfAborted(options.signal);
return await createImageBitmap(blob);
}
const cached = imageBitmapCache.get(sourceUrl); const cached = imageBitmapCache.get(sourceUrl);
if (cached) { if (cached) {
return await cached; return await cached;

View File

@@ -0,0 +1,305 @@
import { renderFull } from "@/lib/image-pipeline/bridge";
import {
renderPreview,
type PreviewRenderResult,
} from "@/lib/image-pipeline/preview-renderer";
import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import type { HistogramData } from "@/lib/image-pipeline/histogram";
import type { RenderFullOptions, RenderFullResult } from "@/lib/image-pipeline/render-types";
export type { PreviewRenderResult };
type PreviewWorkerPayload = {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
};
type WorkerRequestMessage =
| {
kind: "preview";
requestId: number;
payload: PreviewWorkerPayload;
}
| {
kind: "full";
requestId: number;
payload: RenderFullOptions;
}
| {
kind: "cancel";
requestId: number;
};
type WorkerResultPreviewPayload = {
width: number;
height: number;
histogram: HistogramData;
pixels: ArrayBuffer;
};
type WorkerResponseMessage =
| {
kind: "preview-result";
requestId: number;
payload: WorkerResultPreviewPayload;
}
| {
kind: "full-result";
requestId: number;
payload: RenderFullResult;
}
| {
kind: "error";
requestId: number;
payload: {
name: string;
message: string;
};
};
class WorkerUnavailableError extends Error {
constructor(causeMessage?: string) {
super(causeMessage ?? "Image pipeline worker is unavailable.");
this.name = "WorkerUnavailableError";
}
}
type PendingRequest = {
kind: "preview" | "full";
resolve: (value: PreviewRenderResult | RenderFullResult) => void;
reject: (reason?: unknown) => void;
};
let workerInstance: Worker | null = null;
let workerInitError: Error | null = null;
let requestIdCounter = 0;
const pendingRequests = new Map<number, PendingRequest>();
function nextRequestId(): number {
requestIdCounter += 1;
return requestIdCounter;
}
function makeAbortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}
function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof Error && error.name === "AbortError") {
return true;
}
return false;
}
function handleWorkerFailure(error: Error): void {
workerInitError = error;
if (workerInstance) {
workerInstance.terminate();
workerInstance = null;
}
for (const [requestId, pending] of pendingRequests.entries()) {
pending.reject(error);
pendingRequests.delete(requestId);
}
}
function getWorker(): Worker {
if (typeof window === "undefined" || typeof Worker === "undefined") {
throw new WorkerUnavailableError("Worker API is not available.");
}
if (workerInitError) {
throw new WorkerUnavailableError(workerInitError.message);
}
if (workerInstance) {
return workerInstance;
}
try {
const created = new Worker(new URL("./image-pipeline.worker.ts", import.meta.url), {
type: "module",
});
created.onmessage = (event: MessageEvent<WorkerResponseMessage>) => {
const message = event.data;
const pending = pendingRequests.get(message.requestId);
if (!pending) {
return;
}
pendingRequests.delete(message.requestId);
if (message.kind === "error") {
const workerError = new Error(message.payload.message);
workerError.name = message.payload.name;
pending.reject(workerError);
return;
}
if (pending.kind === "preview" && message.kind === "preview-result") {
const pixels = new Uint8ClampedArray(message.payload.pixels);
pending.resolve({
width: message.payload.width,
height: message.payload.height,
imageData: new ImageData(pixels, message.payload.width, message.payload.height),
histogram: message.payload.histogram,
});
return;
}
if (pending.kind === "full" && message.kind === "full-result") {
pending.resolve(message.payload);
return;
}
pending.reject(new Error("Image pipeline worker response type mismatch."));
};
created.onerror = () => {
handleWorkerFailure(new Error("Image pipeline worker crashed."));
};
created.onmessageerror = () => {
handleWorkerFailure(new Error("Image pipeline worker message deserialization failed."));
};
workerInstance = created;
return created;
} catch (error: unknown) {
const normalized = error instanceof Error ? error : new Error("Worker initialization failed.");
workerInitError = normalized;
throw new WorkerUnavailableError(normalized.message);
}
}
function runWorkerRequest<TResponse extends PreviewRenderResult | RenderFullResult>(args: {
kind: "preview" | "full";
payload: PreviewWorkerPayload | RenderFullOptions;
signal?: AbortSignal;
}): Promise<TResponse> {
if (args.signal?.aborted) {
return Promise.reject(makeAbortError());
}
const worker = getWorker();
const requestId = nextRequestId();
return new Promise<TResponse>((resolve, reject) => {
let isSettled = false;
const settleOnce = (callback: () => void): void => {
if (isSettled) {
return;
}
isSettled = true;
callback();
};
const abortHandler = () => {
settleOnce(() => {
pendingRequests.delete(requestId);
worker.postMessage({ kind: "cancel", requestId } satisfies WorkerRequestMessage);
reject(makeAbortError());
});
};
if (args.signal) {
args.signal.addEventListener("abort", abortHandler, { once: true });
}
const wrappedResolve = (value: TResponse) => {
settleOnce(() => {
if (args.signal) {
args.signal.removeEventListener("abort", abortHandler);
}
resolve(value);
});
};
const wrappedReject = (error: unknown) => {
settleOnce(() => {
if (args.signal) {
args.signal.removeEventListener("abort", abortHandler);
}
reject(error);
});
};
pendingRequests.set(requestId, {
kind: args.kind,
resolve: wrappedResolve as PendingRequest["resolve"],
reject: wrappedReject,
});
if (args.kind === "preview") {
worker.postMessage({
kind: "preview",
requestId,
payload: args.payload as PreviewWorkerPayload,
} satisfies WorkerRequestMessage);
return;
}
worker.postMessage({
kind: "full",
requestId,
payload: args.payload as RenderFullOptions,
} satisfies WorkerRequestMessage);
});
}
export async function renderPreviewWithWorkerFallback(options: {
sourceUrl: string;
steps: readonly PipelineStep[];
previewWidth: number;
signal?: AbortSignal;
}): Promise<PreviewRenderResult> {
try {
return await runWorkerRequest<PreviewRenderResult>({
kind: "preview",
payload: {
sourceUrl: options.sourceUrl,
steps: options.steps,
previewWidth: options.previewWidth,
},
signal: options.signal,
});
} catch (error: unknown) {
if (isAbortError(error)) {
throw error;
}
return await renderPreview(options);
}
}
export async function renderFullWithWorkerFallback(
options: RenderFullOptions,
): Promise<RenderFullResult> {
try {
return await runWorkerRequest<RenderFullResult>({
kind: "full",
payload: options,
signal: options.signal,
});
} catch (error: unknown) {
if (isAbortError(error)) {
throw error;
}
return await renderFull(options);
}
}
export function isPipelineAbortError(error: unknown): boolean {
return isAbortError(error);
}