@keeratita/heic-converter 0.4.1 โ†’ 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,6 +134,37 @@ 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
+
154
+ #### Reducing the download size
155
+
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.
157
+
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:
159
+
160
+ ```bash
161
+ curl -sI -H 'Accept-Encoding: br' https://your-site/heic-decoder.wasm | grep -i content-encoding
162
+ ```
163
+
164
+ If your server does not compress automatically, serve the pre-compressed files with the matching `Content-Encoding` header (nginx: `gzip_static on;` / `brotli_static on;`). Do **not** point `locateFile` at a `.br`/`.gz` URL โ€” the browser only decompresses responses tagged with `Content-Encoding`.
165
+
166
+ For long-lived caching, serve the WASM with `Cache-Control: public, max-age=31536000, immutable` and rename it per release (e.g. `heic-decoder-1.2.3.wasm` via `locateFile`) so clients pick up upgrades. GitHub Pages caps asset cache lifetime at 600s; put a CDN in front if that matters.
167
+
124
168
  ### 5. Node.js: Decoding Raw Pixel Data
125
169
 
126
170
  Since Node.js lacks the native browser Canvas API, `convertHeic` (which relies on Canvas to encode raster formats) will throw an error on the backend.
@@ -135,19 +179,34 @@ import sharp from 'sharp'; // external node image library
135
179
  async function convertNode() {
136
180
  const heicData = new Uint8Array(fs.readFileSync('input.heic'));
137
181
 
138
- 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
+ });
139
190
  await decoder.initialize();
140
191
 
141
- // Decodes to { width, height, data: Uint8ClampedArray (RGBA) }.
192
+ // Decodes to { width, height, data: Uint8ClampedArray (RGBA), orientation?, exif? }.
142
193
  // data is an independent copy โ€” safe to use after decoder.free().
143
- 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);
144
201
 
145
202
  // Process raw pixels using sharp
146
- await sharp(Buffer.from(data), {
203
+ let pipeline = sharp(Buffer.from(data), {
147
204
  raw: { width, height, channels: 4 },
148
- })
149
- .toFormat('jpeg')
150
- .toFile('output.jpg');
205
+ });
206
+ if (exif) {
207
+ pipeline = pipeline.withMetadata({ exif: Buffer.from(exif) });
208
+ }
209
+ await pipeline.toFormat('jpeg').toFile('output.jpg');
151
210
 
152
211
  // Clean up WASM memory
153
212
  decoder.free();
@@ -161,7 +220,8 @@ For large images, you can pass an `onProgress` callback to track the conversion
161
220
  ```typescript
162
221
  import { convertHeic } from '@keeratita/heic-converter';
163
222
 
164
- const heicBlob = /* your file */;
223
+ declare const heicBlob: Blob; // your HEIC file/blob
224
+
165
225
  const jpegBlob = await convertHeic(heicBlob, {
166
226
  to: 'jpeg',
167
227
  onProgress: (percent) => {
@@ -171,6 +231,9 @@ const jpegBlob = await convertHeic(heicBlob, {
171
231
  });
172
232
  ```
173
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
+
174
237
  > [!TIP]
175
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.
176
239
 
@@ -181,6 +244,8 @@ Downscale to fit within maximum dimensions (aspect ratio is preserved, images sm
181
244
  ```typescript
182
245
  import { convertHeic } from '@keeratita/heic-converter';
183
246
 
247
+ declare const heicBlob: Blob; // your HEIC file/blob
248
+
184
249
  const thumbnailBlob = await convertHeic(heicBlob, {
185
250
  to: 'jpeg',
186
251
  maxWidth: 800,
@@ -197,13 +262,97 @@ const halfSizeBlob = await convertHeic(heicBlob, {
197
262
  });
198
263
  ```
199
264
 
200
- ### 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
201
348
 
202
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:
203
350
 
204
351
  ```typescript
205
352
  import { convertMany } from '@keeratita/heic-converter';
206
353
 
354
+ declare const heicFiles: File[]; // your HEIC files/blobs
355
+
207
356
  const blobs = await convertMany(heicFiles, {
208
357
  to: 'png',
209
358
  concurrency: 3,
@@ -213,7 +362,40 @@ const blobs = await convertMany(heicFiles, {
213
362
  });
214
363
  ```
215
364
 
216
- ### 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
217
399
 
218
400
  Run the conversion inside a Web Worker so the main thread stays responsive. Create a worker script that uses this library:
219
401
 
@@ -228,7 +410,11 @@ self.onmessage = async (event) => {
228
410
  ...options,
229
411
  onProgress: (percent) => self.postMessage({ type: 'progress', percent }),
230
412
  });
231
- 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
+ );
232
418
  } catch (error) {
233
419
  self.postMessage({ type: 'result', ok: false, error: error?.stack ?? error?.message ?? String(error) });
234
420
  }
@@ -240,6 +426,8 @@ Then convert from the main thread:
240
426
  ```typescript
241
427
  import { convertHeicInWorker } from '@keeratita/heic-converter';
242
428
 
429
+ declare const heicBlob: Blob; // your HEIC file/blob
430
+
243
431
  const jpegBlob = await convertHeicInWorker(heicBlob, {
244
432
  workerUrl: new URL('./converter.worker.js', import.meta.url),
245
433
  workerType: 'module',
@@ -252,11 +440,28 @@ const jpegBlob = await convertHeicInWorker(heicBlob, {
252
440
  > [!NOTE]
253
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.
254
442
  > - `workerUrl` should be a compile-time constant; the script runs with the page's privileges.
255
- > - Only `progress` and `result` messages are understood; any other message type is ignored.
256
- > - `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.
257
- > - `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).
258
447
  > - This helper is **browser-only**: it rejects in Node.js, where there is no global `Worker`.
259
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
+
260
465
  ---
261
466
 
262
467
  ## ๐Ÿ”’ Content Security Policy (CSP)
@@ -280,36 +485,55 @@ Converts a HEIC image file to a standard web format.
280
485
 
281
486
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
282
487
  - **`options`**: (optional) `ConvertOptions`
283
- - `to`: `'jpeg' | 'jpg' | 'png' | 'webp' | 'svg'` (Default: `'jpeg'`)
284
- - `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`)
285
490
  - `decoder`: `IHeicDecoder` (Inject custom decoder instance)
286
491
  - `onProgress`: `(percent: number) => void` (Optional callback, receives progress percentage from `0` to `100` during decoding)
287
492
  - `maxWidth`: `number` (Downscale to fit within this width, preserving aspect ratio. Never upscales)
288
493
  - `maxHeight`: `number` (Downscale to fit within this height, preserving aspect ratio. Never upscales)
289
494
  - `scale`: `number` (Uniform scale factor, e.g. `0.5` halves the image. Takes precedence over `maxWidth`/`maxHeight`)
290
- - **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
291
501
 
292
502
  ### `convertMany(inputs, options?)`
293
503
 
294
- 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.
295
505
 
296
506
  - **`inputs`**: `Array<Blob | File | ArrayBuffer | Uint8Array>`
297
507
  - **`options`**: (optional) `ConvertManyOptions` โ€” same as `ConvertOptions`, except `onProgress` uses the batch signature below; plus:
298
508
  - `concurrency`: `number` (Maximum concurrent conversions. Default: `4`)
299
- - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback)
300
- - `decoder`: `IHeicDecoder` (Optional. When provided, the same instance is shared by all concurrent conversions and must be safe for concurrent `decode()` calls)
301
- - **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`)
302
514
 
303
515
  ### `convertHeicInWorker(input, options)`
304
516
 
305
- 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.
306
518
 
307
519
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
308
- - **`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:
309
521
  - `workerUrl`: `string | URL` (URL of the worker script; should be a compile-time constant)
310
522
  - `workerType`: `'classic' | 'module'` (Worker script type. Default: `'classic'`; use `'module'` for scripts with ES imports)
311
- - `timeoutMs`: `number` (Maximum wait for the result in milliseconds. Default: `60000`; `0` disables)
312
- - **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)
313
537
 
314
538
  ### `LibheifDecoder(options?)`
315
539
 
@@ -317,11 +541,56 @@ The default WASM-based implementation of `IHeicDecoder`.
317
541
 
318
542
  - **`options`**: (optional) `LibheifDecoderOptions`
319
543
  - `locateFile`: `(path: string, prefix: string) => string`
320
- - `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`)
321
546
  - **Methods**:
322
547
  - `initialize(): Promise<void>`: Loads and initializes the WASM wrapper. Safe to call concurrently; the module loads at most once per instance.
323
- - `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()`.
324
- - `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
+ ```
325
594
 
326
595
  ### `freeSharedDecoder()`
327
596
 
@@ -366,9 +635,10 @@ npm run test
366
635
 
367
636
  ### Run Browser E2E Tests
368
637
 
369
- 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/`:
370
639
 
371
640
  ```bash
641
+ npm run build
372
642
  npx playwright install chromium # one-time
373
643
  npm run test:e2e
374
644
  ```