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 { 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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,15 +10,11 @@ export type PreviewRenderResult = {
|
|||||||
histogram: HistogramData;
|
histogram: HistogramData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function renderPreview(options: {
|
type PreviewCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||||
sourceUrl: string;
|
type PreviewContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||||
steps: readonly PipelineStep[];
|
|
||||||
previewWidth: number;
|
|
||||||
}): Promise<PreviewRenderResult> {
|
|
||||||
const bitmap = await loadSourceBitmap(options.sourceUrl);
|
|
||||||
const width = Math.max(1, Math.round(options.previewWidth));
|
|
||||||
const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width));
|
|
||||||
|
|
||||||
|
function createPreviewContext(width: number, height: number): PreviewContext {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
@@ -27,14 +23,63 @@ export async function renderPreview(options: {
|
|||||||
throw new Error("Preview renderer could not create 2D 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, {
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
const width = Math.max(1, Math.round(options.previewWidth));
|
||||||
|
const height = Math.max(1, Math.round((bitmap.height / bitmap.width) * width));
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted.", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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