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
10 changes: 10 additions & 0 deletions .changeset/exif-and-thumbnails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@su-engineering/heic": minor
---

Add EXIF carry-over and embedded-thumbnail decoding.

- `convertHeic` takes `exif: 'none' | 'all' | 'no-location'` (default `'none'`). Copied EXIF has Orientation reset to 1 and the pixel dimensions set to the output size, so viewers do not rotate the upright pixels a second time. `'no-location'` removes the GPS block and zeroes its bytes. `ConvertedImage.exif` reports what was written.
- `decodeHeicThumbnail` decodes the embedded `thmb` thumbnail with WebCodecs, and falls back to a downscaled primary when the file has none or it cannot be decoded.
- `readExif` returns the raw EXIF TIFF block.
- `planItem` and `findThumbnailItemId` are exported for advanced use.
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,35 @@ 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.
Install `libheif-js` for the fallback in this example. Omit `wasmLoader` if you only want browser decoding. Conversion runs in browsers and workers and releases its own bitmap. Browser encoders determine the final color and compression behavior.

### Keep EXIF in the JPEG

Source EXIF is dropped unless you ask for it, so a photo's location never leaves the device by accident:

```ts
const converted = await convertHeic(file, { exif: 'no-location' }); // or 'all'
console.log(converted.exif); // what was actually written: 'none', 'all' or 'no-location'
```

`'no-location'` removes the GPS block and overwrites its bytes; camera, lens and capture time stay. Either mode rewrites Orientation to 1 and the pixel dimensions to the output size, because the pixels are already upright; leaving the iPhone's Orientation in place makes viewers rotate the photo a second time. EXIF is written to JPEG only. When the source has no EXIF, the result says `exif: 'none'` with an `exif-missing` warning.

To read the metadata without converting, `readExif(file)` returns the raw TIFF block for an EXIF parser such as [exifr](https://github.com/MikeKovarik/exifr).

## Fast previews from the embedded thumbnail

Many iPhone photos carry a ~320 px HEVC thumbnail next to the full 12 to 48 MP grid. Decoding it is far cheaper than decoding the photo, which suits gallery grids and upload previews:

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

const preview = await decodeHeicThumbnail(file);
canvas.getContext('2d')!.drawImage(preview.image, 0, 0);
preview.image.close();
console.log(preview.embedded); // false when the full image was decoded instead
```

The embedded thumbnail is decoded with WebCodecs. Recent iPhones often omit it, and some browsers cannot decode it; in both cases the primary image is decoded through the normal cascade and downscaled to the thumbnail's size (320 px when there is none). Pass `fallback: 'none'` to get a `HeicUnsupportedError` instead.

## Choose by capability

Expand All @@ -122,7 +150,9 @@ 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. |
| `convertHeic(input, options?)` | Encode the primary image to a JPEG/PNG `Blob` plus decode metadata, optionally with EXIF; releases its bitmap automatically. |
| `decodeHeicThumbnail(input, options?)` | Decode the embedded thumbnail, or a downscaled primary when there is none. |
| `readExif(input)` | Return the raw EXIF TIFF block, or `null`. |
| `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 @@ -133,7 +163,7 @@ Common decode options are `strategy`, `maxDimension`, `colorSpace`, `signal`, an

## Scope and limits

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.
This library returns pixels or a JPEG/PNG conversion of the primary HEVC image. It does not encode HEIC, supply an upload UI, write EXIF to PNG, 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
29 changes: 26 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,33 @@ The decoder returns pixels, not a new HEIC file. EXIF and other source metadata

## `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.
Returns `Promise<ConvertedImage>` using the same inputs and decode options as `decodeHeic`. Adds `type: 'image/jpeg' | 'image/png'` (default JPEG), `quality` (default `0.92`, a finite number from 0 to 1; ignored for PNG) and `exif` (below). Invalid type/quality/exif 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.
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. Full HDR and cross-browser color equivalence are not promised. The software fallback remains explicit through `wasmLoader` or adapter registration.

### EXIF

| `exif` | Written to the JPEG |
| --- | --- |
| `'none'` (default) | Nothing from the source. Some encoders (WebKit) add their own color-space and dimension tags. |
| `'all'` | The source EXIF block, including GPS. |
| `'no-location'` | The source EXIF block with the GPS IFD pointer removed and every GPS byte, including out-of-line values, overwritten with zeros. |

In both copying modes, Orientation is set to 1 and `PixelXDimension`/`PixelYDimension` to the encoded size, since the pixels are already transformed. All other tags, MakerNote included, are copied byte for byte; MakerNote contents are not inspected. Any Exif segment the browser encoder wrote is replaced. Requesting EXIF with `type: 'image/png'` throws `TypeError`.

`ConvertedImage.exif` reports what was actually written. It is `'none'` with an `exif-missing` warning when the source has no EXIF, and `'none'` with an `exif-not-written` warning when the block is malformed or larger than one JPEG APP1 segment (about 64 KB). A malformed block is never written in `'no-location'` mode, because it cannot be proven free of location data.

## `decodeHeicThumbnail(input, options?)`

Returns `Promise<ThumbnailImage>`: a `DecodedImage` plus `embedded: boolean`. Takes the `decodeHeic` options plus `fallback: 'primary' | 'none'` (default `'primary'`).

The thumbnail is the image item linked to the primary by a `thmb` reference (the largest, if there are several), decoded with WebCodecs and its own `irot`/`imir`/`clap` applied. `maxDimension` downsizes it further but never upscales. If the file has no thumbnail, `strategy` excludes WebCodecs, or the decode fails, `'primary'` decodes the primary image through the normal cascade with `maxDimension` defaulting to the thumbnail's longest side, or 320 when there is none; `'none'` throws `HeicUnsupportedError` listing the attempts. The caller owns and must close `image`, as with `decodeHeic`.

## `readExif(input)`

Returns `Promise<Uint8Array | null>`: a copy of the primary image's EXIF as a TIFF block starting at the `II`/`MM` byte-order mark, or `null`. The Exif item is found by its `cdsc` reference, or as the only Exif item in the file. Values are as stored: Orientation describes the coded pixels, which `decodeHeic` has already rotated.

## `isHeic(input)`

Expand Down Expand Up @@ -112,7 +134,8 @@ These functions do not decode pixels and accept `ArrayBuffer`/`Uint8Array` where
| `readItemData` | Extract an item's payload from its extents. |
| `readGrid`, `parseGridPayload` | Read grid references and grid descriptors. |
| `planDecode` | Resolve an `ImagePlan` with tiles, groups, display size, transforms, and warnings. |
| `planItem`, `findThumbnailItemId` | Plan any image item of a parsed file; find the primary's `thmb` thumbnail item. |
| `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`, `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`.
Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `ConvertOptions`, `ConvertedImage`, `ExifMode`, `ThumbnailOptions`, `ThumbnailImage`, `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`.
4 changes: 2 additions & 2 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Do not infer support from a browser name or version alone. Platform codecs, hard

Supported primary items are HEVC `hvc1`/`hev1` images and supported HEVC tiled grids. Container `irot`, `imir`, and valid `clap` transforms are planned; native/libheif paths are expected to apply those themselves. Unsupported essential properties cause an error.

The library decodes one primary image. Recognized alpha, depth/disparity, and HDR gain-map auxiliary items can generate warnings but are not composited. It does not decode AVIF/AV1, arbitrary derived-image types, animation, image sequences, Live Photo motion, or all HEIF extensions. Sequence and motion features are not exhaustively detected, so no warning does not establish their absence.
The library decodes one primary image, or its embedded `thmb` thumbnail through `decodeHeicThumbnail`. Recognized alpha, depth/disparity, and HDR gain-map auxiliary items can generate warnings but are not composited. It does not decode AVIF/AV1, arbitrary derived-image types, animation, image sequences, Live Photo motion, or all HEIF extensions. Sequence and motion features are not exhaustively detected, so no warning does not establish their absence.

## Color and HDR

Expand All @@ -42,7 +42,7 @@ Cancellation is cooperative. An `AbortSignal` is checked around parts of the pip
- Browser tests exercise the public built package, software decoding, transforms, dimensions, and workers across Playwright engines.
- Native and WebCodecs tests skip when capability probes fail. Headless Linux generally cannot validate platform HEVC decoding; some WebKit builds accept limited profiles through software decoders. Tests query each fixture configuration.
- The optional `chrome-hevc` project can exercise an installed browser with platform decoding. Released Safari still needs Apple-device testing.
- Committed fixtures include a tiny single-image file and generated asymmetric transform variants. They do not provide a broad real-world Apple grid/device corpus. Private local fixtures improve local coverage but are absent from CI.
- Committed fixtures include a tiny single-image file, generated asymmetric transform variants, and a generated file with EXIF (synthetic GPS, Orientation) and an embedded thumbnail, the way an iPhone writes them. They do not provide a broad real-world Apple grid/device corpus. Private local fixtures improve local coverage but are absent from CI.

A green CI run establishes only the tests that executed. Review skips and the input corpus before making support claims. See [fixture documentation](https://github.com/su-engineering/heic-web/blob/master/test/fixtures/README.md).

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@playwright/test": "^1.62.1",
"@types/node": "^22.20.1",
"esbuild": "^0.28.1",
"exifr": "7.1.3",
"heic-to": "1.5.2",
"libheif-js": "^1.23.2",
"tsup": "^8.5.1",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

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

Loading
Loading