Files
CLProject/public/js/images.js
T
2026-04-20 21:04:54 +02:00

156 lines
5.1 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.
*
* EXIF preservation: Canvas operations strip all metadata. After resize/compress
* we re-inject the original EXIF APP1 segment into the output JPEG so that
* camera info, GPS, date-taken etc. survive the optimization.
*/
import { extractExifSegment, insertExifIntoJpeg } from './exif.js';
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.
* After optimization, EXIF metadata from the original file is re-injected into
* the output JPEG so that camera/GPS/date info is preserved.
*/
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}`);
}
/* Extract raw EXIF segment from original JPEG before canvas strips it */
let exifSegment = null;
if (file.type === 'image/jpeg') {
try {
const originalBuffer = await file.arrayBuffer();
exifSegment = extractExifSegment(originalBuffer);
} catch { /* non-critical — proceed without EXIF */ }
}
const w = getWorker();
const result = w
? await optimizeInWorker(w, file, imageRules)
: await optimizeOnMainThread(file, imageRules);
/* Re-inject EXIF into the optimized JPEG */
if (exifSegment && result.blob.type === 'image/jpeg') {
result.blob = await insertExifIntoJpeg(result.blob, exifSegment);
}
return result;
}
/* ── 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)
};
}