export const HEIC_MAX_INPUT_BYTES = 32 * 1024 * 1024; export const HEIC_MAX_PIXELS = 64_000_000; export const HEIC_DECODE_TIMEOUT_MS = 45_000; export const heicError = code => Object.assign(new Error(code), { code }); export function heicOutputProfile(options = {}) { return { maxDimension: Number.isFinite(options.maxDimension) ? Math.max(1, Math.min(4096, Math.floor(options.maxDimension))) : 1600, quality: Number.isFinite(options.quality) ? Math.max(0.2, Math.min(1, options.quality)) : 0.82, maxSizeBytes: Number.isFinite(options.maxSizeBytes) ? Math.max(1, Math.min(HEIC_MAX_INPUT_BYTES, Math.floor(options.maxSizeBytes))) : 2 * 1024 * 1024, maxPixels: Number.isFinite(options.maxPixels) ? Math.max(1, Math.min(HEIC_MAX_PIXELS, Math.floor(options.maxPixels))) : HEIC_MAX_PIXELS, }; } export function validateHeicDimensions(width, height, maxPixels = HEIC_MAX_PIXELS) { if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > 32768 || height > 32768 || width * height > Math.min(maxPixels, HEIC_MAX_PIXELS)) throw heicError('HEIC_IMAGE_TOO_LARGE'); } /** Decode only the primary image, after bounded metadata checks. No DOM or I/O. */ export async function decodeHeicPixels(buffer, libheif, options = {}) { if (!(buffer instanceof ArrayBuffer) || !buffer.byteLength || buffer.byteLength > HEIC_MAX_INPUT_BYTES) throw heicError('HEIC_INPUT_TOO_LARGE'); let decoder, images = []; try { decoder = new libheif.HeifDecoder(); images = decoder.decode(new Uint8Array(buffer)); if (!Array.isArray(images) || !images.length || images.length > 64) throw heicError('HEIC_DECODE_FAILED'); const primary = images.filter(image => image.is_primary?.()); const image = primary.length === 1 ? primary[0] : images.length === 1 ? images[0] : null; if (!image) throw heicError('HEIC_PRIMARY_IMAGE_MISSING'); const width = image.get_width(), height = image.get_height(); validateHeicDimensions(width, height, heicOutputProfile(options).maxPixels); const pixels = { width, height, data: new Uint8ClampedArray(width * height * 4) }; await new Promise((resolve, reject) => image.display(pixels, decoded => { if (!decoded || decoded.width !== width || decoded.height !== height || decoded.data?.length !== width * height * 4) reject(heicError('HEIC_DECODE_FAILED')); else resolve(decoded); })); // libheif applies HEIF rotation/mirror/crop transforms during decode; applying // EXIF orientation again here would rotate iPhone photos twice. return pixels; } finally { if (Array.isArray(images)) images.forEach(image => image.free?.()); if (decoder?.decoder) { libheif.heif_context_free(decoder.decoder); decoder.decoder = null; } } } /** Resize before canvas allocation, then encode a bounded, ordinary JPEG. */ export async function encodeHeicPixels(pixels, options = {}, environment = globalThis) { const profile = heicOutputProfile(options); validateHeicDimensions(pixels.width, pixels.height, profile.maxPixels); const scale = Math.min(1, profile.maxDimension / Math.max(pixels.width, pixels.height)); let width = Math.max(1, Math.round(pixels.width * scale)); let height = Math.max(1, Math.round(pixels.height * scale)); let bitmap, canvas; try { bitmap = await environment.createImageBitmap(new environment.ImageData(pixels.data, pixels.width, pixels.height), { resizeWidth: width, resizeHeight: height, resizeQuality: 'high', }); canvas = new environment.OffscreenCanvas(width, height); // Three sizes and four qualities bound work even for a pathological image. for (let resize = 0; resize < 3; resize++) { canvas.width = width; canvas.height = height; const context = canvas.getContext('2d', { alpha: false }); if (!context) throw heicError('HEIC_CONVERSION_FAILED'); context.fillStyle = '#fff'; context.fillRect(0, 0, width, height); context.drawImage(bitmap, 0, 0, width, height); for (let step = 0; step < 4; step++) { const quality = Math.max(0.2, profile.quality - step * 0.12); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); if (blob?.type === 'image/jpeg' && blob.size > 0 && blob.size <= profile.maxSizeBytes) return blob; if (quality === 0.2) break; } width = Math.max(1, Math.floor(width * 0.75)); height = Math.max(1, Math.floor(height * 0.75)); } throw heicError('HEIC_OUTPUT_TOO_LARGE'); } finally { bitmap?.close?.(); if (canvas) { canvas.width = 1; canvas.height = 1; } } }