import { runFullPipelineWithBackendRouter } from "@/lib/image-pipeline/backend/backend-router"; import { resolveRenderSize } from "@/lib/image-pipeline/render-size"; import { RENDER_FORMAT_TO_MIME, type RenderFormat, type RenderFullOptions, type RenderFullResult, } from "@/lib/image-pipeline/render-types"; import { applyGeometryStepsToSource, partitionPipelineSteps, } from "@/lib/image-pipeline/geometry-transform"; import { loadRenderSourceBitmap } from "@/lib/image-pipeline/source-loader"; type SupportedCanvas = HTMLCanvasElement | OffscreenCanvas; type SupportedContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; function normalizeJpegQuality(value: number | undefined): number { if (value === undefined) { return 0.92; } if (!Number.isFinite(value)) { throw new Error("Invalid render options: jpegQuality must be a finite number."); } return Math.max(0, Math.min(1, value)); } 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("Render bridge 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("Render bridge could not create an offscreen 2D context."); } return { canvas, context, }; } throw new Error("Canvas rendering is not available in this environment."); } async function canvasToBlob( canvas: SupportedCanvas, mimeType: string, quality: number | undefined, ): Promise { if (typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas) { return await canvas.convertToBlob({ type: mimeType, quality }); } return await new Promise((resolve, reject) => { (canvas as HTMLCanvasElement).toBlob( (blob) => { if (!blob) { reject(new Error("Render bridge could not encode output blob.")); return; } resolve(blob); }, mimeType, quality, ); }); } function resolveMimeType(format: RenderFormat): string { const mimeType = RENDER_FORMAT_TO_MIME[format]; if (!mimeType) { throw new Error(`Unsupported render format '${format}'.`); } return mimeType; } export async function renderFull(options: RenderFullOptions): Promise { const { signal } = options; const bitmap = await loadRenderSourceBitmap({ sourceUrl: options.sourceUrl, sourceComposition: options.sourceComposition, signal, }); const { geometrySteps, tonalSteps } = partitionPipelineSteps(options.steps); const geometryResult = applyGeometryStepsToSource({ source: bitmap, sourceWidth: bitmap.width, sourceHeight: bitmap.height, steps: geometrySteps, signal, }); const resolvedSize = resolveRenderSize({ sourceWidth: geometryResult.width, sourceHeight: geometryResult.height, render: options.render, limits: options.limits, }); const { canvas, context } = createCanvasContext(resolvedSize.width, resolvedSize.height); context.drawImage(geometryResult.canvas, 0, 0, resolvedSize.width, resolvedSize.height); const imageData = context.getImageData(0, 0, resolvedSize.width, resolvedSize.height); runFullPipelineWithBackendRouter({ pixels: imageData.data, steps: tonalSteps, width: resolvedSize.width, height: resolvedSize.height, executionOptions: { 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); const quality = options.render.format === "jpeg" ? normalizeJpegQuality(options.render.jpegQuality) : null; const blob = await canvasToBlob(canvas, mimeType, quality ?? undefined); return { blob, width: resolvedSize.width, height: resolvedSize.height, mimeType, format: options.render.format, quality, sizeBytes: blob.size, sourceWidth: bitmap.width, sourceHeight: bitmap.height, wasSizeClamped: resolvedSize.wasClamped, }; } export const bridge = { renderFull, };