Accept iPhone HEIC/HEIF photos in any web upload. One function normalizes them
to a standard JPEG/PNG File at the input boundary — the rest of your pipeline
never sees HEIC.
import { normalizeHeicFile } from "heic-normalize";
input.addEventListener("change", async () => {
const file = await normalizeHeicFile(input.files[0]); // HEIC → .jpg File; anything else passes through
// ...decode/resize/crop/upload as usual
});HEIC has been the iPhone camera default since iOS 11, but no browser except
Safari 17+ can decode HEVC-coded HEIC. <img>, createImageBitmap() and
canvas all fail on it in Chrome, Firefox and Edge — so any consumer upload
surface breaks on iPhone photos unless it handles HEIC explicitly.
- Non-HEIC input passes through untouched. Detection is a 16-byte
magic-number sniff (ISO-BMFF
ftypbrand) — never the extension or MIME type, so files renamed to.jpgare still caught, and AVIF (which shares theftypbox but decodes everywhere) is deliberately not matched. - Safari 17+/iOS: native decode.
createImageBitmapuses the OS codec — zero WASM downloaded for the audience that actually produces HEIC files. - Chrome/Firefox/Edge: lazy WASM fallback.
heic-to(libheif) is dynamically imported on the first HEIC file only (~1.2 MB). Your cold path ships none of it. - CSP-friendly. Uses
heic-to/csp, the eval-free build — works under a strict Content-Security-Policy (wasm-unsafe-evalis enough; nounsafe-evalneeded) and equally without any CSP. - Orientation-correct. The decoder applies the HEIF
irottransform; the code deliberately does not re-apply EXIF orientation, which would double-rotate portrait shots.
npm install heic-normalizeReturns the same File when it isn't HEIC. Returns a new File
(photo.heic → photo.jpg) when it is. JPEG is encoded at quality 0.92 — a
near-lossless intermediate meant to feed further processing. Pass
"image/png" when your downstream output is lossless.
The 16-byte sniff on its own, if you only need detection.
Thrown when a file is HEIC but could not be decoded (corrupt/truncated file,
or WASM decoder OOM). error.reason is "decode_failed" or
"encode_failed"; error.cause carries the underlying error. Everything else
passes through or resolves — this is the one failure you need to surface to
the user.
import { normalizeHeicFile, HeicDecodeError } from "heic-normalize";
try {
file = await normalizeHeicFile(file);
} catch (err) {
if (err instanceof HeicDecodeError) showToast("Couldn't read this photo — try exporting it as JPEG.");
else throw err;
}- Browser-only. Needs
File, canvas and (outside Safari) WASM. Importing it in SSR code is safe — browser APIs are only touched when the functions run. - Full-resolution decode. A 48 MP HEIC peaks at several hundred MB of RAM during decode. If you cap megapixels downstream, the cap applies after normalization — keep your own guard for extreme inputs.
- Quality is opinionated. 0.92 JPEG as an intermediate. If you need the
original bits, keep the original
Filetoo — normalization is for the processing path, not archival.
MIT. The WASM fallback dependency heic-to
is LGPL-3.0 (libheif); it stays an external dependency and is loaded
dynamically — this package does not bundle it.
Extracted from the production code of RoundCut — free, in-browser image tools (circle crop, background remover, compress, convert) — where it runs on every upload surface across 29 languages.