Skip to content

Araluma/heic-normalize

Repository files navigation

heic-normalize

OpenSSF Best Practices

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
});

The problem

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.

What it does

  • Non-HEIC input passes through untouched. Detection is a 16-byte magic-number sniff (ISO-BMFF ftyp brand) — never the extension or MIME type, so files renamed to .jpg are still caught, and AVIF (which shares the ftyp box but decodes everywhere) is deliberately not matched.
  • Safari 17+/iOS: native decode. createImageBitmap uses 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-eval is enough; no unsafe-eval needed) and equally without any CSP.
  • Orientation-correct. The decoder applies the HEIF irot transform; the code deliberately does not re-apply EXIF orientation, which would double-rotate portrait shots.

Install

npm install heic-normalize

API

normalizeHeicFile(file: File, target?: "image/jpeg" | "image/png"): Promise<File>

Returns the same File when it isn't HEIC. Returns a new File (photo.heicphoto.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.

isHeicFile(file: File): Promise<boolean>

The 16-byte sniff on its own, if you only need detection.

HeicDecodeError

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;
}

Caveats

  • 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 File too — normalization is for the processing path, not archival.

License

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.

About

Accept iPhone HEIC photos in any web upload — normalize to JPEG/PNG at the input boundary (native Safari decode, lazy WASM fallback)

Topics

Resources

License

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors