heif
HEIC/HEIF to JPEG conversion with ICC profile preservation.
What it does
@takazudo/ converts a HEIC/HEIF source to JPEG. On macOS it prefers the system sips binary for speed with no extra dependencies; everywhere else — or whenever sips is unavailable or fails — it falls back to a Node-native decoder (heic-decode, WASM libheif-js) plus a dependency-free ISOBMFF box parser that pulls the source's ICC profile and EXIF metadata straight out of the container and splices them into the output JPEG.
Exports
function convertHeifToJpeg(
input: string | Buffer,
opts?: ConvertHeifToJpegOptions,
): Promise<ConvertHeifResult>;
function convertHeifToJpegNode(
input: string | Buffer,
opts?: ConvertHeifToJpegOptions,
): Promise<ConvertHeifResult>;
function extractIccFromHeif(buffer: Buffer): Buffer | null;
function extractExifFromHeif(buffer: Buffer): Buffer | null;convertHeifToJpeg is the entry point most callers want: it tries sips first (file-path input only, macOS only), then falls back to convertHeifToJpegNode. Call convertHeifToJpegNode directly to force the Node/WASM path (e.g. to skip the sips attempt on a Buffer input, which already always uses the Node path). extractIccFromHeif is the low-level ICC-profile extractor convertHeifToJpegNode uses internally; it's exported for callers who only need the profile bytes. extractExifFromHeif is its EXIF counterpart: it returns the source's EXIF metadata as a TIFF-format block (with the Orientation tag neutralised to 1 — see Caveats), or null when the file has no EXIF item.
Options
| Name | Type | Default | Effect |
|---|---|---|---|
quality | number | 90 | JPEG encode quality, 1-100. |
maxInputBytes | number | 268435456 (256 MiB) | Reject inputs larger than this many bytes before decoding. |
maxDecodePixels | number | 268402689 (sharp's default limitInputPixels) | Node path only: reject inputs whose container declares more pixels than this (ispe box) before the WASM decode — a tiny crafted HEIC declaring huge dimensions would otherwise force a multi-GB allocation inside the decoder. |
ConvertHeifResult shape:
interface ConvertHeifResult {
buffer: Buffer;
width: number;
height: number;
/** Whether a source ICC profile was found and embedded in the output. */
iccApplied: boolean;
/** Which conversion path produced the output. */
converter: 'sips' | 'node';
}converter exists because the two paths are not byte-identical: the Node fallback diverges from sips for nclx-only wide-gamut files (see Caveats), so callers that care can detect which path ran.
Example
import { convertHeifToJpeg } from '@takazudo/zudo-image-tweaker/heif';
const { buffer, width, height, iccApplied } = await convertHeifToJpeg('./photo.heic', {
quality: 90,
});
console.log(`${width}x${height}, icc applied: ${iccApplied}, ${buffer.length} bytes`);Caveats
Trusted input only
The Node/WASM fallback decoder (heic-decode → bundled libheif-js1.19.8) predates the libheif 1.22.0 fixes for CVE-2026-32740 (heap overflow) and CVE-2026-32739 (infinite-loop DoS). Only decode HEIC/HEIF files from sources you trust. The decoder runs inside a WASM sandbox andmaxInputBytes rejects oversized inputs before they reach it, but both are defense-in-depth, not a substitute for trusting the source.
sipsis only tried for a file-pathinput. ABufferinput always goes through the Node/WASM path, even on macOS.HDR "gain map" (
tmap) files force the Node fallback on some macOS hosts. System libheif versions before 1.18.0 (whatsipsand sharp's bundled libvips use) hardcode a strict auxiliary-image-reference limit that rejects gain-map HEIC files from recent iPhone/Android cameras ("Too many auxiliary image references"). The bundled WASM decoder doesn't carry that limit, sosipsfailing on such a file is expected and the Node path picks it up automatically.The Node fallback preserves EXIF, but nclx-only wide-gamut color is lost. Source EXIF is copied into the output JPEG as an APP1 segment, with the Orientation tag neutralised to 1 because the WASM decoder already applies the container's
irot/imirtransforms to the pixels (carrying the source orientation forward would double-rotate in EXIF-aware viewers). What the Node path cannot preserve is wide-gamut color described only by annclxcolr box with no embedded ICC profile: such output carries no profile and decodes as sRGB, whereassipsconverts through the system color engine. Checkconverterif this matters to you.A crafted
colrbox larger than ~16.7 MB is skipped, not embedded. The ICC-in-JPEG convention caps a profile at 255 APP2 chunks; anything larger is malformed by definition (real profiles are KBs) and degrades gracefully toiccApplied: falseinstead of erroring.The ICC profile is spliced in as a raw APP2 marker, not applied via sharp's
withIccProfile()— the decoded pixels are already in the space the extracted profile describes, so a color-managed transform would alter already-correct samples instead of just tagging them.Multi-image containers: a documented edge case.
heic-decode's default export decodes the first top-level image in the container, not necessarily the item marked primary in thepitmbox (which is what the ICC extractor targets). This is a non-issue for single-image consumer photos, including HDR gain-map files (the gain map is an auxiliary reference on the same item, not a separate top-level image) — but a multi-image container whose primary item isn't first would decode the wrong image while attaching the correct image's ICC profile to it.Only the first
ipmabox is consulted when walking the container's item-property associations. ISOBMFF technically permits more than one, but every real-world file this was validated against — including camera-generated HDR gain-map files — carries a single one.