/** * Off-main-thread image encoder. * * Accepts a ``File``, validates it via magic bytes (ignoring the extension to * defeat rename-based spoofs), and either passes through or *normalizes* the * bytes so the resulting base64 data URL stays ≤ ``TARGET_MAX_BYTES``. The * normalization path uses ``createImageBitmap`` + ``OffscreenCanvas`` so the * full decode/resize/re-encode cycle never blocks the UI thread. * * Output contract: * ``{ok: true, dataUrl, mime, bytes, origBytes, normalized}`` on success, or * ``{ok: false, reason}`` for every recoverable failure — magic-bytes * mismatch, unsupported MIME, decode error, or a post-normalization payload * that *still* exceeds the budget (extreme aspect ratios). */ /// // --- Types ------------------------------------------------------------------- export type EncodeInput = { id: string; file: File; }; export type EncodeSuccess = { id: string; ok: true; dataUrl: string; mime: string; bytes: number; origBytes: number; /** True iff the Worker re-encoded the image to hit the size budget. */ normalized: boolean; }; export type EncodeFailure = { id: string; ok: false; reason: | "invalid_mime" | "magic_mismatch" | "too_large_after_normalize" | "decode_failed" | "io"; }; export type EncodeResponse = EncodeSuccess | EncodeFailure; // --- Budgets ----------------------------------------------------------------- /** Upper bound for the final base64-decoded payload. Matches the server-side * safeguard (8 MB) minus safety margin; anything this function yields should * safely pass ``_MAX_IMAGE_BYTES`` on the server. */ export const TARGET_MAX_BYTES = 6 * 1024 * 1024; /** Long-edge pixel cap when we resize a large image. 2048 keeps retina UIs * crisp while bounding decode cost and matching most LLM vision tiers' * internal downscale target. */ const NORMALIZE_MAX_EDGE = 2048; /** JPEG/WebP quality during normalization. 0.85 is the sweet spot — visually * lossless for content photography, ~30% smaller than libjpeg default. */ const WEBP_QUALITY = 0.85; /** PNG / GIF kept as PNG after normalization so crisp UI screenshots stay * lossless. JPEG / WebP re-encode as WebP for better compression. */ const NORMALIZE_LOSSY_MIMES = new Set(["image/jpeg", "image/webp"]); const SUPPORTED_MIMES = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", ]); // --- Magic bytes ------------------------------------------------------------- /** Sniff the first 12 bytes; returns the canonical MIME or ``null``. * * Covers PNG, JPEG, WebP, GIF — the same whitelist honoured by the server. */ export function sniffImageMime(bytes: Uint8Array): string | null { if (bytes.length >= 8) { if ( bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a ) { return "image/png"; } } if (bytes.length >= 3) { if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return "image/jpeg"; } } if (bytes.length >= 6) { const g1 = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 && bytes[5] === 0x61; if (g1 && (bytes[4] === 0x37 || bytes[4] === 0x39)) { return "image/gif"; } } if (bytes.length >= 12) { const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46; const webp = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50; if (riff && webp) return "image/webp"; } return null; } // --- Encoder ----------------------------------------------------------------- function bufferToBase64(buf: ArrayBuffer): string { // ``btoa`` can't take large strings — chunk through 32 KB windows. const bytes = new Uint8Array(buf); let binary = ""; const CHUNK = 0x8000; for (let i = 0; i < bytes.length; i += CHUNK) { binary += String.fromCharCode.apply( null, bytes.subarray(i, i + CHUNK) as unknown as number[], ); } return self.btoa(binary); } function computeScaledDims( srcW: number, srcH: number, maxEdge: number, ): { w: number; h: number } { const longest = Math.max(srcW, srcH); if (longest <= maxEdge) return { w: srcW, h: srcH }; const scale = maxEdge / longest; return { w: Math.max(1, Math.round(srcW * scale)), h: Math.max(1, Math.round(srcH * scale)), }; } async function normalize( file: File, sourceMime: string, ): Promise<{ dataUrl: string; mime: string; bytes: number } | { error: EncodeFailure["reason"] }> { // Re-encode paths: JPEG/WebP → WebP q=0.85; PNG/GIF → PNG (keep crisp). const targetMime = NORMALIZE_LOSSY_MIMES.has(sourceMime) ? "image/webp" : "image/png"; let bitmap: ImageBitmap; try { bitmap = await createImageBitmap(file); } catch { return { error: "decode_failed" }; } const { w, h } = computeScaledDims(bitmap.width, bitmap.height, NORMALIZE_MAX_EDGE); try { const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext("2d", { alpha: true }); if (!ctx) { bitmap.close(); return { error: "decode_failed" }; } ctx.imageSmoothingQuality = "high"; ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close(); const options: ImageEncodeOptions = { type: targetMime }; if (targetMime === "image/webp") options.quality = WEBP_QUALITY; const blob = await canvas.convertToBlob(options); if (blob.size > TARGET_MAX_BYTES) { return { error: "too_large_after_normalize" }; } const buf = await blob.arrayBuffer(); const dataUrl = `data:${targetMime};base64,${bufferToBase64(buf)}`; return { dataUrl, mime: targetMime, bytes: blob.size }; } catch { try { bitmap.close(); } catch { // bitmap already closed } return { error: "decode_failed" }; } } export async function encodeImageInWorker( input: EncodeInput, ): Promise { const { id, file } = input; const origBytes = file.size; let buffer: ArrayBuffer; try { buffer = await file.arrayBuffer(); } catch { return { id, ok: false, reason: "io" }; } const head = new Uint8Array(buffer.slice(0, 12)); const sniffed = sniffImageMime(head); if (!sniffed) return { id, ok: false, reason: "magic_mismatch" }; if (!SUPPORTED_MIMES.has(sniffed)) { return { id, ok: false, reason: "invalid_mime" }; } // Defend against MIME spoofing: the declared ``file.type`` can lie. if (file.type && SUPPORTED_MIMES.has(file.type) && file.type !== sniffed) { // Trust the magic bytes; proceed with the sniffed MIME. } if (origBytes <= TARGET_MAX_BYTES) { const dataUrl = `data:${sniffed};base64,${bufferToBase64(buffer)}`; return { id, ok: true, dataUrl, mime: sniffed, bytes: origBytes, origBytes, normalized: false, }; } const result = await normalize(file, sniffed); if ("error" in result) { return { id, ok: false, reason: result.error }; } return { id, ok: true, dataUrl: result.dataUrl, mime: result.mime, bytes: result.bytes, origBytes, normalized: true, }; } // --- Worker boot ------------------------------------------------------------- // Only attach the message listener when running *inside* a Worker so the same // module can be imported by tests (and by the thin ``imageEncode.ts`` wrapper // in the main thread, which also calls ``encodeImageInWorker`` as a // fall-through path when the Worker isn't available). declare const self: DedicatedWorkerGlobalScope; if ( typeof self !== "undefined" && typeof (self as unknown as { importScripts?: unknown }).importScripts === "function" ) { self.addEventListener("message", async (event: MessageEvent) => { const response = await encodeImageInWorker(event.data); self.postMessage(response); }); }