Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@su-engineering/heic': minor
---

Add JPEG/PNG conversion with decode metadata and automatic bitmap cleanup. Require libheif-js 1.23.2 or newer for the optional fallback, release libheif contexts after decoding, and add a reproducible browser benchmark against heic-to.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:
- run: pnpm test:package
- run: pnpm exec playwright install --with-deps chromium firefox webkit
- run: pnpm test:browser
- name: Smoke-test the comparison benchmark
run: node tools/benchmark.mjs --iterations 1 --cold 1 --output benchmark-results/smoke.json

# Headless Linux generally lacks platform HEVC decoding. Some WebKit
# builds accept limited HEVC profiles. Tests check each fixture config;
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ test/fixtures/local/

# Bundled public-API consumer used by browser integration tests.
test/.consumer/
test/.benchmark/
benchmark-results/
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ HEIC uploads need not force every visitor to download a software codec. This Typ

The project is at **0.1.0**. Test it with representative files and target devices before production use. The repository is named `heic-web`; the npm package name is `@su-engineering/heic`.

[API reference](docs/api.md) · [Compatibility and limitations](docs/compatibility.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Release process](docs/releasing.md)
[API reference](docs/api.md) · [Compatibility and limitations](docs/compatibility.md) · [Benchmarks](docs/benchmarks.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Release process](docs/releasing.md)

## Installation

Expand Down Expand Up @@ -83,7 +83,26 @@ const decoded = await decodeHeic(file, {

The separate entry point loads `libheif-js/wasm-bundle.js` on its first decode. A bundler supporting dynamic imports can keep the adapter and codec out of the initial chunk. Check your bundler's output: asset splitting and download sizes depend on your toolchain and the libheif version.

For self-hosted assets, custom builds, or direct browser imports, use [`createWasmAdapter`](docs/api.md#wasm-adapters). The core library is MIT licensed; optional libheif distributions have [their own licenses](docs/compatibility.md#third-party-code).
The optional peer requires `libheif-js` 1.23.2 or newer within major version 1. For self-hosted assets, custom builds, or direct browser imports, use [`createWasmAdapter`](docs/api.md#wasm-adapters). The core library is MIT licensed; optional libheif distributions have [their own licenses](docs/compatibility.md#third-party-code).

## Convert to JPEG or PNG

```ts
import { convertHeic } from '@su-engineering/heic';

const converted = await convertHeic(file, {
type: 'image/jpeg', // Or 'image/png'; JPEG is the default.
quality: 0.92, // 0–1 for JPEG; ignored for PNG.
maxDimension: 2048,
wasmLoader: async () => (await import('@su-engineering/heic/wasm')).wasmDecoder,
});

const form = new FormData();
form.append('image', converted.blob, 'photo.jpg');
console.log(converted.width, converted.height, converted.strategy, converted.warnings);
```

Install `libheif-js` for the fallback in this example. Omit `wasmLoader` if you only want browser decoding. Conversion runs in browsers and workers, releases its own bitmap, and does not copy source EXIF into the output. Browser encoders determine the final color and compression behavior.

## Choose by capability

Expand All @@ -103,6 +122,7 @@ HEVC support depends on the browser, OS, installed codecs, hardware, and file pr
| Export | Purpose |
| --- | --- |
| `decodeHeic(input, options?)` | Decode a `Blob`, `File`, `ArrayBuffer`, or `Uint8Array` to an `ImageBitmap` plus metadata. |
| `convertHeic(input, options?)` | Encode the primary image to a JPEG/PNG `Blob` plus decode metadata; releases its bitmap automatically. |
| `isHeic(input)` | Inspect up to the first 64 KiB for HEIC identification and coding hints. |
| `probeSupport()` | Probe native HEIC decoding and accepted WebCodecs HEVC configurations. |
| `parseHeif(buffer)` | Inspect container items, properties, references, and locations without decoding pixels. |
Expand All @@ -113,7 +133,7 @@ Common decode options are `strategy`, `maxDimension`, `colorSpace`, `signal`, an

## Scope and limits

This library returns pixels for the primary HEVC image. It does not encode HEIC, supply an upload UI, preserve EXIF in the output, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive.
This library returns pixels or a JPEG/PNG conversion of the primary HEVC image. It does not encode HEIC, supply an upload UI, preserve EXIF in the output, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive.

`maxDimension` reduces the returned bitmap size. **It does not cap peak decode memory:** decoding and compositing may still allocate the full-resolution image. Apply file-size limits, bound concurrent decodes, and use workers for large or untrusted uploads.

Expand Down
10 changes: 9 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ await pending; // Rejects with HeicAbortError at a cancellation checkpoint.

The decoder returns pixels, not a new HEIC file. EXIF and other source metadata are not serialized into a new output file. Warnings are advisory and do not enumerate every unsupported feature.

## `convertHeic(input, options?)`

Returns `Promise<ConvertedImage>` using the same inputs and decode options as `decodeHeic`. Adds `type: 'image/jpeg' | 'image/png'` (default JPEG) and `quality` (default `0.92`, a finite number from 0 to 1; ignored for PNG). Invalid type/quality values throw `TypeError`/`RangeError` before decoding.

The result contains `blob` and all `DecodedImage` metadata except `image`. Dimensions describe the encoded image. Transforms and `maxDimension` apply before encoding. Its bitmap and conversion canvas are released on success, cancellation, or encoder failure; callers do not need `close()`. Revoke any object URLs you create when finished.

Conversion uses `OffscreenCanvas.convertToBlob`, including in workers. Platform encoding errors can propagate. An empty Blob or unexpected MIME type throws `HeicDecodeError`. Cancellation is checked before and after encoding, but cannot interrupt an encoder already running. Quality and encoded bytes can differ across browsers. Source EXIF is not copied; full HDR and cross-browser color equivalence are not promised. The software fallback remains explicit through `wasmLoader` or adapter registration.

## `isHeic(input)`

Returns `Promise<IsHeicResult>` with `isHeic: boolean` and optional `brand`, `primaryItemType`, and `coding` (`'hevc'`, `'av1'`, or `'unknown'`). Inspects at most the first 64 KiB, including for a `Blob`.
Expand Down Expand Up @@ -107,4 +115,4 @@ These functions do not decode pixels and accept `ArrayBuffer`/`Uint8Array` where
| `parseHvcC`, `hvccToCodecString` | Parse HEVC configuration and produce a WebCodecs codec string. |
| `hvccToAnnexBPrologue`, `lengthPrefixedToAnnexB` | Prepare HEVC NAL data for Annex B decoding. |

Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`.
Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `ConvertOptions`, `ConvertedImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`.
66 changes: 66 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Benchmarking against heic-to

Compare full-resolution JPEG/PNG conversion with **heic-to 1.5.2**, using identical input bytes, MIME type, and JPEG quality. Photos are served only on `127.0.0.1`, never uploaded. The competitor is a pinned development dependency and is not shipped in the package.

## Run it

```sh
pnpm install --frozen-lockfile
pnpm exec playwright install chromium firefox
pnpm benchmark

# Use your own consented photos (quote paths containing spaces):
pnpm benchmark -- /path/to/one.heic /path/to/two.heic \
--iterations 10 --cold 5 --output benchmark-results/photos.json

pnpm benchmark -- /path/to/one.heic --browser firefox
pnpm benchmark -- /path/to/one.heic --type image/png
```

Options: `--browser chromium|firefox|webkit`, `--iterations` (warm samples, default 5), `--cold` (first-use samples, default 3), `--type` (default `image/jpeg`), `--quality` (default `0.92`), and `--output`. With no files, two small committed synthetic fixtures smoke-test the harness; they are not representative phone-photo performance measurements.

For installed Chromium with platform HEVC, set `HEIC_CHROME=/path/to/browser`. This does not install codecs or guarantee hardware decoding. WebKit needs platform dependencies and does not represent released Safari. Close other CPU-intensive tasks and run benchmarks separately from tests.

## Methodology

| Variant | Decode behavior |
| --- | --- |
| `ours-auto` | Native → WebCodecs → lazy libheif WASM fallback. |
| `ours-wasm` | Force the libheif-js 1.23.2 WASM fallback. |
| `heic-to` | Standard `heicTo` API with its bundled libheif build. |

- **Cold:** fresh context for every conversion; time module import, local bundle loading/parsing, codec initialization, decoding, and encoding. The browser process is reused. Loopback delivery is unthrottled and is not a mobile-network simulation.
- **Warm:** separate context per variant, one discarded warm-up, then interleaved sequential conversions with cached modules/codec. Variant order rotates between rounds.
- **Input fetch:** excluded for all variants. Neither side resizes. Output inspection is outside timing.
- **Validation:** every Blob must have the requested MIME type, nonzero size, and decodable pixels. Dimensions must match heic-to. A 64×64 thumbnail RGB mean absolute difference against heic-to is diagnostic, not independent proof of correct pixels or color.
- **Report:** raw samples, median/p95, actual strategy, dimensions, encoded bytes, loaded uncompressed JS bytes, browser/OS/CPU and dependency versions, and raw/gzip standalone core size. Small-sample p95 is usually the maximum, not a reliable population percentile.

Generated bundles/reports are ignored by Git. Reports use anonymous fixture IDs and omit filenames, source paths, and photo bytes. Keep private photos outside committed fixtures.

## Initial local measurements

September 18, 2026 conversion candidate, Linux x64, AMD Ryzen 7 PRO 8840U, Playwright Chromium 153.0.8010.12. Two consented iPhone photos, each 2268×4032 with a 5×8 tile grid, converted at full resolution to JPEG quality 0.92. Five cold samples and ten warm samples per variant; times below are medians. No other test suite ran during these measurements.

| Input | ours-auto cold | heic-to cold | ours-auto warm | heic-to warm |
| --- | ---: | ---: | ---: | ---: |
| Photo 1 | 475 ms | 1,243 ms | 361 ms | 722 ms |
| Photo 2 | 587 ms | 1,503 ms | 471 ms | 848 ms |

Both `ours-auto` runs used **WASM**, not native/WebCodecs. Forced WASM warm medians were 362/475 ms. All outputs matched full-resolution dimensions; their 64×64 RGB thumbnail MAD against heic-to was zero in this browser. This is a narrow local result using private inputs, not an independently reproducible public corpus or a general performance guarantee. The harness is reproducible with the committed fixtures or your own photos.

An isolated Firefox 155 run on the same machine used three cold and five warm samples per variant, with the same photos and JPEG settings:

| Input | ours-auto cold | heic-to cold | ours-auto warm | heic-to warm |
| --- | ---: | ---: | ---: | ---: |
| Photo 1 | 493 ms | 1,076 ms | 402 ms | 894 ms |
| Photo 2 | 607 ms | 1,350 ms | 518 ms | 1,117 ms |

These auto runs also used WASM; output dimensions matched and thumbnail RGB MAD was zero. Raw/gzip core size is reported separately from codec assets. In this bundled harness, our WASM path loaded approximately 2.02 MB of uncompressed JavaScript versus 3.00 MB for heic-to; production transfer sizes depend on bundling and compression.

## Interpretation

Our fallback stays unloaded when native or WebCodecs succeeds. `ours-auto` reports the actual successful strategy so a fallback result cannot be presented as a native/hardware speedup. Without platform HEVC, this compares software WASM against heic-to's bundled software implementation.

[heic-to 1.5.2](https://github.com/hoppergee/heic-to) documents libheif 1.22.2; this candidate tests libheif-js 1.23.2. Results compare complete package versions, including different codec builds and encoders; they do not isolate wrapper overhead.

Do not compare npm unpacked sizes as initial download sizes. `loadedJsBytes` is uncompressed local delivery, not gzip transfer cost. Core size excludes the optional codec. This script does not measure peak memory, UI blocking, constrained-network performance, or establish hardware acceleration. Two photos on one machine do not prove universal performance or compatibility. Test representative tiled, rotated, high-resolution, and color-diverse photos on target devices before making broad claims.
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"validate-corpus": "node --experimental-strip-types tools/validate-corpus.ts",
"prepublishOnly": "pnpm typecheck && pnpm build && pnpm test:package && pnpm test:unit",
"release": "node tools/publish.mjs",
"test:package": "node tools/check-package.mjs"
"test:package": "node tools/check-package.mjs",
"benchmark": "pnpm build && node tools/benchmark.mjs"
},
"keywords": [
"heic",
Expand All @@ -50,21 +51,25 @@
"webcodecs",
"image",
"browser",
"iphone"
"iphone",
"conversion",
"jpeg",
"png"
],
"devDependencies": {
"@changesets/cli": "^2.27.10",
"@playwright/test": "^1.62.1",
"@types/node": "^22.20.1",
"esbuild": "^0.28.1",
"libheif-js": "^1.19.8",
"heic-to": "1.5.2",
"libheif-js": "^1.23.2",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"libheif-js": "^1.18.0"
"libheif-js": "^1.23.2"
},
"peerDependenciesMeta": {
"libheif-js": {
Expand Down
10 changes: 9 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 45 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { toHeicBlob } from './bytes.ts';
import { HeicAbortError, HeicUnsupportedError } from './errors.ts';
import { HeicAbortError, HeicDecodeError, HeicUnsupportedError } from './errors.ts';
import { decodeNative } from './decoders/native.ts';
import { decodeWithWebCodecs, isWebCodecsAvailable } from './decoders/webcodecs.ts';
import { DETECTION_PREFIX_BYTES, detectFromBuffer } from './parser/detect.ts';
Expand All @@ -8,6 +8,8 @@ import { createCanvas, throwIfAborted } from './render/canvas.ts';
import { applyTransforms, summarizeTransforms } from './render/transform.ts';
import type {
DecodeOptions,
ConvertOptions,
ConvertedImage,
DecodedImage,
DecoderAdapter,
Strategy,
Expand Down Expand Up @@ -47,6 +49,8 @@ export type {
AdapterRequest,
AdapterResult,
DecodeOptions,
ConvertOptions,
ConvertedImage,
DecodedImage,
DecoderAdapter,
HeicWarning,
Expand Down Expand Up @@ -204,6 +208,46 @@ export async function decodeHeic(
});
}

/**
* Convert the primary HEIC image to JPEG or PNG using the browser's encoder.
* Uses the same explicit decode cascade as decodeHeic and releases all pixels.
* Source EXIF is not copied into the output file.
*/
export async function convertHeic(
input: BinaryInput,
options: ConvertOptions = {},
): Promise<ConvertedImage> {
const { type = 'image/jpeg', quality = 0.92, ...decodeOptions } = options;
if (type !== 'image/jpeg' && type !== 'image/png') {
throw new TypeError('type must be image/jpeg or image/png');
}
if (!Number.isFinite(quality) || quality < 0 || quality > 1) {
throw new RangeError('quality must be a finite number between 0 and 1');
}
const decoded = await decodeHeic(input, decodeOptions);
let canvas: OffscreenCanvas | undefined;
try {
throwIfAborted(options.signal);
canvas = createCanvas(decoded.width, decoded.height);
const ctx = canvas.getContext('2d', { colorSpace: options.colorSpace ?? 'srgb', alpha: false });
if (!ctx) throw new HeicDecodeError('Could not get a 2d context for conversion', {});
ctx.drawImage(decoded.image, 0, 0);
const blob = await canvas.convertToBlob({ type, quality });
throwIfAborted(options.signal);
if (blob.type !== type || blob.size === 0) {
throw new HeicDecodeError(`Browser could not encode ${type}`, {});
}
const { image: _image, ...metadata } = decoded;
return { ...metadata, blob };
} finally {
decoded.image.close();
if (canvas) {
canvas.width = 0;
canvas.height = 0;
}
}
}

// ---------------------------------------------------------------------------
// Adapter registration
// ---------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ export interface DecodedImage {
warnings: HeicWarning[];
}

export interface ConvertOptions extends DecodeOptions {
/** Output format. Default 'image/jpeg'. */
type?: 'image/jpeg' | 'image/png';
/** JPEG encoder quality, from 0 to 1. Default 0.92; ignored for PNG. */
quality?: number;
}

/** Encoded pixels and decode metadata; no bitmap needs to be closed by the caller. */
export interface ConvertedImage extends Omit<DecodedImage, 'image'> {
blob: Blob;
}

export interface SupportReport {
/** `createImageBitmap` decodes HEIC directly (Safari, some Chrome builds). */
native: boolean;
Expand Down
12 changes: 12 additions & 0 deletions test/browser/convert-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { convertHeic } from '/test/.consumer/stdin.js';
import { wasmDecoder } from '/test/.consumer/stdin.js';
self.onmessage = async () => {
try {
const source = await (await fetch('/test/fixtures/generated/asym-base.heic')).blob();
const result = await convertHeic(source, { strategy: 'wasm', wasmLoader: async () => wasmDecoder });
const image = await createImageBitmap(result.blob);
self.postMessage({ type: result.blob.type, size: result.blob.size, width: image.width,
height: image.height, strategy: result.strategy });
image.close();
} catch (error) { self.postMessage({ error: String(error) }); }
};
Loading
Loading