/* * 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) }; }