diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 9b5ae0e..0e79767 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -16,7 +16,10 @@ import { resolveRenderPreviewInput } from "@/lib/canvas-render-preview"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { parseAspectRatioString } from "@/lib/image-formats"; 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 { 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 renderRunIdRef = useRef(0); + const renderAbortControllerRef = useRef(null); const menuButtonRef = useRef(null); const menuPanelRef = useRef(null); const lastAppliedAspectRatioRef = useRef(null); + useEffect(() => { + return () => { + renderAbortControllerRef.current?.abort(); + renderAbortControllerRef.current = null; + }; + }, []); + useEffect(() => { localDataRef.current = localData; }, [localData]); @@ -763,6 +774,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr renderRunIdRef.current += 1; const runId = renderRunIdRef.current; + renderAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + renderAbortControllerRef.current = abortController; setIsRendering(true); try { @@ -778,7 +792,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality : null, }); - const renderResult = await bridge.renderFull({ + const renderResult = await renderFullWithWorkerFallback({ sourceUrl, steps, render: { @@ -796,6 +810,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr jpegQuality: activeData.format === "jpeg" ? activeData.jpegQuality / 100 : undefined, }, + signal: abortController.signal, }); if (runId !== renderRunIdRef.current) return; @@ -928,6 +943,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr } } catch (error: unknown) { if (runId !== renderRunIdRef.current) return; + if (isPipelineAbortError(error)) { + return; + } const message = error instanceof Error ? error.message : "Render failed"; logRenderDebug("render-error", { @@ -944,6 +962,9 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr await persistImmediately(next); } finally { if (runId === renderRunIdRef.current) { + if (renderAbortControllerRef.current === abortController) { + renderAbortControllerRef.current = null; + } setIsRendering(false); } } diff --git a/hooks/use-pipeline-preview.ts b/hooks/use-pipeline-preview.ts index 174a513..ba4378e 100644 --- a/hooks/use-pipeline-preview.ts +++ b/hooks/use-pipeline-preview.ts @@ -5,9 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts"; import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram"; import { - renderPreview, + isPipelineAbortError, + renderPreviewWithWorkerFallback, type PreviewRenderResult, -} from "@/lib/image-pipeline/preview-renderer"; +} from "@/lib/image-pipeline/worker-client"; type UsePipelinePreviewOptions = { sourceUrl: string | null; @@ -78,14 +79,16 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { const currentRun = runIdRef.current + 1; runIdRef.current = currentRun; + const abortController = new AbortController(); const timer = window.setTimeout(() => { setIsRendering(true); setError(null); - void renderPreview({ + void renderPreviewWithWorkerFallback({ sourceUrl, steps: options.steps, previewWidth, + signal: abortController.signal, }) .then((result: PreviewRenderResult) => { if (runIdRef.current !== currentRun) return; @@ -105,6 +108,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { }) .catch((renderError: unknown) => { if (runIdRef.current !== currentRun) return; + if (isPipelineAbortError(renderError)) return; + const message = renderError instanceof Error ? renderError.message @@ -119,6 +124,7 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): { return () => { window.clearTimeout(timer); + abortController.abort(); }; }, [options.sourceUrl, options.steps, pipelineHash, previewWidth]); diff --git a/lib/image-pipeline/bridge.ts b/lib/image-pipeline/bridge.ts index c616ec6..a9cf9a0 100644 --- a/lib/image-pipeline/bridge.ts +++ b/lib/image-pipeline/bridge.ts @@ -93,7 +93,9 @@ function resolveMimeType(format: RenderFormat): string { } export async function renderFull(options: RenderFullOptions): Promise { - const bitmap = await loadSourceBitmap(options.sourceUrl); + const { signal } = options; + + const bitmap = await loadSourceBitmap(options.sourceUrl, { signal }); const resolvedSize = resolveRenderSize({ sourceWidth: bitmap.width, sourceHeight: bitmap.height, @@ -111,7 +113,15 @@ export async function renderFull(options: RenderFullOptions): Promise Boolean(signal?.aborted), + }, ); + + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + context.putImageData(imageData, 0, 0); const mimeType = resolveMimeType(options.render.format); diff --git a/lib/image-pipeline/image-pipeline.worker.ts b/lib/image-pipeline/image-pipeline.worker.ts new file mode 100644 index 0000000..ff78272 --- /dev/null +++ b/lib/image-pipeline/image-pipeline.worker.ts @@ -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) => void) | null; +}; + +const workerScope = self as unknown as WorkerScope; +const runningControllers = new Map(); + +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 { + 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 { + 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) => { + 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 {}; diff --git a/lib/image-pipeline/preview-renderer.ts b/lib/image-pipeline/preview-renderer.ts index 0775347..ce6e504 100644 --- a/lib/image-pipeline/preview-renderer.ts +++ b/lib/image-pipeline/preview-renderer.ts @@ -10,31 +10,76 @@ export type PreviewRenderResult = { 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 { + await new Promise((resolve) => { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => resolve()); + return; + } + + setTimeout(() => resolve(), 0); + }); +} + export async function renderPreview(options: { sourceUrl: string; steps: readonly PipelineStep[]; previewWidth: number; + signal?: AbortSignal; }): Promise { - 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 height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width)); - 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."); + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); } + const context = createPreviewContext(width, height); + context.drawImage(bitmap, 0, 0, width, height); const imageData = context.getImageData(0, 0, width, height); for (let index = 0; index < options.steps.length; index += 1) { - applyPipelineStep(imageData.data, options.steps[index]!, width, height); - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); + applyPipelineStep(imageData.data, options.steps[index]!, width, height, { + shouldAbort: () => Boolean(options.signal?.aborted), }); + await yieldToMainOrWorkerLoop(); + + if (options.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } } const histogram = computeHistogram(imageData.data); diff --git a/lib/image-pipeline/render-core.ts b/lib/image-pipeline/render-core.ts index 3e1c819..cc32480 100644 --- a/lib/image-pipeline/render-core.ts +++ b/lib/image-pipeline/render-core.ts @@ -7,6 +7,20 @@ import { type CurvePoint, } 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 { 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 rgbLut = buildLut(curves.points.rgb); const redLut = buildLut(curves.points.red); @@ -112,6 +130,10 @@ function applyCurves(pixels: Uint8ClampedArray, params: unknown): void { const invGamma = 1 / curves.levels.gamma; for (let index = 0; index < pixels.length; index += 4) { + if (shouldCheckAbort(index)) { + throwIfAborted(options); + } + const applyLevels = (value: number) => { const normalized = clamp((value - curves.levels.blackPoint) / levelRange, 0, 1); 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 saturationFactor = 1 + color.hsl.saturation / 100; const luminanceShift = color.hsl.luminance / 100; const hueShift = color.hsl.hue; for (let index = 0; index < pixels.length; index += 4) { + if (shouldCheckAbort(index)) { + throwIfAborted(options); + } + const currentRed = pixels[index] ?? 0; const currentGreen = pixels[index + 1] ?? 0; const currentBlue = pixels[index + 2] ?? 0; @@ -180,6 +210,7 @@ function applyLightAdjust( params: unknown, width: number, height: number, + options?: PipelineExecutionOptions, ): void { const light = normalizeLightAdjustData(params); const exposureFactor = Math.pow(2, light.exposure / 2); @@ -190,6 +221,10 @@ function applyLightAdjust( const centerY = height / 2; for (let y = 0; y < height; y += 1) { + if (y % 64 === 0) { + throwIfAborted(options); + } + for (let x = 0; x < width; x += 1) { const index = (y * width + x) * 4; let red = pixels[index] ?? 0; @@ -238,7 +273,11 @@ function pseudoNoise(seed: number): number { 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 sharpenBoost = detail.sharpen.amount / 500; 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); for (let index = 0; index < pixels.length; index += 4) { + if (shouldCheckAbort(index)) { + throwIfAborted(options); + } + let red = pixels[index] ?? 0; let green = pixels[index + 1] ?? 0; let blue = pixels[index + 2] ?? 0; @@ -293,21 +336,24 @@ export function applyPipelineStep( step: PipelineStep, width: number, height: number, + options?: PipelineExecutionOptions, ): void { + throwIfAborted(options); + if (step.type === "curves") { - applyCurves(pixels, step.params); + applyCurves(pixels, step.params, options); return; } if (step.type === "color-adjust") { - applyColorAdjust(pixels, step.params); + applyColorAdjust(pixels, step.params, options); return; } if (step.type === "light-adjust") { - applyLightAdjust(pixels, step.params, width, height); + applyLightAdjust(pixels, step.params, width, height, options); return; } if (step.type === "detail-adjust") { - applyDetailAdjust(pixels, step.params); + applyDetailAdjust(pixels, step.params, options); } } @@ -316,8 +362,9 @@ export function applyPipelineSteps( steps: readonly PipelineStep[], width: number, height: number, + options?: PipelineExecutionOptions, ): void { for (let index = 0; index < steps.length; index += 1) { - applyPipelineStep(pixels, steps[index]!, width, height); + applyPipelineStep(pixels, steps[index]!, width, height, options); } } diff --git a/lib/image-pipeline/render-types.ts b/lib/image-pipeline/render-types.ts index f616930..c3c8c86 100644 --- a/lib/image-pipeline/render-types.ts +++ b/lib/image-pipeline/render-types.ts @@ -36,6 +36,7 @@ export type RenderFullOptions = { steps: readonly PipelineStep[]; render: RenderOptions; limits?: RenderSizeLimits; + signal?: AbortSignal; }; export type RenderFullResult = { diff --git a/lib/image-pipeline/source-loader.ts b/lib/image-pipeline/source-loader.ts index 4c7fa34..94702c5 100644 --- a/lib/image-pipeline/source-loader.ts +++ b/lib/image-pipeline/source-loader.ts @@ -1,6 +1,19 @@ const imageBitmapCache = new Map>(); -export async function loadSourceBitmap(sourceUrl: string): Promise { +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 { if (!sourceUrl || sourceUrl.trim().length === 0) { throw new Error("Render sourceUrl is required."); } @@ -9,6 +22,19 @@ export async function loadSourceBitmap(sourceUrl: string): Promise 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); if (cached) { return await cached; diff --git a/lib/image-pipeline/worker-client.ts b/lib/image-pipeline/worker-client.ts new file mode 100644 index 0000000..48d7d7f --- /dev/null +++ b/lib/image-pipeline/worker-client.ts @@ -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(); + +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) => { + 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(args: { + kind: "preview" | "full"; + payload: PreviewWorkerPayload | RenderFullOptions; + signal?: AbortSignal; +}): Promise { + if (args.signal?.aborted) { + return Promise.reject(makeAbortError()); + } + + const worker = getWorker(); + const requestId = nextRequestId(); + + return new Promise((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 { + try { + return await runWorkerRequest({ + 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 { + try { + return await runWorkerRequest({ + 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); +}