Works with any backend npm install Zero dependencies

Convert iPhone HEIC photos to JPEG transparently before upload

iPhones default to HEIC since iOS 11, but Firefox and Safari can't display the format and most backends can't process it without a decoder. convertHeic: true transparently runs HEIC files through the browser's native ImageDecoder API (Chrome / Edge / Opera 94+), or a user-supplied polyfill, before the rest of the image pipeline kicks in. The server never sees HEIC - it sees clean JPEG with EXIF intact.

Try it: drop an IMG_XXXX.HEIC from an iPhone (AirDrop / Photos export). It appears in the queue with a .jpg filename and a smaller size than the original HEIC. Inspect the queue thumbnail - it renders directly in Firefox/Safari too because the conversion has already happened.
Three-tier conversion strategy
  1. Browser's ImageDecoder API - Chrome 94+, Edge 94+, Opera 80+. Decodes natively, ~3-10x faster than any JS polyfill.
  2. User-supplied heicDecoder - pass a function that returns Promise<Blob | ImageBitmap | { width, height, data }>. Typically wraps libheif-js.
  3. Fail-open - when neither is available, the original HEIC is uploaded unchanged. A heicConvertFailed event fires + the onWarn callback is called so you can log it.
Polyfill recipe (libheif-js)
// 1. Install: npm install libheif-js
// 2. Load it lazily on first HEIC encounter (don't blow up your bundle):

let heifModule = null;
async function loadLibheif() {
 if (!heifModule) heifModule = await import('libheif-js');
 return heifModule;
}

new MultipleUpload('#uploader', {
 uploadUrl: '/api/upload',
 convertHeic: true,
 heicDecoder: async (file) => {
 const libheif = await loadLibheif();
 const buf = await file.arrayBuffer();
 const decoder = new libheif.HeifDecoder();
 const images = decoder.decode(buf);
 const img = images[0];
 return new Promise((resolve) => {
 img.display({ data: new Uint8ClampedArray(img.get_width() * img.get_height() * 4),
 width: img.get_width(), height: img.get_height() }, resolve);
 });
 }
});
API
new MultipleUpload('#uploader', {
 convertHeic: true, // master switch
 heicDecoder: fn // optional polyfill
});

// Static helper for use outside an uploader instance:
const jpegFile = await MultipleUpload.convertHeicToJpeg(heicFile, {
 quality: 0.92, // JPEG quality 0..1
 maxWidth: 3000, // optional downscale
 maxHeight: 3000,
 heicDecoder: fn // optional polyfill
});

// Detection:
if (MultipleUpload.isHeicFile(file)) { ... }
Pair with image presets

HEIC conversion runs FIRST in the pipeline, so subsequent imagePreset / imageResize / imageCompress steps see a JPEG. This means:

new MultipleUpload('#uploader', {
 convertHeic: true,
 imagePreset: 'avatar' // 512x512 JPEG output
})

// iPhone HEIC -> JPEG (via ImageDecoder) -> 512x512 (via preset) -> upload
Browser support
  • Native ImageDecoder: Chrome 94+, Edge 94+, Opera 80+, Samsung Internet 19+
  • Polyfill required: Firefox (all), Safari (all - including Safari 17 ironically; Apple doesn't expose ImageDecoder to the web)
  • Mobile: Chrome Android works natively; iOS Safari needs the polyfill
  • Worker context: ImageDecoder is available in Workers - combine with Web Worker hashing for parallel processing