135 lines
4.2 KiB
JavaScript
135 lines
4.2 KiB
JavaScript
/*
|
|
* Image optimization module (P1). When OffscreenCanvas is available the heavy
|
|
* pixel work runs in a dedicated Web Worker so the UI thread stays responsive
|
|
* during large-image processing. On browsers that lack OffscreenCanvas support
|
|
* (or when running inside a Worker is not possible) the module falls back to
|
|
* main-thread canvas operations.
|
|
*/
|
|
|
|
let worker = null;
|
|
let workerSupported = null;
|
|
|
|
function isWorkerSupported() {
|
|
if (workerSupported !== null) {
|
|
return workerSupported;
|
|
}
|
|
|
|
try {
|
|
/* OffscreenCanvas is required inside the Worker to draw without a DOM. */
|
|
workerSupported = typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
|
|
} catch {
|
|
workerSupported = false;
|
|
}
|
|
|
|
return workerSupported;
|
|
}
|
|
|
|
function getWorker() {
|
|
if (!worker && isWorkerSupported()) {
|
|
worker = new Worker('/js/image-worker.js');
|
|
}
|
|
|
|
return worker;
|
|
}
|
|
|
|
/*
|
|
* Public entry point. Validates the file against the image rules, then delegates
|
|
* the actual resize/compress work to the worker or the main-thread fallback.
|
|
*/
|
|
export async function optimizeImage(file, imageRules) {
|
|
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
|
|
throw new Error(`Unsupported file type: ${file.type}`);
|
|
}
|
|
|
|
if (imageRules?.oversizeBehavior === 'block' && file.size > imageRules.maxFileSizeBytes) {
|
|
throw new Error(`File exceeds limit: ${file.name}`);
|
|
}
|
|
|
|
const w = getWorker();
|
|
|
|
if (w) {
|
|
return optimizeInWorker(w, file, imageRules);
|
|
}
|
|
|
|
return optimizeOnMainThread(file, imageRules);
|
|
}
|
|
|
|
/* ── Worker path ────────────────────────────────────────────────────────── */
|
|
|
|
function optimizeInWorker(w, file, imageRules) {
|
|
return new Promise((resolve, reject) => {
|
|
const messageId = crypto.randomUUID();
|
|
|
|
function handler(event) {
|
|
if (event.data?.id !== messageId) {
|
|
return;
|
|
}
|
|
|
|
w.removeEventListener('message', handler);
|
|
w.removeEventListener('error', errorHandler);
|
|
|
|
if (event.data.error) {
|
|
reject(new Error(event.data.error));
|
|
} else {
|
|
resolve(event.data.result);
|
|
}
|
|
}
|
|
|
|
function errorHandler(event) {
|
|
w.removeEventListener('message', handler);
|
|
w.removeEventListener('error', errorHandler);
|
|
reject(new Error(event.message || 'Worker error'));
|
|
}
|
|
|
|
w.addEventListener('message', handler);
|
|
w.addEventListener('error', errorHandler);
|
|
w.postMessage({ id: messageId, file, imageRules });
|
|
});
|
|
}
|
|
|
|
/* ── Main-thread fallback ───────────────────────────────────────────────── */
|
|
|
|
async function optimizeOnMainThread(file, imageRules) {
|
|
const imageBitmap = await createImageBitmap(file);
|
|
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
|
|
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
|
|
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
const context = canvas.getContext('2d');
|
|
context.drawImage(imageBitmap, 0, 0, width, height);
|
|
|
|
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
|
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
|
|
|
|
const blob = await new Promise((resolve) => {
|
|
canvas.toBlob(resolve, targetMimeType, quality);
|
|
});
|
|
|
|
if (!blob) {
|
|
throw new Error(`Failed to optimize image: ${file.name}`);
|
|
}
|
|
|
|
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
|
|
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
|
|
}
|
|
|
|
return {
|
|
blob,
|
|
width,
|
|
height,
|
|
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
|
|
};
|
|
}
|
|
|
|
export function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
|
|
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
|
|
|
|
return {
|
|
width: Math.round(originalWidth * ratio),
|
|
height: Math.round(originalHeight * ratio)
|
|
};
|
|
}
|