feat(canvas): move image pipeline rendering off main thread with worker fallback
This commit is contained in:
@@ -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<AbortController | null>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastAppliedAspectRatioRef = useRef<number | null>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ function resolveMimeType(format: RenderFormat): string {
|
||||
}
|
||||
|
||||
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({
|
||||
sourceWidth: bitmap.width,
|
||||
sourceHeight: bitmap.height,
|
||||
@@ -111,7 +113,15 @@ export async function renderFull(options: RenderFullOptions): Promise<RenderFull
|
||||
options.steps,
|
||||
resolvedSize.width,
|
||||
resolvedSize.height,
|
||||
{
|
||||
shouldAbort: () => 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);
|
||||
|
||||
166
lib/image-pipeline/image-pipeline.worker.ts
Normal file
166
lib/image-pipeline/image-pipeline.worker.ts
Normal 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 {};
|
||||
@@ -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<void> {
|
||||
await new Promise<void>((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<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 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<void>((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);
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type RenderFullOptions = {
|
||||
steps: readonly PipelineStep[];
|
||||
render: RenderOptions;
|
||||
limits?: RenderSizeLimits;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type RenderFullResult = {
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
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) {
|
||||
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.");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
305
lib/image-pipeline/worker-client.ts
Normal file
305
lib/image-pipeline/worker-client.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user