stage 1
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user