DEV Community

shaojie gong
shaojie gong

Posted on

Decoding iPhone HEIC photos in Chrome: libheif as WebAssembly, loaded on demand and kept out of the bundle

I'm the developer of MakeQuickWork, a small set of free browser tools. This post is about its HEIC to JPG converter and the two or three things that took longer than the conversion itself.

iPhones save photos as HEIC by default. Copy a few of them to a Windows laptop, or try to attach one to a form that only takes .jpg, and you find out how unevenly that format is supported. I wanted a converter that runs in the tab, so the photos are never uploaded: https://makequickwork.com/image/heic-to-jpg/

The interesting part is that the browser you're in decides how much work there is.

Two decode paths

Safari can decode HEIC itself. Chrome, Edge and Firefox can't, which covers most Windows and Android users, i.e. most of the people who actually have the problem. So the decoder tries the native path first and falls back to libheif compiled to WebAssembly:

if (format === "heic") {
  try {
    // Safari decodes HEIC natively, rotation included.
    const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
    source = bmp;
  } catch {
    // Everyone else: libheif (WebAssembly), loaded on first use.
    const { decodeHeic } = await import("./heic");
    const data = await decodeHeic(bytes);
    const c = newCanvas(data.width, data.height);
    c.getContext("2d")!.putImageData(data, 0, 0);
    source = c;
  }
}
Enter fullscreen mode Exit fullscreen mode

Both paths end on a canvas. From there it's canvas.toBlob() as JPG (quality Maximum, High or Medium) or PNG. Rotation is applied on both paths, so photos come out the right way up.

format doesn't come from the file extension or the MIME type. Windows often reports HEIC files with an empty MIME type, so the file input accepts .heic,.heif by extension, and the code then reads the ftyp box and checks the brands (heic, heix, mif1 and friends). A JPG renamed to .heic gets a clear "this is not a HEIC photo" message instead of a decoder crash.

Why the decoder isn't in the bundle

libheif is LGPL-3.0. I'm not a lawyer, but the spirit of the LGPL is that users should be able to swap the library for their own build. If I let the bundler inline it into a hashed chunk, that's hard to do in any practical sense.

So it's not bundled. A build script copies the unmodified libheif.js and libheif.wasm from libheif-js 1.23.5 to /vendor/libheif/, next to the licence text and a SOURCE.txt that points to the corresponding source and says you may replace the two files. The app loads them from that fixed path with a plain <script> tag.

A side effect I like: the decoder is about 1.4 MB, and it's a separate request that only happens when it's needed.

Two gotchas with the Emscripten build

It couldn't find its own .wasm. Loaded via a <script> tag on the main thread, this build failed to locate and fetch libheif.wasm by itself. The fix was to fetch it myself and hand it over as wasmBinary:

const res = await fetch("/vendor/libheif/libheif.wasm");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const wasmBinary = new Uint8Array(await res.arrayBuffer());

// Emscripten writes its exports onto the options object.
const mod: Record<string, unknown> = {
  wasmBinary,
  // HeifDecoder is attached right after onRuntimeInitialized, so wait one tick.
  onRuntimeInitialized: () => setTimeout(() => resolve(mod)),
};
factory(mod);
Enter fullscreen mode Exit fullscreen mode

HeifDecoder wasn't there yet. onRuntimeInitialized fires, you reach for mod.HeifDecoder, and it's undefined. The JS wrapper attaches it right after that callback returns. A setTimeout with no delay was enough. It's an ugly line, so it has a comment.

The loader promise is cached, and reset on failure, so a flaky connection gives a "check your connection and try again" message and the next attempt starts clean.

Lazy loading, with a test that holds me to it

The page itself loads only a few KB of JavaScript. The decoder is downloaded when the first HEIC is actually converted, and Safari, which decodes HEIC itself, doesn't need it. There's a Playwright test that opens the page and fails if any request to libheif happens before a file is chosen, plus one that converts the sample photo and checks that both vendor files were fetched from /vendor/libheif/.

(That screenshot is Chrome: it can't preview HEIC, so the rows show an icon instead of a thumbnail until they're converted.)

On my test files, three 1280×854 HEIC samples of 701.3 KB each, the JPGs at High quality came out at 417.7 KB. Those are small samples, not full-size iPhone photos, so don't read much into the ratio. Several files come back as a ZIP; one file downloads directly.

What the output doesn't keep

Going through a canvas has consequences, and I'd rather list them than have someone find out later:

  • No EXIF. GPS location, camera model and date are not carried over. For photos you're sending to someone, I count that as a side benefit.
  • Live Photos give you the still image only. Depth and HDR extras are dropped.
  • Colour. iPhones shoot in Display P3; the canvas saves sRGB, so very saturated colours can look slightly less intense on a wide-gamut screen.
  • Size. iPhone Safari refuses canvases above roughly 16.7 million pixels, so anything bigger is scaled to stay under ~16 MP, and the result row says so.
  • Only HEIC/HEIF. Other formats go to the separate Image Converter.

Checking that nothing is uploaded

The site is static (Astro on Cloudflare Pages), so there's no endpoint to upload to. An automated test still processes a file with each tool and fails on any POST, or on any third-party request other than Google Analytics (used for anonymous page counts), and also if an analytics event contains a file name. You can watch the Network tab yourself while converting. You do need a connection to load the page and, outside Safari, the decoder.

The open question for me is moving decoding into a Web Worker so a batch of large photos doesn't block the main thread. If you've run an Emscripten build like this in a worker, I'd like to hear what broke.

Top comments (0)