Dev.to · 4 min read

Decoding iPhone HEIC Photos in the Browser With libheif WASM (and the One-Line Bug That Broke It)

Decoding iPhone HEIC Photos in the Browser With libheif WASM (and the One-Line Bug That Broke It)

Every iPhone since iOS 11 saves photos as HEIC by default. Send one to a Windows laptop, an upload form or an older CMS and it often fails. The usual fix is an online converter that uploads your photo to a server. You don't need the server. A browser can decode HEIC itself, and where it can't, a WebAssembly build of libheif can. Here's how that pipeline works, including a bug in libheif-js that cost me an afternoon. Step 1: check the bytes, not the file name People rename files. A HEIC saved as .jpg exists, and so does a JPEG saved as .heic. So the first job is to read the file header. HEIF files are ISO-BMFF containers. Bytes 4 to 8 spell ftyp, and bytes 8 to 12 are the "major brand": const HEIC_BRANDS = ["heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"] function ascii(b, start, end) { let s = "" for (let i = start; i < end && i < b.length; i++) s += String.fromCharCode(b[i]) return s } async function isHeic(file) { const b = new Uint8Array(await file.slice(0, 12).arrayBuffer()) if (b.length < 12 || ascii(b, 4, 8) !== "ftyp") return false return HEIC_BRANDS.includes(ascii(b, 8, 12).toLowerCase()) } mif1 and msf1 matter. Some phones and editors write those generic brands instead of heic. Step 2: let the browser try first Safari 17 and later can decode HEIC natively. If createImageBitmap(file) works, you're done, and you never download any WASM: async function tryNative(file) { try { return await createImageBitmap(file, { imageOrientation: "from-image" }) } catch { return null // Chrome, Firefox and Edge on most systems end up here } } Step 3: fall back to libheif, loaded only when needed For everyone else, libheif compiled to WebAssembly does the decoding. It's about 520 KB gzipped, so import it lazily, the first time a HEIC file actually shows up: let libPromise = null function loadLibheif() { libPromise ??= import("libheif-js/libheif-wasm/libheif-bundle.mjs") .then(async (m) => { const lib = m.default() if (lib.ready) await lib.ready return lib }) .catch((err) => { libPromise = null // one failed chunk load must not break every later photo throw err }) return libPromise } Resetting libPromise in the catch matters. Without it, one flaky network moment poisons every photo after it. Step 4: pick the primary image, not the first one A HEIC file can hold several images: the photo, a thumbnail, a depth map. You want the one the container marks as primary. libheif-js has a method for exactly that on each image: const primary = images.find((im) => im.is_primary()) // don't do this In libheif-js 1.19.8, HeifImage.prototype.is_primary() calls a global function that doesn't exist, so it throws a ReferenceError. My tests used synthetic files with a single image, so they never reached that line. The first real phone photo did. The fix is to call the underlying embind export yourself and treat any failure as "not primary": function isPrimary(lib, im) { try { return im.handle !== undefined && !!lib.heif_image_handle_is_primary_image(im.handle) } catch { return false } } const primary = images.find((im) => isPrimary(lib, im)) ?? images[0] Lesson: test with real files from real phones. Synthetic files only exercise the code paths you already thought about. Step 5: refuse huge photos before you allocate Decoding gives you raw RGBA, four bytes per pixel. A 48 MP photo is about 190 MB before you've drawn anything. I refuse anything over 64 MP before decoding (a 64 MP RGBA buffer is about 256 MB), and free every image handle and the decoder in a finally block, including on failure. Orientation is the easy part. libheif applies the container's rotation and mirror boxes (irot, imir), so the pixels come out upright. When you write the JPG, set EXIF Orientation to 1, or viewers will rotate the photo a second time. Step 6: one worker per photo, and fewer on phones Decoding is CPU-heavy, so it runs in Web Workers. The tempting move is one worker per core. On a phone that crashes the tab, because memory runs out before the CPU does. So the pool size depends on the device: function pickConcurrency({ cores, deviceMemory, mobile }) { const c = cores && cores > 0 ? cores : 4 // A 48 MP decode peaks near 1 GB, and Android's low-memory killer // takes the whole tab, not just the worker. if (mobile || (deviceMemory !== undefined && deviceMemory

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Gadgets & Consumer Tech