156 lines
5.1 KiB
JavaScript
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)
|
|
};
|
|
}
|