@keeratita/heic-converter 0.4.2 โ†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @keeratita/heic-converter
2
2
 
3
- A modern, lightweight TypeScript library to convert `.heic` and `.heif` images to standard web formats (JPEG, PNG, WebP, SVG) client-side in the browser or on the backend in Node.js.
3
+ A modern, lightweight TypeScript library to convert `.heic` and `.heif` images to standard web formats (JPEG, PNG, WebP, AVIF, SVG) client-side in the browser or on the backend in Node.js.
4
4
 
5
5
  Designed specifically for environments with strict **Content Security Policy (CSP)** rules, it is built with WebAssembly compiled **without** dynamic code execution (`eval()` or `new Function()`).
6
6
 
@@ -10,13 +10,16 @@ Designed specifically for environments with strict **Content Security Policy (CS
10
10
 
11
11
  - ๐Ÿ”’ **CSP Compliant**: Emscripten glue code is compiled with `-s DYNAMIC_EXECUTION=0`. Safe to run without `'unsafe-eval'`.
12
12
  - ๐Ÿงฉ **Dependency Injection Architecture**: Swap the decoder module easily by implementing a simple `IHeicDecoder` interface.
13
- - โšก **Optimized Performance**: A fresh decoder instance is created and released per conversion โ€” memory is reclaimed promptly and concurrent conversions never share mutable WASM state.
13
+ - โšก **Optimized Performance**: A fresh decoder instance is created and released per conversion โ€” memory is reclaimed promptly and concurrent conversions never share mutable WASM state. `convertMany({ reuseDecoders: true })` amortizes WASM init across batch items.
14
14
  - ๐ŸŒ **Isomorphic / Universal**: Runs in Node.js (decoding) and browser (decoding & canvas-based encoding).
15
15
  - ๐Ÿ“ฆ **No Bloat**: Zero external production dependencies. Small footprint.
16
- - ๐ŸŽจ **Format Support**: Convert to `jpeg` (with quality configuration), `png`, `webp`, and `svg` (embedded lossless vector).
17
- - ๐Ÿ“ **Resize Support**: Downscale with `maxWidth`/`maxHeight` or apply a uniform `scale` factor during conversion.
18
- - ๐Ÿ“š **Batch Conversion**: Convert many images at once with bounded concurrency via `convertMany`.
19
- - ๐Ÿงต **Web Worker Helper**: Offload conversions to a Web Worker with `convertHeicInWorker` to keep the UI thread responsive.
16
+ - ๐ŸŽจ **Format Support**: Convert to `jpeg` (with quality configuration), `png`, `webp`, `avif`, and `svg` (the encoded raster wrapped in an SVG document). AVIF degrades gracefully with a clear `format_unsupported` error where the canvas cannot encode it.
17
+ - ๐Ÿ“ **Resize & Crop**: Downscale with `maxWidth`/`maxHeight` or apply a uniform `scale` factor; cut any region with `crop` (in display pixels, composed with orientation).
18
+ - ๐Ÿงญ **EXIF-Orientation Aware**: Images stored rotated with an EXIF orientation flag come out upright, matching what viewers display. Opt out with `applyOrientation: false`.
19
+ - ๐Ÿ—‚ **Metadata Preservation**: `preserveExif: true` keeps the source EXIF block in JPEG (APP1) and PNG (`eXIf`) output; raw-decode consumers get the block via `DecodedImage.exif`.
20
+ - ๐Ÿ“š **Batch Conversion**: Convert many images at once with bounded concurrency via `convertMany` โ€” with per-item results (`continueOnError`) and pooled decoders (`reuseDecoders`) for large batches.
21
+ - ๐Ÿงต **Web Worker Helpers**: Offload single conversions (`convertHeicInWorker`) or whole batches (`convertManyInWorker`) to Web Workers to keep the UI thread responsive.
22
+ - ๐ŸŽ› **Output Shapes & Cancellation**: Return Blobs, base64 data URLs, or ArrayBuffers (`output`), and cancel in-flight work with an `AbortSignal` (`signal`) โ€” worker conversions terminate immediately.
20
23
 
21
24
  ---
22
25
 
@@ -48,15 +51,17 @@ In the browser, you can pass a `File` or `Blob` and get a converted `Blob` back:
48
51
  import { convertHeic } from '@keeratita/heic-converter';
49
52
 
50
53
  // Convert input File/Blob to JPEG
51
- const heicBlob = /* your file input */;
52
- const jpegBlob = await convertHeic(heicBlob, {
54
+ const input = document.querySelector<HTMLInputElement>('#fileInput')!.files!.item(0)!;
55
+ const jpegBlob = await convertHeic(input, {
53
56
  to: 'jpeg',
54
- quality: 0.9
57
+ quality: 0.9,
55
58
  });
56
59
 
57
60
  // Create preview URL
58
- const imageUrl = URL.createObjectURL(jpegBlob);
59
- document.querySelector('img').src = imageUrl;
61
+ const preview = document.querySelector('img');
62
+ if (preview) {
63
+ preview.src = URL.createObjectURL(jpegBlob);
64
+ }
60
65
  ```
61
66
 
62
67
  ### 2. Browser: WebP Output
@@ -66,6 +71,8 @@ WebP offers excellent compression with quality configuration:
66
71
  ```typescript
67
72
  import { convertHeic } from '@keeratita/heic-converter';
68
73
 
74
+ declare const heicBlob: Blob; // your HEIC file/blob
75
+
69
76
  const webpBlob = await convertHeic(heicBlob, {
70
77
  to: 'webp',
71
78
  quality: 0.8,
@@ -79,6 +86,8 @@ SVG wraps the raster image as an embedded lossless PNG inside an SVG container:
79
86
  ```typescript
80
87
  import { convertHeic } from '@keeratita/heic-converter';
81
88
 
89
+ declare const heicBlob: Blob; // your HEIC file/blob
90
+
82
91
  const svgBlob = await convertHeic(heicBlob, {
83
92
  to: 'svg',
84
93
  });
@@ -93,6 +102,8 @@ If your bundler places files in a custom assets folder or CDN, you can configure
93
102
  ```typescript
94
103
  import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
95
104
 
105
+ declare const heicBlob: Blob; // your HEIC file/blob
106
+
96
107
  // Create decoder with custom asset paths
97
108
  const decoder = new LibheifDecoder({
98
109
  locateFile: (path, prefix) => `https://cdn.example.com/assets/${path}`,
@@ -110,6 +121,8 @@ Alternatively, if you prefer to load the WASM binary manually as an `ArrayBuffer
110
121
  ```typescript
111
122
  import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
112
123
 
124
+ declare const heicBlob: Blob; // your HEIC file/blob
125
+
113
126
  const wasmResponse = await fetch('/assets/heic-decoder.wasm');
114
127
  const wasmBinary = await wasmResponse.arrayBuffer();
115
128
 
@@ -121,11 +134,28 @@ const jpegBlob = await convertHeic(heicBlob, {
121
134
  });
122
135
  ```
123
136
 
137
+ #### Using a bundler (Vite, webpack, Rollup, esbuild)
138
+
139
+ The main entry is a small lazy loader: the Emscripten glue ships as a dynamic-import chunk and the `.wasm` binary is **not** inlined โ€” you must make sure `dist/heic-decoder.wasm` is served and resolvable at runtime. Bundlers that rewrite asset URLs usually handle this automatically because the glue resolves the binary relative to its own chunk. If yours doesn't, copy the binary into your assets (the `@keeratita/heic-converter/wasm` subpath export resolves to the file itself, handy as a copy source) and point the decoder at it:
140
+
141
+ ```typescript
142
+ import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
143
+
144
+ declare const heicBlob: Blob;
145
+
146
+ // Serve a copy of @keeratita/heic-converter/dist/heic-decoder.wasm there.
147
+ const blob = await convertHeic(heicBlob, {
148
+ decoder: new LibheifDecoder({ locateFile: () => '/assets/heic-decoder.wasm' }),
149
+ });
150
+ ```
151
+
152
+ Fetching a `.gz`/`.br` variant URL directly will not work โ€” see the compression note below.
153
+
124
154
  #### Reducing the download size
125
155
 
126
- The WASM binary (~1.2 MB) dominates the payload. Everything else is small and lazy: the Emscripten glue (~65 KB) is a separate chunk fetched only on the first decode, and the main entry is ~9 KB.
156
+ The WASM binary (~1.3 MB) dominates the payload. Everything else is small and lazy: the Emscripten glue (~70 KB) is a separate chunk fetched only on the first decode, and the main entry is ~26 KB.
127
157
 
128
- `npm run build` also emits pre-compressed copies โ€” `dist/heic-decoder.wasm.gz` (~397 KB) and `dist/heic-decoder.wasm.br` (~294 KB). Most static hosts and CDNs (GitHub Pages, Netlify, Vercel, Cloudflare) already compress `application/wasm` automatically when the browser sends `Accept-Encoding`; verify with:
158
+ `npm run build` also emits pre-compressed copies โ€” `dist/heic-decoder.wasm.gz` (~421 KB) and `dist/heic-decoder.wasm.br` (~312 KB). Most static hosts and CDNs (GitHub Pages, Netlify, Vercel, Cloudflare) already compress `application/wasm` automatically when the browser sends `Accept-Encoding`; verify with:
129
159
 
130
160
  ```bash
131
161
  curl -sI -H 'Accept-Encoding: br' https://your-site/heic-decoder.wasm | grep -i content-encoding
@@ -149,19 +179,34 @@ import sharp from 'sharp'; // external node image library
149
179
  async function convertNode() {
150
180
  const heicData = new Uint8Array(fs.readFileSync('input.heic'));
151
181
 
152
- const decoder = new LibheifDecoder();
182
+ // Passing wasmBinary explicitly avoids relying on default path resolution
183
+ // inside the package; fs.readFileSync returns a Buffer, which the option
184
+ // accepts (it takes ArrayBuffer or any ArrayBufferView).
185
+ const decoder = new LibheifDecoder({
186
+ wasmBinary: fs.readFileSync(
187
+ 'node_modules/@keeratita/heic-converter/dist/heic-decoder.wasm',
188
+ ),
189
+ });
153
190
  await decoder.initialize();
154
191
 
155
- // Decodes to { width, height, data: Uint8ClampedArray (RGBA) }.
192
+ // Decodes to { width, height, data: Uint8ClampedArray (RGBA), orientation?, exif? }.
156
193
  // data is an independent copy โ€” safe to use after decoder.free().
157
- const { width, height, data } = await decoder.decode(heicData);
194
+ // orientation 2-8 means the stored pixels need a rotation/flip for display
195
+ // (convertHeic applies it automatically; raw-decode consumers must do it
196
+ // themselves โ€” rotate the buffer, or set the orientation tag in `exif`
197
+ // before handing it to sharp so viewers apply it).
198
+ // exif is the source EXIF block ("Exif\0\0" + TIFF) when the file carries
199
+ // one โ€” pass it to sharp.withMetadata({ exif }) to keep camera metadata.
200
+ const { width, height, data, exif } = await decoder.decode(heicData);
158
201
 
159
202
  // Process raw pixels using sharp
160
- await sharp(Buffer.from(data), {
203
+ let pipeline = sharp(Buffer.from(data), {
161
204
  raw: { width, height, channels: 4 },
162
- })
163
- .toFormat('jpeg')
164
- .toFile('output.jpg');
205
+ });
206
+ if (exif) {
207
+ pipeline = pipeline.withMetadata({ exif: Buffer.from(exif) });
208
+ }
209
+ await pipeline.toFormat('jpeg').toFile('output.jpg');
165
210
 
166
211
  // Clean up WASM memory
167
212
  decoder.free();
@@ -175,7 +220,8 @@ For large images, you can pass an `onProgress` callback to track the conversion
175
220
  ```typescript
176
221
  import { convertHeic } from '@keeratita/heic-converter';
177
222
 
178
- const heicBlob = /* your file */;
223
+ declare const heicBlob: Blob; // your HEIC file/blob
224
+
179
225
  const jpegBlob = await convertHeic(heicBlob, {
180
226
  to: 'jpeg',
181
227
  onProgress: (percent) => {
@@ -185,6 +231,9 @@ const jpegBlob = await convertHeic(heicBlob, {
185
231
  });
186
232
  ```
187
233
 
234
+ > [!NOTE]
235
+ > The callback contract is enforced by the library: `percent` is always a finite number clamped to `0`โ€“`100` (first call `0`, last call `100`). If your callback throws, the conversion rejects with a `progress_callback_failed` error naming your message โ€” the failure is attributed to the callback instead of crashing the WASM module.
236
+
188
237
  > [!TIP]
189
238
  > Since the WebAssembly module runs on the main browser thread, the UI thread will be occupied during conversion. For maximum responsiveness when converting large images, it is highly recommended to run this library inside a standard JS **Web Worker** and communicate progress back to the main thread.
190
239
 
@@ -195,6 +244,8 @@ Downscale to fit within maximum dimensions (aspect ratio is preserved, images sm
195
244
  ```typescript
196
245
  import { convertHeic } from '@keeratita/heic-converter';
197
246
 
247
+ declare const heicBlob: Blob; // your HEIC file/blob
248
+
198
249
  const thumbnailBlob = await convertHeic(heicBlob, {
199
250
  to: 'jpeg',
200
251
  maxWidth: 800,
@@ -211,13 +262,97 @@ const halfSizeBlob = await convertHeic(heicBlob, {
211
262
  });
212
263
  ```
213
264
 
214
- ### 8. Batch Conversion
265
+ > [!NOTE]
266
+ > Target dimensions are validated before any decoding work: `scale`, `maxWidth`, and `maxHeight` must be positive finite numbers (otherwise an `invalid_resize` error is thrown), and the resulting target size must not exceed **16384 px** on either side โ€” the canvas/WASM ceiling shared with the decoder's own security limits.
267
+
268
+ ### 8. Orientation (EXIF) Handling
269
+
270
+ Some HEIC files are stored rotated, with the intended display rotation recorded in the EXIF orientation tag (274). Browsers, Photos, and most image viewers apply that rotation when displaying โ€” so `convertHeic` uprights the output for you during rendering:
271
+
272
+ ```typescript
273
+ import { convertHeic } from '@keeratita/heic-converter';
274
+
275
+ // Upright according to the source's EXIF orientation (default: applyOrientation: true)
276
+ const jpeg = await convertHeic(heicFile);
277
+
278
+ // Keep the exact stored-pixel geometry instead (the pre-0.5 behavior):
279
+ const unrotated = await convertHeic(heicFile, { applyOrientation: false });
280
+ ```
281
+
282
+ How it works:
283
+
284
+ - HEIF's native display-transform boxes (`irot`/`imir`) are applied by the decoder itself during `decode()` โ€” `convertHeic` never stacks a second rotation on top, so Apple-style files are never double-rotated.
285
+ - When a file carries only the EXIF tag (the class written by tools that just rewrite metadata), `convertHeic` applies the rotation/flip via a canvas transform.
286
+ - `LibheifDecoder.decode()` returns the raw **stored** pixels and reports a pending rotation via the optional `orientation` field (EXIF semantics `1`โ€“`8`; absent or `1` means nothing is pending). If you encode the pixels yourself (e.g. Node.js + `sharp`), apply the rotation yourself.
287
+ - A non-boolean `applyOrientation` value is rejected up front with an `invalid_input` error.
288
+
289
+ ### 9. Cropping
290
+
291
+ Cut a rectangle out of the image during conversion. Crop coordinates are in **post-orientation display pixels** โ€” the geometry the image is shown in (after any EXIF/irot correction), which is what "top-left corner" means to a user. The crop is applied before `scale`/`maxWidth`/`maxHeight`, which then downscale the cropped region:
292
+
293
+ ```typescript
294
+ import { convertHeic } from '@keeratita/heic-converter';
295
+
296
+ declare const heicBlob: Blob; // your HEIC file/blob
297
+
298
+ const thumbnail = await convertHeic(heicBlob, {
299
+ to: 'jpeg',
300
+ crop: { x: 200, y: 100, width: 640, height: 480 }, // x/y default to 0
301
+ maxWidth: 320, // downscale the crop, not the whole image
302
+ });
303
+ ```
304
+
305
+ Cropping semantics:
306
+
307
+ - `width`/`height` are required positive integers; `x`/`y` are optional non-negative integers. Malformed values reject with `invalid_crop` **before** any decoding; a rectangle that exceeds the image rejects with `invalid_crop` naming the actual display size.
308
+ - Crop, orientation, and resize compose into a single canvas transform โ€” no intermediate copies.
309
+ - With `applyOrientation: false`, crop coordinates address the **stored** pixels instead (geometry as decoded).
310
+
311
+ ### 10. Output Shapes (Blob, data URL, ArrayBuffer)
312
+
313
+ By default conversions return a `Blob`. Choose another representation with `output` โ€” the return type follows the option:
314
+
315
+ ```typescript
316
+ const dataUrl = await convertHeic(heicBlob, { to: 'png', output: 'dataUrl' });
317
+ // string: "data:image/png;base64,โ€ฆ" โ€” ready for <img src>
318
+
319
+ const bytes = await convertHeic(heicBlob, { to: 'webp', output: 'arrayBuffer' });
320
+ // ArrayBuffer โ€” for uploads (fetch body), WebCodecs, custom pipelines
321
+
322
+ const blob = await convertHeic(heicBlob, { to: 'jpeg' }); // default Blob
323
+ ```
324
+
325
+ `output` applies to `convertMany` and `convertManyInWorker` too (in the worker case the chosen representation travels back through the worker protocol โ€” `arrayBuffer` results arrive as a structured-clone copy, never a base64 round-trip; a custom worker script can additionally transfer the buffer with `postMessage(msg, [msg.blob])`). Invalid values reject with `invalid_input`.
326
+
327
+ ### 11. Canceling a Conversion (AbortSignal)
328
+
329
+ Pass an `AbortSignal` to cancel pending work. Cancellation is checked at stage boundaries (after input read, before/after decode, before encode), so a queued or in-flight conversion stops promptly and rejects with the `aborted` code:
330
+
331
+ ```typescript
332
+ import { convertHeic, convertHeicInWorker } from '@keeratita/heic-converter';
333
+
334
+ const controller = new AbortController();
335
+ setTimeout(() => controller.abort(), 2000); // give up after 2s
336
+
337
+ await convertHeic(heicBlob, { signal: controller.signal }); // rejects with code 'aborted'
338
+ await convertMany(files, { signal: controller.signal }); // aborts remaining items
339
+ await convertHeicInWorker(heicBlob, { workerUrl, signal: controller.signal }); // terminates the worker
340
+ ```
341
+
342
+ > [!NOTE]
343
+ > - Worker-based conversions abort **immediately**: the worker is terminated, since it runs off the main thread.
344
+ > - The synchronous WASM `decode()` on the main thread cannot be preempted mid-call โ€” cancellation takes effect at the next boundary after it finishes.
345
+ > - In `convertMany`, an aborted batch rejects with `aborted` (cancellation is the outcome you asked for, and it wins over item-failure aggregation). With `continueOnError`, already-started items record `aborted` errors per item.
346
+
347
+ ### 12. Batch Conversion
215
348
 
216
349
  Convert many images at once with bounded concurrency (default `4`). Results are returned in input order; if any conversion fails, the promise rejects as soon as the failure is known with an error that identifies the failing item:
217
350
 
218
351
  ```typescript
219
352
  import { convertMany } from '@keeratita/heic-converter';
220
353
 
354
+ declare const heicFiles: File[]; // your HEIC files/blobs
355
+
221
356
  const blobs = await convertMany(heicFiles, {
222
357
  to: 'png',
223
358
  concurrency: 3,
@@ -227,7 +362,40 @@ const blobs = await convertMany(heicFiles, {
227
362
  });
228
363
  ```
229
364
 
230
- ### 9. Web Worker Conversion
365
+ Batch semantics worth knowing:
366
+
367
+ - The promise rejects with the **first failure in time** (not the lowest index). All items are still allowed to finish; decoders of in-flight items are released when they settle.
368
+ - The rejection is a `batch_item_failed` error carrying structured fields: `itemIndex` (0-based index of the first failing item), `itemTotal`, `failedCount` (total items that failed), and `cause` (the underlying error). The message names the failing item and, when several items failed, summarizes the count and the other errors.
369
+ - `onProgress` only fires for items that succeed; a failing item never reports 100%.
370
+ - `concurrency` must be a positive integer; the default is `4`.
371
+
372
+ **Keeping going after failures** โ€” with `continueOnError: true` the batch never rejects for item failures. Every item runs, and you get a result entry per item in input order:
373
+
374
+ ```typescript
375
+ const results = await convertMany(files, { to: 'png', continueOnError: true });
376
+ // Array<{ index: number; ok: true; result: Blob } | { index: number; ok: false; error: Error }>
377
+
378
+ const failures = results.filter((r) => !r.ok);
379
+ if (failures.length > 0) {
380
+ console.warn(`${failures.length} file(s) failed`, failures.map((f) => f.index));
381
+ }
382
+ ```
383
+
384
+ Up-front validation mistakes (bad format, bad `concurrency`, โ€ฆ) still reject normally โ€” a typo is a caller bug, not an item failure.
385
+
386
+ **Pooling decoders for big batches** โ€” by default each item gets its own decoder instance (created, WASM-initialized, freed per item). For batches much larger than the concurrency, `reuseDecoders: true` hands each batch runner one pooled instance that is reused across items โ€” the WASM module load is amortized while the exclusive-use invariant holds (an instance never decodes two things at once):
387
+
388
+ ```typescript
389
+ const blobs = await convertMany(thousandFiles, {
390
+ to: 'webp',
391
+ concurrency: 4,
392
+ reuseDecoders: true, // one LibheifDecoder per runner instead of one per item
393
+ });
394
+ ```
395
+
396
+ Ignored when you inject your own `decoder` (yours is used for all items, per the injection contract).
397
+
398
+ ### 13. Web Worker Conversion
231
399
 
232
400
  Run the conversion inside a Web Worker so the main thread stays responsive. Create a worker script that uses this library:
233
401
 
@@ -242,7 +410,11 @@ self.onmessage = async (event) => {
242
410
  ...options,
243
411
  onProgress: (percent) => self.postMessage({ type: 'progress', percent }),
244
412
  });
245
- self.postMessage({ type: 'result', ok: true, blob });
413
+ // With output: 'arrayBuffer' the payload is zero-copy via a transfer list.
414
+ self.postMessage(
415
+ { type: 'result', ok: true, blob },
416
+ blob instanceof ArrayBuffer ? [blob] : []
417
+ );
246
418
  } catch (error) {
247
419
  self.postMessage({ type: 'result', ok: false, error: error?.stack ?? error?.message ?? String(error) });
248
420
  }
@@ -254,6 +426,8 @@ Then convert from the main thread:
254
426
  ```typescript
255
427
  import { convertHeicInWorker } from '@keeratita/heic-converter';
256
428
 
429
+ declare const heicBlob: Blob; // your HEIC file/blob
430
+
257
431
  const jpegBlob = await convertHeicInWorker(heicBlob, {
258
432
  workerUrl: new URL('./converter.worker.js', import.meta.url),
259
433
  workerType: 'module',
@@ -266,11 +440,28 @@ const jpegBlob = await convertHeicInWorker(heicBlob, {
266
440
  > [!NOTE]
267
441
  > - Use `workerType: 'module'` when the worker script uses ES module imports (as in the example above). The default is `'classic'`, which requires the script to be pre-bundled (e.g. by Vite or webpack) โ€” static ES imports are not supported in classic workers.
268
442
  > - `workerUrl` should be a compile-time constant; the script runs with the page's privileges.
269
- > - Only `progress` and `result` messages are understood; any other message type is ignored.
270
- > - `decoder`, `onProgress`, and `workerUrl` are not structured-cloneable, so they are not sent to the worker. Progress is forwarded through `{ type: 'progress', percent }` messages instead.
271
- > - `timeoutMs` (default `60000`) bounds how long the promise waits for a result; set `0` to disable.
443
+ > - Only `progress` and `result` messages are understood; any other message type is ignored โ€” but ignored messages are counted and surfaced in timeout diagnostics (see below).
444
+ > - `decoder` cannot cross the worker boundary: passing it rejects immediately with an `invalid_input` error (it is also absent from `WorkerConvertOptions` at compile time). `onProgress` and `workerUrl` are stripped from the posted message; progress is forwarded through `{ type: 'progress', percent }` messages instead.
445
+ > - Concurrent calls are bounded per worker script: calls beyond `maxConcurrentWorkers` queue and start as slots free. Must be a positive integer (`invalid_concurrency` otherwise). Default: `navigator.hardwareConcurrency` clamped to 1โ€“8 (4 when unavailable).
446
+ > - `timeoutMs` (default `60000`) bounds how long the promise waits for a result; must be a finite number โ‰ฅ 0, `0` disables. The deadline starts when the call is made, so time spent queued behind `maxConcurrentWorkers` counts toward it โ€” raise `timeoutMs` or `maxConcurrentWorkers` for very large batches. A timeout rejects with a `worker_timeout` error whose message includes diagnostics โ€” how many progress messages arrived, the last percent, and any unrecognized message type (the usual sign the worker script doesn't implement the protocol).
272
447
  > - This helper is **browser-only**: it rejects in Node.js, where there is no global `Worker`.
273
448
 
449
+ **Batch inside workers** โ€” `convertManyInWorker` mirrors `convertMany` (input order, `batch_item_failed` aggregation or `continueOnError` per-item results) while each item runs in a worker; real concurrency is bounded by `maxConcurrentWorkers`:
450
+
451
+ ```typescript
452
+ import { convertManyInWorker } from '@keeratita/heic-converter';
453
+
454
+ const blobs = await convertManyInWorker(files, {
455
+ workerUrl: new URL('./converter.worker.js', import.meta.url),
456
+ workerType: 'module',
457
+ to: 'webp',
458
+ maxConcurrentWorkers: 3,
459
+ onProgress: (index, percent) => console.log(`Image ${index}: ${Math.round(percent)}%`),
460
+ });
461
+ ```
462
+
463
+ Every option of `convertHeicInWorker` applies (`output`, `signal`, `crop`, `preserveExif`, `timeoutMs`, โ€ฆ); `onProgress` receives the item `index` in addition to the percent, and a mid-batch abort terminates the running workers.
464
+
274
465
  ---
275
466
 
276
467
  ## ๐Ÿ”’ Content Security Policy (CSP)
@@ -294,36 +485,55 @@ Converts a HEIC image file to a standard web format.
294
485
 
295
486
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
296
487
  - **`options`**: (optional) `ConvertOptions`
297
- - `to`: `'jpeg' | 'jpg' | 'png' | 'webp' | 'svg'` (Default: `'jpeg'`)
298
- - `quality`: `number` (0.0 to 1.0, applicable to JPEG and WebP. Default: `0.92`)
488
+ - `to`: `'jpeg' | 'jpg' | 'png' | 'webp' | 'avif' | 'svg'` (Default: `'jpeg'`; `avif` rejects with `format_unsupported` where the canvas cannot encode it)
489
+ - `quality`: `number` (0.0 to 1.0, applicable to JPEG, WebP, and AVIF. Default: `0.92`)
299
490
  - `decoder`: `IHeicDecoder` (Inject custom decoder instance)
300
491
  - `onProgress`: `(percent: number) => void` (Optional callback, receives progress percentage from `0` to `100` during decoding)
301
492
  - `maxWidth`: `number` (Downscale to fit within this width, preserving aspect ratio. Never upscales)
302
493
  - `maxHeight`: `number` (Downscale to fit within this height, preserving aspect ratio. Never upscales)
303
494
  - `scale`: `number` (Uniform scale factor, e.g. `0.5` halves the image. Takes precedence over `maxWidth`/`maxHeight`)
304
- - **Returns**: `Promise<Blob>`
495
+ - `applyOrientation`: `boolean` (Rotate/flip the output to match the source's EXIF orientation. `irot`/`imir` transforms are already applied by the decoder and never stacked. Default: `true`)
496
+ - `crop`: `{ x?, y?, width, height }` (Cut a rectangle in post-orientation display pixels before resize. Default: none)
497
+ - `preserveExif`: `boolean` (Re-inject the source EXIF block into JPEG (APP1) / PNG (`eXIf`) output. Metadata may contain GPS โ€” opt-in. Default: `false`)
498
+ - `output`: `'blob' | 'dataUrl' | 'arrayBuffer'` (Result representation. Default: `'blob'`; see [Usage section 10](#10-output-shapes-blob-data-url-arraybuffer))
499
+ - `signal`: `AbortSignal` (Cancel pending work; rejects with `aborted`. Default: none)
500
+ - **Returns**: `Promise<Blob>` โ€” or `Promise<string>` / `Promise<ArrayBuffer>` with the typed `output` overloads
305
501
 
306
502
  ### `convertMany(inputs, options?)`
307
503
 
308
- Converts multiple HEIC images with bounded concurrency. Results are returned in input order; rejects as soon as a conversion fails, with an error identifying the failing item index.
504
+ Converts multiple HEIC images with bounded concurrency. Results are returned in input order; rejects with the first failure in time โ€” a `batch_item_failed` error whose `message` names the failing item and whose fields describe the batch (`itemIndex` 0-based, `itemTotal`, `failedCount`, `cause`). With `continueOnError` the promise fulfills with per-item results instead.
309
505
 
310
506
  - **`inputs`**: `Array<Blob | File | ArrayBuffer | Uint8Array>`
311
507
  - **`options`**: (optional) `ConvertManyOptions` โ€” same as `ConvertOptions`, except `onProgress` uses the batch signature below; plus:
312
508
  - `concurrency`: `number` (Maximum concurrent conversions. Default: `4`)
313
- - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback)
314
- - `decoder`: `IHeicDecoder` (Optional. When provided, the same instance is shared by all concurrent conversions and must be safe for concurrent `decode()` calls)
315
- - **Returns**: `Promise<Blob[]>`
509
+ - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback; fires only for successful items)
510
+ - `decoder`: `IHeicDecoder` (Optional. When provided, the same instance is shared by all concurrent conversions and must be safe for concurrent `decode()` calls; the library never frees an injected decoder; `reuseDecoders` is ignored)
511
+ - `continueOnError`: `boolean` (Return per-item `{ index, ok, result | error }` entries instead of rejecting on the first failure. Default: `false`)
512
+ - `reuseDecoders`: `boolean` (Hand each batch runner one pooled `LibheifDecoder` reused across items โ€” amortizes WASM init for large batches while keeping exclusive use. Default: `false`)
513
+ - **Returns**: `Promise<Blob[]>` โ€” or `Promise<string[]>` / `Promise<ArrayBuffer[]>` with the typed `output` overloads; `Promise<ConvertItemResult[]>` with `continueOnError` (also typed by `output`)
316
514
 
317
515
  ### `convertHeicInWorker(input, options)`
318
516
 
319
- Converts a HEIC image inside a Web Worker. The worker script must implement the message protocol shown in [Usage section 9](#9-web-worker-conversion). Browser-only; rejects in Node.js.
517
+ Converts a HEIC image inside a Web Worker. The worker script must implement the message protocol shown in [Usage section 13](#13-web-worker-conversion). Browser-only; rejects in Node.js.
320
518
 
321
519
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
322
- - **`options`**: `WorkerConvertOptions` โ€” same as `ConvertOptions`, plus:
520
+ - **`options`**: `WorkerConvertOptions` โ€” same as `ConvertOptions` but without `decoder` (cannot be structured-cloned; passing one rejects with `invalid_input`), plus:
323
521
  - `workerUrl`: `string | URL` (URL of the worker script; should be a compile-time constant)
324
522
  - `workerType`: `'classic' | 'module'` (Worker script type. Default: `'classic'`; use `'module'` for scripts with ES imports)
325
- - `timeoutMs`: `number` (Maximum wait for the result in milliseconds. Default: `60000`; `0` disables)
326
- - **Returns**: `Promise<Blob>`
523
+ - `timeoutMs`: `number` (Maximum wait for the result in milliseconds, including time spent queued. Finite, โ‰ฅ 0; `0` disables. Default: `60000`)
524
+ - `maxConcurrentWorkers`: `number` (Positive integer. Concurrent workers per `workerUrl` + type; extra calls queue. Default: `navigator.hardwareConcurrency` clamped to 1โ€“8)
525
+ - **Returns**: `Promise<Blob>` โ€” or `Promise<string>` / `Promise<ArrayBuffer>` with the typed `output` overloads. Aborting via `signal` terminates the worker immediately.
526
+
527
+ ### `convertManyInWorker(inputs, options)`
528
+
529
+ Converts multiple HEIC images, each inside a Web Worker, with `convertMany` semantics (input order, `batch_item_failed` aggregation, `continueOnError` per-item results). Browser-only; rejects in Node.js.
530
+
531
+ - **`inputs`**: `Array<Blob | File | ArrayBuffer | Uint8Array>`
532
+ - **`options`**: `WorkerBatchOptions` โ€” same as `convertHeicInWorker`'s `WorkerConvertOptions` (including `output`, `crop`, `preserveExif`, `signal`), except `onProgress` uses the batch signature below; plus:
533
+ - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback)
534
+ - `maxConcurrentWorkers`: `number` (Positive integer. Real concurrency โ€” each in-flight item occupies one semaphore slot. Default: `navigator.hardwareConcurrency` clamped to 1โ€“8)
535
+ - `continueOnError`: `boolean` (Per-item `{ index, ok, result | error }` entries instead of rejecting on the first failure. Default: `false`)
536
+ - **Returns**: `Promise<Blob[]>` โ€” typed overloads mirror `convertMany` (`output` / `continueOnError` combinations)
327
537
 
328
538
  ### `LibheifDecoder(options?)`
329
539
 
@@ -331,11 +541,56 @@ The default WASM-based implementation of `IHeicDecoder`.
331
541
 
332
542
  - **`options`**: (optional) `LibheifDecoderOptions`
333
543
  - `locateFile`: `(path: string, prefix: string) => string`
334
- - `wasmBinary`: `ArrayBuffer`
544
+ - `wasmBinary`: `ArrayBuffer | ArrayBufferView` (e.g. a Node.js `Buffer` from `fs.readFileSync`)
545
+ - `moduleOverrides`: `Record<string, unknown>` (Advanced: merged into the Emscripten module arguments, e.g. `instantiateWasm`)
335
546
  - **Methods**:
336
547
  - `initialize(): Promise<void>`: Loads and initializes the WASM wrapper. Safe to call concurrently; the module loads at most once per instance.
337
- - `decode(data: Uint8Array, onProgress?: (percent: number) => void): Promise<DecodedImage>`: Decodes the HEIC bytes to raw RGBA, with optional progress callback. Returns an independent copy of the pixel data โ€” safe to use after `free()`.
338
- - `free(): void`: Releases allocated WebAssembly heap memory. Idempotent.
548
+ - `decode(data: Uint8Array, onProgress?: (percent: number) => void): Promise<DecodedImage>`: Decodes the HEIC bytes to raw RGBA, with optional progress callback (normalized to finite `0`โ€“`100`). Returns JS-owned pixel data โ€” safe to use after `free()`. The result carries an optional `orientation` field (`2`โ€“`8`, EXIF semantics) when the stored pixels need a rotation for display that the decoder did not itself apply (EXIF-tag-only files); absent or `1` means the pixels are upright. Safe to call after `free()`: the instance transparently re-initializes.
549
+ - `free(): void`: Releases the WASM module and decoder instance. Idempotent, and safe to call while `initialize()` is in flight (the in-flight load is discarded).
550
+
551
+ ### Error handling
552
+
553
+ All errors thrown by this library are `HeicConverterError` instances (`extends Error`) with a machine-readable `code`, and โ€” where relevant โ€” a `cause` and batch fields:
554
+
555
+ | `code` | Thrown by | Meaning |
556
+ | --- | --- | --- |
557
+ | `invalid_input` | all conversion APIs | Unsupported input type; non-boolean `applyOrientation`/`continueOnError`/`reuseDecoders`/`preserveExif`, unknown `output`, malformed `signal`; or worker helper called with a `decoder` |
558
+ | `invalid_quality` | all conversion APIs | `quality` outside `0.0`โ€“`1.0` or not a finite number |
559
+ | `invalid_resize` | all conversion APIs | `scale`/`maxWidth`/`maxHeight` not positive finite numbers, or target size exceeds 16384 px |
560
+ | `invalid_format` | all conversion APIs | Unknown `to` value |
561
+ | `invalid_crop` | all conversion APIs | `crop` with non-integer/non-positive dimensions, negative offsets, or a rectangle beyond the image's display size |
562
+ | `invalid_concurrency` | all conversion APIs | `concurrency` (batch) or `maxConcurrentWorkers` (worker APIs) not a positive integer |
563
+ | `decoder_init_failed` | `convertHeic`, `convertMany` | WASM module could not be loaded (missing asset, CSP block) |
564
+ | `decode_failed` | `convertHeic`, `convertMany` | Invalid/corrupt HEIC bytes |
565
+ | `unsupported_environment` | `convertHeic`, `convertMany` | No canvas APIs (e.g. Node.js); the worker APIs reject with `worker_unsupported` there instead. Decode raw RGBA via `LibheifDecoder` |
566
+ | `render_encode_failed` | `convertHeic`, `convertMany` | Canvas render/encode failure (bad dimensions, `toBlob` returned null) |
567
+ | `format_unsupported` | `convertHeic`, `convertMany` | The canvas cannot encode the requested format (e.g. `avif` on Safari) |
568
+ | `aborted` | all conversion APIs | The `AbortSignal` was aborted before the work completed |
569
+ | `progress_callback_failed` | all conversion APIs | The host `onProgress` callback threw; message attributes the failure |
570
+ | `worker_unsupported` | `convertHeicInWorker`, `convertManyInWorker` | No global `Worker` (e.g. Node.js) |
571
+ | `worker_create_failed` | `convertHeicInWorker`, `convertManyInWorker` | `new Worker(...)` threw (wrong URL, MIME type) |
572
+ | `worker_post_failed` | `convertHeicInWorker`, `convertManyInWorker` | `postMessage` threw (non-cloneable option) |
573
+ | `worker_timeout` | `convertHeicInWorker`, `convertManyInWorker` | No result within `timeoutMs`; message includes progress/protocol diagnostics |
574
+ | `worker_failed` | `convertHeicInWorker`, `convertManyInWorker` | Worker reported `{ type: 'result', ok: false, error }` |
575
+
576
+ Option validation for **all four** conversion APIs runs on the main thread before any worker is created, so a bad option always keeps its own code. Stage errors raised *inside* a worker (decode, render, unsupported format) come back stringified under `worker_failed` โ€” the numeric `code` does not survive the worker boundary; match on the message instead.
577
+ | `batch_item_failed` | `convertMany`, `convertManyInWorker` | One or more items failed; see `itemIndex`/`itemTotal`/`failedCount`/`cause` |
578
+
579
+ ```typescript
580
+ import { convertHeic, type HeicConverterErrorCode } from '@keeratita/heic-converter';
581
+
582
+ try {
583
+ await convertHeic(input, { to: 'png' });
584
+ } catch (error) {
585
+ const code: HeicConverterErrorCode | undefined =
586
+ error && typeof error === 'object' && 'code' in error
587
+ ? (error as { code?: HeicConverterErrorCode }).code
588
+ : undefined;
589
+ if (code === 'unsupported_environment') {
590
+ // e.g. Node.js: decode raw RGBA with LibheifDecoder and encode externally
591
+ }
592
+ }
593
+ ```
339
594
 
340
595
  ### `freeSharedDecoder()`
341
596
 
@@ -380,9 +635,10 @@ npm run test
380
635
 
381
636
  ### Run Browser E2E Tests
382
637
 
383
- Real browser conversions against the CSP sandbox and the GitHub Pages demo (`docs/`):
638
+ Real browser conversions against the CSP sandbox and the GitHub Pages demo (`docs/`). Build first โ€” both servers serve `dist/`:
384
639
 
385
640
  ```bash
641
+ npm run build
386
642
  npx playwright install chromium # one-time
387
643
  npm run test:e2e
388
644
  ```