import type { BrowserImageCodec, DecodedImage, ImageDimensions, ImageEncodeCandidate, RenderedImage, } from './image-attachment-compression'; interface ImageDecoderTrackLike { frameCount: number; } interface ImageDecoderLike { tracks: { ready: Promise; selectedTrack?: ImageDecoderTrackLike | null; }; close: () => void; } interface ImageDecoderConstructorLike { new (init: { data: ArrayBuffer | ReadableStream; type: string; preferAnimation: boolean; }): ImageDecoderLike; isTypeSupported: (type: string) => Promise; } function getImageDecoderConstructor(): ImageDecoderConstructorLike | undefined { return (globalThis as typeof globalThis & { ImageDecoder?: ImageDecoderConstructorLike; }).ImageDecoder; } function createAbortError(): DOMException { return new DOMException('Image processing was aborted', 'AbortError'); } function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted) throw signal.reason ?? createAbortError(); } async function hasStaticImageSignature( blob: Blob, mimeType: string, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const bytes = new Uint8Array(await blob.slice(0, 3).arrayBuffer()); throwIfAborted(signal); if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') { return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; } return bytes[0] === 0x42 && bytes[1] === 0x4d; } async function inspectFrameCount( blob: Blob, mimeType: string, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const normalized = mimeType.trim().toLowerCase(); if ( normalized === 'image/jpeg' || normalized === 'image/jpg' || normalized === 'image/bmp' ) { return await hasStaticImageSignature(blob, normalized, signal) ? 1 : null; } const ImageDecoderConstructor = getImageDecoderConstructor(); if (!ImageDecoderConstructor) return null; if (!(await ImageDecoderConstructor.isTypeSupported(normalized))) return null; throwIfAborted(signal); let decoder: ImageDecoderLike; try { decoder = new ImageDecoderConstructor({ data: blob.stream(), type: normalized, preferAnimation: true, }); } catch { throwIfAborted(signal); const data = await blob.arrayBuffer(); throwIfAborted(signal); decoder = new ImageDecoderConstructor({ data, type: normalized, preferAnimation: true, }); } let closed = false; const close = () => { if (closed) return; closed = true; decoder.close(); }; const abort = () => close(); signal?.addEventListener('abort', abort, { once: true }); try { await decoder.tracks.ready; throwIfAborted(signal); const frameCount = decoder.tracks.selectedTrack?.frameCount; return Number.isInteger(frameCount) && (frameCount ?? 0) > 0 ? frameCount ?? null : null; } finally { signal?.removeEventListener('abort', abort); close(); } } async function decodeWithImageElement( blob: Blob, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const url = URL.createObjectURL(blob); let revoked = false; const revokeUrl = () => { if (revoked) return; revoked = true; URL.revokeObjectURL(url); }; try { return await new Promise((resolve, reject) => { const image = new Image(); let settled = false; let abort = () => {}; const removeAbortListener = () => signal?.removeEventListener('abort', abort); const dispose = () => { try { image.src = ''; } catch { // Preserve fail-open processing when browser image cleanup fails. } finally { revokeUrl(); } }; abort = () => { if (settled) return; settled = true; dispose(); removeAbortListener(); reject(signal?.reason ?? createAbortError()); }; image.decoding = 'async'; image.onload = () => { if (settled) return; settled = true; removeAbortListener(); resolve({ source: image, width: image.naturalWidth, height: image.naturalHeight, dispose, }); }; image.onerror = () => { if (settled) return; settled = true; dispose(); removeAbortListener(); reject(new Error('Image element decode failed')); }; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) { abort(); return; } image.src = url; }); } catch (error) { revokeUrl(); throw error; } } async function decode( blob: Blob, options: { imageOrientation: 'from-image' }, signal?: AbortSignal, ): Promise { throwIfAborted(signal); if (typeof globalThis.createImageBitmap !== 'function') { return await decodeWithImageElement(blob, signal); } const bitmap = await globalThis.createImageBitmap(blob, { imageOrientation: options.imageOrientation, }); if (signal?.aborted) { bitmap.close(); throw signal.reason ?? createAbortError(); } return { source: bitmap, width: bitmap.width, height: bitmap.height, dispose: () => bitmap.close(), }; } function render( decoded: DecodedImage, target: ImageDimensions, signal?: AbortSignal, ): RenderedImage { throwIfAborted(signal); const canvas = document.createElement('canvas'); canvas.width = target.width; canvas.height = target.height; const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) throw new Error('2D canvas is unavailable'); context.clearRect(0, 0, target.width, target.height); context.drawImage(decoded.source, 0, 0, target.width, target.height); const pixels = context.getImageData(0, 0, target.width, target.height).data; let hasAlpha = false; for (let index = 3; index < pixels.length; index += 4) { if (pixels[index] < 255) { hasAlpha = true; break; } } const rendered = { canvas, hasAlpha, dispose: () => { canvas.width = 1; canvas.height = 1; }, }; try { throwIfAborted(signal); return rendered; } catch (error) { rendered.dispose(); throw error; } } async function encode( rendered: RenderedImage, candidate: ImageEncodeCandidate, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const blob = await new Promise((resolve, reject) => { let settled = false; let abort = () => {}; const removeAbortListener = () => signal?.removeEventListener('abort', abort); abort = () => { if (settled) return; settled = true; removeAbortListener(); reject(signal?.reason ?? createAbortError()); }; signal?.addEventListener('abort', abort, { once: true }); try { rendered.canvas.toBlob( (result) => { if (settled) return; settled = true; removeAbortListener(); resolve(result); }, candidate.mimeType, candidate.quality, ); } catch (error) { settled = true; removeAbortListener(); reject(error); } }); throwIfAborted(signal); if (!blob) return null; return blob.type.trim().toLowerCase() === candidate.mimeType ? blob : null; } export const browserImageCodec: BrowserImageCodec = { inspectFrameCount, decode, render, encode, };