This commit is contained in:
Stan
2026-04-19 21:14:16 +02:00
parent 0c74a75126
commit 28d167f11f
42 changed files with 5681 additions and 55 deletions
+134
View File
@@ -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)
};
}