runlocally

runlocally engineering notes

HEIC to JPG

How HEIC to JPG is built

By Geppetto · · Open HEIC to JPG →

These are the engineering notes for HEIC to JPG: the technologies it is built on, what each one is, and how it is used in the tool.

Tech used

This is the first of these engineering notes, so the building blocks below get a proper introduction — what each one is and how it works, not just its name. Later posts will reference them rather than re-explain.

The format: HEIC / HEIF

HEIC is Apple’s name for an image stored in HEIF (High Efficiency Image File Format). HEIF is an ISO container built on ISOBMFF — the same box structure as MP4 — and the pixel data inside is coded with HEVC (H.265), the modern video codec. That is what makes the files small, and also why a browser can’t decode them the way it decodes JPEG: it needs an HEVC-class decoder, and the format is patent-encumbered. A .heic can also hold several images (bursts, Live Photos) and EXIF metadata, including orientation.

WebAssembly + libheif

WebAssembly (WASM) is a portable binary instruction format that runs inside the browser’s sandbox at near-native speed. Its practical value here: it lets an existing, battle-tested C/C++ library run client-side instead of on a server. We use libheif — the reference C++ library for reading HEIF — compiled to WASM and published as libheif-js 1.19.8. It is loaded with a dynamic import('libheif-js') inside a Web Worker, and exposes a HeifDecoder that turns the container’s bytes into raw RGBA pixels.

Web Workers + OffscreenCanvas

A Web Worker is a script that runs on its own thread, so heavy work doesn’t freeze the page. A normal <canvas>, though, is tied to the DOM and therefore the main thread. OffscreenCanvas is a canvas decoupled from the DOM that a Worker can draw to; its convertToBlob({ type, quality }) encodes the canvas bitmap straight to a JPEG or PNG Blob. Together they let both the decode and the resize/encode happen off the main thread. Browsers without a working Worker-side 2D context (Safari 16 / iOS 16) fall back to a DOM <canvas> on the main thread — same pipeline, different host.

Service Worker (the PWA)

A Service Worker is a script the browser runs in the background, separate from any page, that acts as a programmable network proxy for everything under its scope. Once installed it can answer requests from a cache, which is what makes the tool work offline: it precaches the app shell and serves it with no network. Paired with a web manifest (name, icons, start URL), that turns the page into an installable PWA.

Site shell

Astro renders the page as static HTML at build time; only the converter UI hydrates, as a small Preact island. Everything above runs in that one island.

Implementation & operational notes

Decode off the main thread. HEIC decoding is the expensive step, so it always runs in a Web Worker. HeifDecoder.decode(buffer) returns the frames; for multi-image HEIC files we convert the first frame. libheif applies the EXIF orientation (irot/imir) during decode, so the output is already upright — no separate rotation pass. After each file we free the WASM heap so batch conversions don’t accumulate memory.

Two encode paths, picked at runtime. Modern browsers resize and encode inside the Worker via OffscreenCanvas.convertToBlob(). Safari 16 / iOS 16 ship the OffscreenCanvas class but not a working 2D context, so a capability probe (getContext('2d') + convertToBlob existence) falls back to transferring the decoded ImageData to the main thread and encoding on a DOM <canvas>. The ImageData buffer is transferred, not copied, to keep that hop cheap. Resizing uses drawImage with imageSmoothingQuality: 'high'.

Worker module format matters. The Worker is built as an ES module ('es', not 'iife') so it can share the resize/encode pipeline module with the main-thread fallback. With an IIFE bundle that shared import breaks under code-splitting.

Astro routing without base. The tool lives at runlocally.app/heic-to-jpg/. Astro’s base option rewrites URLs but does not nest the build output, which breaks on Cloudflare Pages (it serves the dist root, so the URL and the physical path disagree). Instead the pages are physically nested — src/pages/heic-to-jpg/index.astro, .../<locale>/index.astro — and bundles go to build.assets: 'heic-to-jpg/_assets'. URL and physical path then match, and the whole subtree sits under one prefix.

The _headers caching trap. Cloudflare Pages combines headers of the same name. An early version put Cache-Control: immutable on /*, which also pinned the HTML, the Service Worker, and the manifest — so returning visitors and the SW precache never saw updates. The fix: scope immutable to the content-hashed /heic-to-jpg/_assets/* only, and give the SW a must-revalidate instead.

One Service Worker for every language. The SW registers at /heic-to-jpg/sw.js with scope: '/heic-to-jpg/', which covers all locale pages (/heic-to-jpg/, /heic-to-jpg/ja/, …) under a single scope. Static assets are cache-first; HTML is network-first so content updates land.

Try it / source