Files
lemonspace_app/lib/image-pipeline/geometry-transform.ts

147 lines
4.3 KiB
TypeScript

import type { PipelineStep } from "@/lib/image-pipeline/contracts";
import { normalizeCropResizeStepParams } from "@/lib/image-pipeline/adjustment-types";
type SupportedCanvas = HTMLCanvasElement | OffscreenCanvas;
type SupportedContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
export type GeometryTransformResult = {
canvas: SupportedCanvas;
context: SupportedContext;
width: number;
height: number;
};
type ApplyGeometryStepsOptions = {
source: CanvasImageSource;
sourceWidth?: number;
sourceHeight?: number;
steps: readonly PipelineStep[];
signal?: AbortSignal;
};
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
}
function createCanvasContext(width: number, height: number): {
canvas: SupportedCanvas;
context: SupportedContext;
} {
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("Geometry transform could not create a 2D context.");
}
return { canvas, context };
}
if (typeof OffscreenCanvas !== "undefined") {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Geometry transform could not create an offscreen 2D context.");
}
return { canvas, context };
}
throw new Error("Geometry transform is not available in this environment.");
}
function ensurePositiveDimension(name: string, value: number): number {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`Invalid ${name}. Expected a positive finite number.`);
}
return Math.max(1, Math.round(value));
}
export function partitionPipelineSteps(steps: readonly PipelineStep[]): {
geometrySteps: PipelineStep[];
tonalSteps: PipelineStep[];
} {
const geometrySteps: PipelineStep[] = [];
const tonalSteps: PipelineStep[] = [];
for (const step of steps) {
if (step.type === "crop") {
geometrySteps.push(step);
continue;
}
tonalSteps.push(step);
}
return { geometrySteps, tonalSteps };
}
export function applyGeometryStepsToSource(options: ApplyGeometryStepsOptions): GeometryTransformResult {
throwIfAborted(options.signal);
const sourceWidth =
options.sourceWidth ?? (options.source as { width?: number }).width ?? Number.NaN;
const sourceHeight =
options.sourceHeight ?? (options.source as { height?: number }).height ?? Number.NaN;
let currentWidth = ensurePositiveDimension("sourceWidth", sourceWidth);
let currentHeight = ensurePositiveDimension("sourceHeight", sourceHeight);
let current = createCanvasContext(currentWidth, currentHeight);
current.context.drawImage(options.source, 0, 0, currentWidth, currentHeight);
for (const step of options.steps) {
throwIfAborted(options.signal);
if (step.type !== "crop") {
continue;
}
const normalized = normalizeCropResizeStepParams(step.params);
const sourceX = Math.max(0, Math.floor(normalized.cropRect.x * currentWidth));
const sourceY = Math.max(0, Math.floor(normalized.cropRect.y * currentHeight));
const maxSourceWidth = Math.max(1, currentWidth - sourceX);
const maxSourceHeight = Math.max(1, currentHeight - sourceY);
const sourceWidth = Math.max(
1,
Math.min(maxSourceWidth, Math.round(normalized.cropRect.width * currentWidth)),
);
const sourceHeight = Math.max(
1,
Math.min(maxSourceHeight, Math.round(normalized.cropRect.height * currentHeight)),
);
const targetWidth = normalized.resize?.width ?? sourceWidth;
const targetHeight = normalized.resize?.height ?? sourceHeight;
const next = createCanvasContext(targetWidth, targetHeight);
next.context.drawImage(
current.canvas,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
targetWidth,
targetHeight,
);
current = next;
currentWidth = targetWidth;
currentHeight = targetHeight;
}
return {
canvas: current.canvas,
context: current.context,
width: currentWidth,
height: currentHeight,
};
}