@pixagram/heic
v0.1.1
Published
Detect HEIC/HEIF files and decode them to RGBA, ImageData, PNG or JPEG in the browser — pure-Rust `heic` decoder compiled to WebAssembly (scalar + SIMD builds).
Maintainers
Readme
@pixagram/heic
Detect HEIC/HEIF files and decode them in the browser — to raw RGBA, an
ImageData, or a PNG/JPEG/WebP Blob/File. Powered by the pure-Rust
heic decoder (v0.1.6, #![forbid(unsafe_code)])
compiled to WebAssembly. No native dependencies, no server round-trip.
License: the decoder is AGPL-3.0-only OR Imazen commercial, and HEVC is patent-encumbered. Read LICENSE.md before shipping.
import { isHeic, toBlob, toImageData } from '@pixagram/heic';
input.addEventListener('change', async () => {
const file = input.files[0];
if (await isHeic(file)) { // reads 272 bytes, no wasm loaded
const jpeg = await toBlob(file, { type: 'image/jpeg', quality: 0.9 });
// …upload `jpeg`, or:
const pixels = await toImageData(file); // draw it, feed it to a pipeline…
}
});Install
npm install @pixagram/heicESM only. Ships two wasm builds (wasm/scalar, wasm/simd); the loader picks
one at runtime and the other is never fetched.
What it does
| Function | Returns | Loads wasm? |
|---|---|---|
| sniff(bytes) / isHeicSync(bytes) | brand-level classification of an ISOBMFF header | no |
| isHeic(input) | Promise<boolean> — HEVC-coded HEIF, from content (reads only the header of a Blob) | no |
| isHeicFilename(name) | extension hint (.heic .heif .heics .heifs .hif) | no |
| probe(input) | dimensions, bit depth, alpha, metadata presence, CICP colour info — no pixels decoded | yes |
| decode(input, options?) | { width, height, data: Uint8ClampedArray, hasAlpha, color } — RGBA8, rotation applied | yes |
| toImageData(input, options?) | ImageData sharing the decoded buffer | yes |
| toBlob(input, options?) | PNG (default) / JPEG / WebP Blob via the platform canvas encoder | yes |
| toFile(input, options?) | same as toBlob, as a File named after the input | yes |
| decodeThumbnail(input) | the embedded preview, or null | yes |
| extractExif / extractXmp / extractIcc(input) | raw bytes or null | yes |
| init(options?), resetWasm(), decoderInfo(), isSimdSupported() | loader control and diagnostics | — |
input is a Blob/File, ArrayBuffer or any typed-array view. Views are
used in place; a Blob is read once.
Detection semantics
sniff parses the ftyp box and reports { isHeif, isHeic, codec, majorBrand, compatibleBrands }.
isHeicistruefor HEVC brands (heic heix hevc hevx heim heis hevm hevs) and for generically-branded HEIF (mif1/msf1) that is not AVIF. Those files carry HEVC in practice;probe()is the definitive check.- AVIF (
avif/avis) isisHeif: true, isHeic: false, codec: 'av1'— same container, not decodable here. - Do not trust
File.type: browsers frequently report''for.heic.
Decode options
await decode(file, {
limits: { maxWidth: 16384, maxHeight: 16384, maxPixels: 100_000_000, maxMemoryBytes: 2 * 2 ** 30 },
});Those are the defaults; 0 disables a limit. Limits are checked against the
header before any pixel buffer is allocated (LIMIT_EXCEEDED). A 48 MP
iPhone photo is 48,000,000 pixels and needs roughly 260 MB of wasm memory.
Encode options (toBlob, toFile, encodeToBlob)
await toBlob(file, {
type: 'image/jpeg', // 'image/png' (default) | 'image/jpeg' | 'image/webp'
quality: 0.9, // lossy formats, 0–1, default 0.92
background: '#fff', // composited under alpha; default '#ffffff' for JPEG, null otherwise
colorSpace: 'auto', // 'auto' | 'srgb' | 'display-p3' — see Colour below
});If the browser cannot encode the requested type it would normally hand back a
PNG silently; this package detects that and throws UNSUPPORTED_OUTPUT_TYPE.
Errors
Everything throws HeicError with a stable code:
import { isHeicError } from '@pixagram/heic';
try { await decode(file); }
catch (e) {
if (isHeicError(e) && e.code === 'NOT_HEIF') … // not a HEIF container
if (isHeicError(e) && e.code === 'UNSUPPORTED_CODEC') … // AVIF / JPEG-in-HEIF
if (isHeicError(e) && e.code === 'LIMIT_EXCEEDED') …
}Decoder codes: NOT_HEIF NEED_MORE_DATA INVALID_CONTAINER INVALID_DATA UNSUPPORTED UNSUPPORTED_CODEC NO_PRIMARY_IMAGE HEVC_DECODE BUFFER_TOO_SMALL LIMIT_EXCEEDED OUT_OF_MEMORY CANCELLED SINK DECODE_ERROR PROBE_ERROR.
Wrapper codes: INVALID_INPUT WASM_LOAD_FAILED WASM_TRAP NO_CANVAS NO_IMAGEDATA UNSUPPORTED_OUTPUT_TYPE ENCODE_FAILED.
Corrupt input returns an error; it does not trap. If the module ever does trap
(WASM_TRAP), the instance is discarded and the next call reloads it.
Loading the wasm
By default the glue resolves heic_wasm_bg.wasm with
new URL('…', import.meta.url) and fetches it — this works unbundled, in
webpack 5 and in Vite builds. Two knobs exist for setups that need them:
import { init } from '@pixagram/heic';
import wasmUrl from '@pixagram/heic/wasm/simd/heic_wasm_bg.wasm?url'; // Vite
await init({ variant: 'simd', wasmUrl }); // explicit URL (must match the variant)
await init({ wasmSource: fetch('/static/heic.wasm') }); // or bytes / Response / WebAssembly.Module- webpack 5: works out of the box — both
.wasmfiles are emitted as hashed assets and one is fetched at runtime. The package contains no computedimport()/require, so there is no "Critical dependency" warning; the remaining "asset size limit" hint is informational (raiseperformance.maxAssetSizeor filter.wasminperformance.assetFilter). - Vite: add
optimizeDeps: { exclude: ['@pixagram/heic'] }so the dev server does not pre-bundle the glue away from its.wasm. - Node ≥ 20.16 / 22.3: the loader reads the file from disk itself via
process.getBuiltinModule(nofetch(file:), noimport('node:fs')that would leak into browser bundles). Older Node: passwasmSource.decode/probe/metadata work,toBlob/toImageDataneed a canvas (NO_CANVAS/NO_IMAGEDATA). init()is idempotent; calling it up front hides the ~50 ms load from the first conversion. PasswasmUrl/wasmSourceon the first call (or afterresetWasm()); once an instance exists they are ignored. Callinginit({ variant: 'scalar' })aftersimdloaded switches builds.
Workers
Decoding a 12 MP photo takes a few hundred milliseconds of pure CPU and the
PNG encode adds more; do it off the main thread. Every function works in a
Worker (encoding uses OffscreenCanvas):
// worker.ts
import { toBlob } from '@pixagram/heic';
self.onmessage = async ({ data: file }) => {
self.postMessage(await toBlob(file, { type: 'image/jpeg' }));
};decode() results are plain objects; data.buffer can be transferred.
Performance and memory
Node 22 / V8, median of 7 runs, this package's fixtures:
| image | scalar | simd | |---|---|---| | 1280×854 (libheif example) | 154 ms | 151 ms | | 2560×1440 | 318 ms | 337 ms | | 1440×960 | 176 ms | 170 ms |
The SIMD build is within noise of scalar with heic 0.1.6: its wasm128
kernels cover colour conversion, IDCT/IDST and dequantisation, but HEVC intra
decode time is dominated by CABAC entropy decoding and intra prediction, which
are scalar. The dual-build plumbing is kept because it costs nothing at runtime
and upstream is actively adding wasm128 kernels. npm run bench reproduces the table.
Wasm linear memory only grows. After a large decode, decoderInfo().memoryBytes
shows the high-water mark; resetWasm() drops the instance so the next call
starts from a fresh, small one.
Grid images (most iPhone photos) use the decoder's streaming path, which skips the full-frame YCbCr intermediate (~60 % lower peak memory).
Colour
decode()applies the file's rotation/mirror transforms; output is display-oriented.- YCbCr→RGB uses the matrix and range the file declares (BT.601/709/2020, full/limited).
- Recent iPhones tag Display P3 primaries (CICP 12). With
colorSpace: 'auto'theImageData/canvas is created asdisplay-p3where the browser supports it (Chrome, Safari), so PNG/JPEG exports carry the right profile. Where it does not, pixels are still correct but wide-gamut colours look slightly duller when interpreted as sRGB.decoded.colorexposes the CICP codes andextractIcc()the embedded profile for callers who colour-manage themselves. - 10-bit sources are downconverted to 8-bit. HDR transfer functions (PQ/HLG)
are reported in
color.transferbut not tone-mapped.
Limitations
- HEVC I-frames only — fine for stills, no video.
- AVIF, JPEG-in-HEIF and H.264-in-HEIF are detected (
UNSUPPORTED_CODEC) but not decoded. - No cancellation mid-decode (the call is synchronous inside wasm); use a Worker and terminate it if you need to abort.
- PNG/JPEG encoding relies on the platform canvas. A pure-wasm encoder can be added behind a build flag if server-side conversion is needed.
- The upstream decoder reports 118/162 HEIF test files and 49/49 HEVC
conformance vectors passing; some exotic files may fail with
UNSUPPORTED.
Building from source
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version "$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/version = |"/,""); print; exit}' rust/Cargo.lock)"
# optional but recommended: wasm-opt from binaryen ≥ 119 (older releases reject
# the bulk-memory / multivalue opcodes current rustc emits)
npm install
npm run build # scripts/build-wasm.sh (both variants) + tsc
npm test # node --test; encode tests need @napi-rs/canvas (dev dep) or skip
npm run benchscripts/build-wasm.sh refuses to run if the wasm-bindgen CLI version differs
from the crate version in rust/Cargo.lock, and it patches the generated glue
to make init() re-entrant (the loader owns idempotency; see comments there).
Layout:
rust/ wasm-bindgen binding crate (probe / decode / thumbnail / metadata, typed error codes)
scripts/ build-wasm.sh, bench.mjs
src/ detect.ts · loader.ts · encode.ts · errors.ts · types.ts · index.ts
wasm/{scalar,simd}/ generated glue + .wasm (committed build artefacts)
dist/ compiled ESM + .d.ts
test/ node:test suites and fixtures