From 02392993a685ba9da80d8916ff125619f1840514 Mon Sep 17 00:00:00 2001 From: Mahir Senturk Date: Fri, 18 Sep 2026 23:26:59 +0200 Subject: [PATCH 1/3] Add JPEG/PNG conversion and heic-to benchmark --- .changeset/conversion.md | 5 ++ .github/workflows/ci.yml | 2 + .gitignore | 2 + README.md | 26 +++++- docs/api.md | 10 ++- docs/benchmarks.md | 57 ++++++++++++ package.json | 8 +- pnpm-lock.yaml | 10 ++- src/index.ts | 46 +++++++++- src/types.ts | 12 +++ test/browser/convert-worker.js | 12 +++ test/browser/convert.spec.ts | 112 +++++++++++++++++++++++ test/unit/wasm.test.ts | 23 +++++ tools/benchmark.mjs | 157 +++++++++++++++++++++++++++++++++ tools/check-package.mjs | 4 +- wasm/index.ts | 110 +++++++++++++---------- 16 files changed, 538 insertions(+), 58 deletions(-) create mode 100644 .changeset/conversion.md create mode 100644 docs/benchmarks.md create mode 100644 test/browser/convert-worker.js create mode 100644 test/browser/convert.spec.ts create mode 100644 test/unit/wasm.test.ts create mode 100644 tools/benchmark.mjs diff --git a/.changeset/conversion.md b/.changeset/conversion.md new file mode 100644 index 0000000..f87b8bd --- /dev/null +++ b/.changeset/conversion.md @@ -0,0 +1,5 @@ +--- +'@su-engineering/heic': minor +--- + +Add JPEG/PNG conversion with decode metadata and automatic bitmap cleanup. Require libheif-js 1.23.2 or newer for the optional fallback, release libheif contexts after decoding, and add a reproducible browser benchmark against heic-to. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db85bd1..1f6e194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: - run: pnpm test:package - run: pnpm exec playwright install --with-deps chromium firefox webkit - run: pnpm test:browser + - name: Smoke-test the comparison benchmark + run: node tools/benchmark.mjs --iterations 1 --cold 1 --output benchmark-results/smoke.json # Headless Linux generally lacks platform HEVC decoding. Some WebKit # builds accept limited HEVC profiles. Tests check each fixture config; diff --git a/.gitignore b/.gitignore index d6ec8ea..659bab5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ test/fixtures/local/ # Bundled public-API consumer used by browser integration tests. test/.consumer/ +test/.benchmark/ +benchmark-results/ diff --git a/README.md b/README.md index 1652d80..419d993 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ HEIC uploads need not force every visitor to download a software codec. This Typ The project is at **0.1.0**. Test it with representative files and target devices before production use. The repository is named `heic-web`; the npm package name is `@su-engineering/heic`. -[API reference](docs/api.md) · [Compatibility and limitations](docs/compatibility.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Release process](docs/releasing.md) +[API reference](docs/api.md) · [Compatibility and limitations](docs/compatibility.md) · [Benchmarks](docs/benchmarks.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Release process](docs/releasing.md) ## Installation @@ -83,7 +83,26 @@ const decoded = await decodeHeic(file, { The separate entry point loads `libheif-js/wasm-bundle.js` on its first decode. A bundler supporting dynamic imports can keep the adapter and codec out of the initial chunk. Check your bundler's output: asset splitting and download sizes depend on your toolchain and the libheif version. -For self-hosted assets, custom builds, or direct browser imports, use [`createWasmAdapter`](docs/api.md#wasm-adapters). The core library is MIT licensed; optional libheif distributions have [their own licenses](docs/compatibility.md#third-party-code). +The optional peer requires `libheif-js` 1.23.2 or newer within major version 1. For self-hosted assets, custom builds, or direct browser imports, use [`createWasmAdapter`](docs/api.md#wasm-adapters). The core library is MIT licensed; optional libheif distributions have [their own licenses](docs/compatibility.md#third-party-code). + +## Convert to JPEG or PNG + +```ts +import { convertHeic } from '@su-engineering/heic'; + +const converted = await convertHeic(file, { + type: 'image/jpeg', // Or 'image/png'; JPEG is the default. + quality: 0.92, // 0–1 for JPEG; ignored for PNG. + maxDimension: 2048, + wasmLoader: async () => (await import('@su-engineering/heic/wasm')).wasmDecoder, +}); + +const form = new FormData(); +form.append('image', converted.blob, 'photo.jpg'); +console.log(converted.width, converted.height, converted.strategy, converted.warnings); +``` + +Install `libheif-js` for the fallback in this example. Omit `wasmLoader` if you only want browser decoding. Conversion runs in browsers and workers, releases its own bitmap, and does not copy source EXIF into the output. Browser encoders determine the final color and compression behavior. ## Choose by capability @@ -103,6 +122,7 @@ HEVC support depends on the browser, OS, installed codecs, hardware, and file pr | Export | Purpose | | --- | --- | | `decodeHeic(input, options?)` | Decode a `Blob`, `File`, `ArrayBuffer`, or `Uint8Array` to an `ImageBitmap` plus metadata. | +| `convertHeic(input, options?)` | Encode the primary image to a JPEG/PNG `Blob` plus decode metadata; releases its bitmap automatically. | | `isHeic(input)` | Inspect up to the first 64 KiB for HEIC identification and coding hints. | | `probeSupport()` | Probe native HEIC decoding and accepted WebCodecs HEVC configurations. | | `parseHeif(buffer)` | Inspect container items, properties, references, and locations without decoding pixels. | @@ -113,7 +133,7 @@ Common decode options are `strategy`, `maxDimension`, `colorSpace`, `signal`, an ## Scope and limits -This library returns pixels for the primary HEVC image. It does not encode HEIC, supply an upload UI, preserve EXIF in the output, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive. +This library returns pixels or a JPEG/PNG conversion of the primary HEVC image. It does not encode HEIC, supply an upload UI, preserve EXIF in the output, or decode AVIF, animation, or Live Photo video. Recognized alpha, depth, and HDR gain-map auxiliary items produce warnings; warning coverage is not exhaustive. `maxDimension` reduces the returned bitmap size. **It does not cap peak decode memory:** decoding and compositing may still allocate the full-resolution image. Apply file-size limits, bound concurrent decodes, and use workers for large or untrusted uploads. diff --git a/docs/api.md b/docs/api.md index c6139a9..cf15bc7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,6 +40,14 @@ await pending; // Rejects with HeicAbortError at a cancellation checkpoint. The decoder returns pixels, not a new HEIC file. EXIF and other source metadata are not serialized into a new output file. Warnings are advisory and do not enumerate every unsupported feature. +## `convertHeic(input, options?)` + +Returns `Promise` using the same inputs and decode options as `decodeHeic`. Adds `type: 'image/jpeg' | 'image/png'` (default JPEG) and `quality` (default `0.92`, a finite number from 0 to 1; ignored for PNG). Invalid type/quality values throw `TypeError`/`RangeError` before decoding. + +The result contains `blob` and all `DecodedImage` metadata except `image`. Dimensions describe the encoded image. Transforms and `maxDimension` apply before encoding. Its bitmap and conversion canvas are released on success, cancellation, or encoder failure; callers do not need `close()`. Revoke any object URLs you create when finished. + +Conversion uses `OffscreenCanvas.convertToBlob`, including in workers. Platform encoding errors can propagate. An empty Blob or unexpected MIME type throws `HeicDecodeError`. Cancellation is checked before and after encoding, but cannot interrupt an encoder already running. Quality and encoded bytes can differ across browsers. Source EXIF is not copied; full HDR and cross-browser color equivalence are not promised. The software fallback remains explicit through `wasmLoader` or adapter registration. + ## `isHeic(input)` Returns `Promise` with `isHeic: boolean` and optional `brand`, `primaryItemType`, and `coding` (`'hevc'`, `'av1'`, or `'unknown'`). Inspects at most the first 64 KiB, including for a `Blob`. @@ -107,4 +115,4 @@ These functions do not decode pixels and accept `ArrayBuffer`/`Uint8Array` where | `parseHvcC`, `hvccToCodecString` | Parse HEVC configuration and produce a WebCodecs codec string. | | `hvccToAnnexBPrologue`, `lengthPrefixedToAnnexB` | Prepare HEVC NAL data for Annex B decoding. | -Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`. +Exported types include `BinaryInput`, `IsHeicResult`, `DecodeOptions`, `DecodedImage`, `ConvertOptions`, `ConvertedImage`, `Strategy`, `OutputColorSpace`, `SourceColor`, `TransformsApplied`, `HeicWarning`, `SupportReport`, `HeicErrorContext`, `DecoderAdapter`, `AdapterRequest`, `AdapterResult`, `HeifFile`, `ItemInfo`, `ItemLocation`, `ItemProperty`, `ItemProperties`, `ItemReferences`, `GridDescriptor`, `HvcC`, `ImagePlan`, `PlannedTile`, `TileGroup`, and `TransformOp`. The WASM entry point additionally exports `WasmAdapterOptions`. diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..fa8f338 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,57 @@ +# Benchmarking against heic-to + +Compare full-resolution JPEG/PNG conversion with **heic-to 1.5.2**, using identical input bytes, MIME type, and JPEG quality. Photos are served only on `127.0.0.1`, never uploaded. The competitor is a pinned development dependency and is not shipped in the package. + +## Run it + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install chromium firefox +pnpm benchmark + +# Use your own consented photos (quote paths containing spaces): +pnpm benchmark -- /path/to/one.heic /path/to/two.heic \ + --iterations 10 --cold 5 --output benchmark-results/photos.json + +pnpm benchmark -- /path/to/one.heic --browser firefox +pnpm benchmark -- /path/to/one.heic --type image/png +``` + +Options: `--browser chromium|firefox|webkit`, `--iterations` (warm samples, default 5), `--cold` (first-use samples, default 3), `--type` (default `image/jpeg`), `--quality` (default `0.92`), and `--output`. With no files, two small committed synthetic fixtures smoke-test the harness; they are not representative phone-photo performance measurements. + +For installed Chromium with platform HEVC, set `HEIC_CHROME=/path/to/browser`. This does not install codecs or guarantee hardware decoding. WebKit needs platform dependencies and does not represent released Safari. Close other CPU-intensive tasks and run benchmarks separately from tests. + +## Methodology + +| Variant | Decode behavior | +| --- | --- | +| `ours-auto` | Native → WebCodecs → lazy libheif WASM fallback. | +| `ours-wasm` | Force the libheif-js 1.23.2 WASM fallback. | +| `heic-to` | Standard `heicTo` API with its bundled libheif build. | + +- **Cold:** fresh context for every conversion; time module import, local bundle loading/parsing, codec initialization, decoding, and encoding. The browser process is reused. Loopback delivery is unthrottled and is not a mobile-network simulation. +- **Warm:** separate context per variant, one discarded warm-up, then interleaved sequential conversions with cached modules/codec. Variant order rotates between rounds. +- **Input fetch:** excluded for all variants. Neither side resizes. Output inspection is outside timing. +- **Validation:** every Blob must have the requested MIME type, nonzero size, and decodable pixels. Dimensions must match heic-to. A 64×64 thumbnail RGB mean absolute difference against heic-to is diagnostic, not independent proof of correct pixels or color. +- **Report:** raw samples, median/p95, actual strategy, dimensions, encoded bytes, loaded uncompressed JS bytes, browser/OS/CPU and dependency versions, and raw/gzip standalone core size. Small-sample p95 is usually the maximum, not a reliable population percentile. + +Generated bundles/reports are ignored by Git. Reports use anonymous fixture IDs and omit filenames, source paths, and photo bytes. Keep private photos outside committed fixtures. + +## Initial local measurements + +September 18, 2026 conversion candidate, Linux x64, AMD Ryzen 7 PRO 8840U, Playwright Chromium 153.0.8010.12. Two consented iPhone photos, each 2268×4032 with a 5×8 tile grid, converted at full resolution to JPEG quality 0.92. Five cold samples and ten warm samples per variant; times below are medians. No other test suite ran during these measurements. + +| Input | ours-auto cold | heic-to cold | ours-auto warm | heic-to warm | +| --- | ---: | ---: | ---: | ---: | +| Photo 1 | 475 ms | 1,243 ms | 361 ms | 722 ms | +| Photo 2 | 587 ms | 1,503 ms | 471 ms | 848 ms | + +Both `ours-auto` runs used **WASM**, not native/WebCodecs. Forced WASM warm medians were 362/475 ms. All outputs matched full-resolution dimensions; their 64×64 RGB thumbnail MAD against heic-to was zero in this browser. This is a narrow local result using private inputs, not an independently reproducible public corpus or a general performance guarantee. The harness is reproducible with the committed fixtures or your own photos. + +## Interpretation + +Our fallback stays unloaded when native or WebCodecs succeeds. `ours-auto` reports the actual successful strategy so a fallback result cannot be presented as a native/hardware speedup. Without platform HEVC, this compares software WASM against heic-to's bundled software implementation. + +[heic-to 1.5.2](https://github.com/hoppergee/heic-to) documents libheif 1.22.2; this candidate tests libheif-js 1.23.2. Results compare complete package versions, including different codec builds and encoders; they do not isolate wrapper overhead. + +Do not compare npm unpacked sizes as initial download sizes. `loadedJsBytes` is uncompressed local delivery, not gzip transfer cost. Core size excludes the optional codec. This script does not measure peak memory, UI blocking, constrained-network performance, or establish hardware acceleration. Two photos on one machine do not prove universal performance or compatibility. Test representative tiled, rotated, high-resolution, and color-diverse photos on target devices before making broad claims. diff --git a/package.json b/package.json index 572c8ba..377da7e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "validate-corpus": "node --experimental-strip-types tools/validate-corpus.ts", "prepublishOnly": "pnpm typecheck && pnpm build && pnpm test:package && pnpm test:unit", "release": "node tools/publish.mjs", - "test:package": "node tools/check-package.mjs" + "test:package": "node tools/check-package.mjs", + "benchmark": "pnpm build && node tools/benchmark.mjs" }, "keywords": [ "heic", @@ -57,14 +58,15 @@ "@playwright/test": "^1.62.1", "@types/node": "^22.20.1", "esbuild": "^0.28.1", - "libheif-js": "^1.19.8", + "heic-to": "1.5.2", + "libheif-js": "^1.23.2", "tsup": "^8.5.1", "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.1.11" }, "peerDependencies": { - "libheif-js": "^1.18.0" + "libheif-js": "^1.23.2" }, "peerDependenciesMeta": { "libheif-js": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afe8206..e3c1107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,11 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + heic-to: + specifier: 1.5.2 + version: 1.5.2 libheif-js: - specifier: ^1.19.8 + specifier: ^1.23.2 version: 1.23.2 tsup: specifier: ^8.5.1 @@ -651,6 +654,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + heic-to@1.5.2: + resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==} + human-id@4.2.1: resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true @@ -1701,6 +1707,8 @@ snapshots: graceful-fs@4.2.11: {} + heic-to@1.5.2: {} + human-id@4.2.1: {} iconv-lite@0.7.3: diff --git a/src/index.ts b/src/index.ts index 095146d..696636b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { toHeicBlob } from './bytes.ts'; -import { HeicAbortError, HeicUnsupportedError } from './errors.ts'; +import { HeicAbortError, HeicDecodeError, HeicUnsupportedError } from './errors.ts'; import { decodeNative } from './decoders/native.ts'; import { decodeWithWebCodecs, isWebCodecsAvailable } from './decoders/webcodecs.ts'; import { DETECTION_PREFIX_BYTES, detectFromBuffer } from './parser/detect.ts'; @@ -8,6 +8,8 @@ import { createCanvas, throwIfAborted } from './render/canvas.ts'; import { applyTransforms, summarizeTransforms } from './render/transform.ts'; import type { DecodeOptions, + ConvertOptions, + ConvertedImage, DecodedImage, DecoderAdapter, Strategy, @@ -47,6 +49,8 @@ export type { AdapterRequest, AdapterResult, DecodeOptions, + ConvertOptions, + ConvertedImage, DecodedImage, DecoderAdapter, HeicWarning, @@ -204,6 +208,46 @@ export async function decodeHeic( }); } +/** + * Convert the primary HEIC image to JPEG or PNG using the browser's encoder. + * Uses the same explicit decode cascade as decodeHeic and releases all pixels. + * Source EXIF is not copied into the output file. + */ +export async function convertHeic( + input: BinaryInput, + options: ConvertOptions = {}, +): Promise { + const { type = 'image/jpeg', quality = 0.92, ...decodeOptions } = options; + if (type !== 'image/jpeg' && type !== 'image/png') { + throw new TypeError('type must be image/jpeg or image/png'); + } + if (!Number.isFinite(quality) || quality < 0 || quality > 1) { + throw new RangeError('quality must be a finite number between 0 and 1'); + } + const decoded = await decodeHeic(input, decodeOptions); + let canvas: OffscreenCanvas | undefined; + try { + throwIfAborted(options.signal); + canvas = createCanvas(decoded.width, decoded.height); + const ctx = canvas.getContext('2d', { colorSpace: options.colorSpace ?? 'srgb', alpha: false }); + if (!ctx) throw new HeicDecodeError('Could not get a 2d context for conversion', {}); + ctx.drawImage(decoded.image, 0, 0); + const blob = await canvas.convertToBlob({ type, quality }); + throwIfAborted(options.signal); + if (blob.type !== type || blob.size === 0) { + throw new HeicDecodeError(`Browser could not encode ${type}`, {}); + } + const { image: _image, ...metadata } = decoded; + return { ...metadata, blob }; + } finally { + decoded.image.close(); + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + } +} + // --------------------------------------------------------------------------- // Adapter registration // --------------------------------------------------------------------------- diff --git a/src/types.ts b/src/types.ts index 3ecada6..119f438 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,18 @@ export interface DecodedImage { warnings: HeicWarning[]; } +export interface ConvertOptions extends DecodeOptions { + /** Output format. Default 'image/jpeg'. */ + type?: 'image/jpeg' | 'image/png'; + /** JPEG encoder quality, from 0 to 1. Default 0.92; ignored for PNG. */ + quality?: number; +} + +/** Encoded pixels and decode metadata; no bitmap needs to be closed by the caller. */ +export interface ConvertedImage extends Omit { + blob: Blob; +} + export interface SupportReport { /** `createImageBitmap` decodes HEIC directly (Safari, some Chrome builds). */ native: boolean; diff --git a/test/browser/convert-worker.js b/test/browser/convert-worker.js new file mode 100644 index 0000000..27ec7b9 --- /dev/null +++ b/test/browser/convert-worker.js @@ -0,0 +1,12 @@ +import { convertHeic } from '/test/.consumer/stdin.js'; +import { wasmDecoder } from '/test/.consumer/stdin.js'; +self.onmessage = async () => { + try { + const source = await (await fetch('/test/fixtures/generated/asym-base.heic')).blob(); + const result = await convertHeic(source, { strategy: 'wasm', wasmLoader: async () => wasmDecoder }); + const image = await createImageBitmap(result.blob); + self.postMessage({ type: result.blob.type, size: result.blob.size, width: image.width, + height: image.height, strategy: result.strategy }); + image.close(); + } catch (error) { self.postMessage({ error: String(error) }); } +}; diff --git a/test/browser/convert.spec.ts b/test/browser/convert.spec.ts new file mode 100644 index 0000000..85f22cd --- /dev/null +++ b/test/browser/convert.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; +import { openHarness } from './support.ts'; + +test.beforeEach(async ({ page }) => { await openHarness(page); }); + +for (const type of ['image/jpeg', 'image/png'] as const) { + test(`conversion encodes rotated, resized pixels as ${type}`, async ({ page }) => { + const result = await page.evaluate(async (type) => { + const consumerUrl = '/test/.consumer/stdin.js'; + const consumer = await import(/* @vite-ignore */ consumerUrl); + const source = await (await fetch('/test/fixtures/generated/asym-irot-90.heic')).blob(); + const converted = await consumer.convertHeic(source, { + type, quality: 0.9, maxDimension: 160, strategy: 'wasm', + wasmLoader: async () => consumer.wasmDecoder, + }); + const bitmap = await createImageBitmap(converted.blob); + const result = { type: converted.blob.type, size: converted.blob.size, + width: converted.width, height: converted.height, encodedWidth: bitmap.width, + encodedHeight: bitmap.height, sourceWidth: converted.sourceWidth, + sourceHeight: converted.sourceHeight, rotation: converted.transformsApplied.rotation, + strategy: converted.strategy, hasImage: 'image' in converted, + mad: await window.meanAbsoluteDifference(bitmap, '/test/fixtures/generated/asym-irot-90.ref.png') }; + bitmap.close(); + return result; + }, type); + expect(result.type).toBe(type); + expect(result.size).toBeGreaterThan(100); + expect(result.width).toBe(107); + expect(result.height).toBe(160); + expect(result.encodedWidth).toBe(107); + expect(result.encodedHeight).toBe(160); + expect(result.sourceWidth).toBe(320); + expect(result.sourceHeight).toBe(480); + expect(result.rotation).toBe(90); + expect(result.strategy).toBe('wasm'); + expect(result.hasImage).toBe(false); + expect(result.mad).toBeLessThan(10); + }); +} + +test('conversion rejects invalid encoding options before loading a codec', async ({ page }) => { + const result = await page.evaluate(async () => { + const { convertHeic } = window.heic; + let loaded = false; + const outcomes = []; + for (const options of [{ quality: NaN }, { quality: -1 }, { quality: 1.1 }, { type: 'image/webp' }]) { + try { await convertHeic(new Blob(), { ...options, wasmLoader: async () => { loaded = true; throw new Error('loaded'); } } as Parameters[1]); } + catch (error) { outcomes.push((error as Error).name); } + } + return { loaded, outcomes }; + }); + expect(result).toEqual({ loaded: false, outcomes: ['RangeError', 'RangeError', 'RangeError', 'TypeError'] }); +}); + +test('conversion supports workers and default JPEG output', async ({ page }) => { + const result = await page.evaluate(async () => { + const worker = new Worker('/test/browser/convert-worker.js', { type: 'module' }); + try { + return await new Promise>((resolve, reject) => { + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + worker.postMessage(null); + }); + } finally { worker.terminate(); } + }); + expect(result).toMatchObject({ type: 'image/jpeg', width: 480, height: 320, strategy: 'wasm' }); + expect(result.size).toBeGreaterThan(100); +}); + +test('conversion cancellation does not load the codec', async ({ page }) => { + const result = await page.evaluate(async () => { + const { convertHeic } = window.heic; + const controller = new AbortController(); controller.abort(); + let loaded = false; + try { await convertHeic(new Blob(), { signal: controller.signal, + wasmLoader: async () => { loaded = true; throw new Error('loaded'); } }); } + catch (error) { return { name: (error as Error).name, loaded }; } + return null; + }); + expect(result).toEqual({ name: 'HeicAbortError', loaded: false }); +}); + +test('conversion releases pixels after encoder failure or late cancellation', async ({ page }) => { + const results = await page.evaluate(async () => { + const consumerUrl = '/test/.consumer/stdin.js'; + const consumer = await import(/* @vite-ignore */ consumerUrl); + const source = await (await fetch('/test/fixtures/generated/asym-base.heic')).blob(); + const encode = OffscreenCanvas.prototype.convertToBlob; + const close = ImageBitmap.prototype.close; + const outcomes = []; + for (const failure of ['encode', 'abort']) { + const controller = new AbortController(); + let closed = 0; + let canvas: OffscreenCanvas | undefined; + ImageBitmap.prototype.close = function () { closed++; return close.call(this); }; + OffscreenCanvas.prototype.convertToBlob = async function (options) { + canvas = this; + if (failure === 'encode') throw new Error('encoder failed'); + const result = await encode.call(this, options); controller.abort(); return result; + }; + try { + await consumer.convertHeic(source, { strategy: 'wasm', signal: controller.signal, wasmLoader: async () => consumer.wasmDecoder }); + } catch (error) { outcomes.push({ name: (error as Error).name, closed, canvasWidth: canvas?.width, canvasHeight: canvas?.height }); } + finally { ImageBitmap.prototype.close = close; OffscreenCanvas.prototype.convertToBlob = encode; } + } + return outcomes; + }); + expect(results).toEqual([ + { name: 'Error', closed: 1, canvasWidth: 0, canvasHeight: 0 }, + { name: 'HeicAbortError', closed: 1, canvasWidth: 0, canvasHeight: 0 }, + ]); +}); diff --git a/test/unit/wasm.test.ts b/test/unit/wasm.test.ts new file mode 100644 index 0000000..09d91b6 --- /dev/null +++ b/test/unit/wasm.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createWasmAdapter } from '../../wasm/index.ts'; + +afterEach(() => { vi.unstubAllGlobals(); }); +describe('libheif resource ownership', () => { + for (const outcome of ['success', 'render-error', 'invalid-size', 'empty'] as const) { + it(`releases image handles and context on ${outcome}`, async () => { + const released: string[] = []; + const image = { get_width: () => outcome === 'invalid-size' ? 0 : 2, get_height: () => 2, + display: (_data: unknown, callback: (data: unknown) => void) => callback(outcome === 'render-error' ? null : {}), + free: () => released.push('primary') }; + const aux = { ...image, free: () => released.push('aux') }; + const module = { HeifDecoder: class { decoder: number | null = 42; decode() { return outcome === 'empty' ? [] : [image, aux]; } }, + heif_context_free: (context: number) => { expect(context).toBe(42); released.push('context'); } }; + vi.stubGlobal('OffscreenCanvas', class { getContext() { return { createImageData: () => ({}), putImageData: () => {} }; } }); + const adapter = createWasmAdapter({ loadLibheif: async () => module }); + const promise = adapter.decode({ data: new Uint8Array(), colorSpace: 'srgb' }); + if (outcome === 'success') await promise; + else await expect(promise).rejects.toThrow(); + expect(released).toEqual(outcome === 'empty' ? ['context'] : ['primary', 'aux', 'context']); + }); + } +}); diff --git a/tools/benchmark.mjs b/tools/benchmark.mjs new file mode 100644 index 0000000..f880e99 --- /dev/null +++ b/tools/benchmark.mjs @@ -0,0 +1,157 @@ +/** Local browser benchmark. Photo bytes never leave the loopback server. */ +import { build } from 'esbuild'; +import { chromium, firefox, webkit } from '@playwright/test'; +import { createServer } from 'node:http'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { cpus, platform, arch } from 'node:os'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; + +let source = { commit: null, dirty: null }; +try { + source = { commit: execFileSync('rtk', ['git', 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + dirty: execFileSync('rtk', ['git', 'status', '--porcelain'], { encoding: 'utf8' }).trim().length > 0 }; +} catch { /* The harness also works in a source snapshot without .git. */ } + +const args = process.argv.slice(2).filter(arg => arg !== '--'); +const settings = { browser: 'chromium', iterations: 5, cold: 3, output: 'benchmark-results/latest.json', type: 'image/jpeg', quality: 0.92 }; +const files = []; +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + if (!(key in settings) || !args[i + 1]) throw new Error(`Unknown or missing option: ${arg}`); + settings[key] = typeof settings[key] === 'number' ? Number(args[++i]) : args[++i]; + } else files.push(resolve(arg)); +} +if (!files.length) files.push(resolve('test/fixtures/generated/asym-base.heic'), resolve('test/fixtures/generated/asym-irot-90.heic')); +if (!['chromium', 'firefox', 'webkit'].includes(settings.browser) || !['image/jpeg', 'image/png'].includes(settings.type) + || !Number.isInteger(settings.iterations) || settings.iterations < 1 || !Number.isInteger(settings.cold) || settings.cold < 1 + || !Number.isFinite(settings.quality) || settings.quality < 0 || settings.quality > 1) throw new Error('Invalid benchmark settings'); +const fixtureBytes = files.map(file => readFileSync(file)); +const outdir = resolve('test/.benchmark'); +mkdirSync(outdir, { recursive: true }); +for (const [name, contents] of Object.entries({ + ours: `import { convertHeic } from './dist/index.js'; import { wasmDecoder } from './dist/wasm.js'; + export async function convert(blob, options, strategy) { return convertHeic(blob, { ...options, strategy, wasmLoader: async () => wasmDecoder }); }`, + competitor: `import { heicTo } from 'heic-to'; + export async function convert(blob, options) { return { blob: await heicTo({ blob, ...options }), strategy: 'libheif-js' }; }`, +})) { + await build({ stdin: { contents, resolveDir: process.cwd(), sourcefile: `${name}.js` }, + bundle: true, splitting: true, format: 'esm', platform: 'browser', minify: true, + outdir: join(outdir, name), entryNames: 'entry' }); +} +const server = createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/') return res.writeHead(200, { 'content-type': 'text/html' }).end('HEIC benchmark'); + const fixture = /^\/fixture\/(\d+)$/.exec(url.pathname); + if (fixture && fixtureBytes[Number(fixture[1])]) return res.writeHead(200, { 'content-type': 'image/heic' }).end(fixtureBytes[Number(fixture[1])]); + if (/^\/assets\/(ours|competitor)\/[a-zA-Z0-9_-]+\.js$/.test(url.pathname)) { + try { return res.writeHead(200, { 'content-type': 'text/javascript' }).end(readFileSync(join(outdir, url.pathname.slice('/assets/'.length)))); } + catch { /* 404 */ } + } + res.writeHead(404).end(); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +let browser; +const variants = [{ name: 'ours-auto', entry: 'ours', strategy: 'auto' }, + { name: 'ours-wasm', entry: 'ours', strategy: 'wasm' }, { name: 'heic-to', entry: 'competitor' }]; +const rows = []; +const percentile = (values, p) => [...values].sort((a, b) => a - b)[Math.max(0, Math.ceil(values.length * p) - 1)]; +const summarize = samples => { + const sorted = samples.map(s => s.ms).sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return { medianMs: sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2, + p95Ms: percentile(sorted, 0.95), samples }; +}; +async function prepare(context, fixtureIndex) { + const page = await context.newPage(); + page.setDefaultTimeout(120000); + await page.goto(origin); + await page.evaluate(async index => { window.input = await (await fetch(`/fixture/${index}`)).blob(); }, fixtureIndex); + return page; +} +async function run(page, variant) { + return page.evaluate(async ({ variant, options }) => { + const started = performance.now(); + const api = await import(`/assets/${variant.entry}/entry.js`); + const result = await api.convert(window.input, options, variant.strategy); + const ms = performance.now() - started; + // Validate every output outside the measured region, then release it. + if (result.blob.type !== options.type || !result.blob.size) throw new Error('Invalid encoded output'); + const bitmap = await createImageBitmap(result.blob); + const canvas = new OffscreenCanvas(64, 64); + const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0, 64, 64); + const pixels = Array.from(ctx.getImageData(0, 0, 64, 64).data); + const summary = { ms, strategy: result.strategy, width: bitmap.width, height: bitmap.height, + outputBytes: result.blob.size, pixels, + loadedJsBytes: performance.getEntriesByType('resource').filter(e => e.name.includes('/assets/')).reduce((sum, e) => sum + e.decodedBodySize, 0) }; + bitmap.close(); canvas.width = 0; canvas.height = 0; + return summary; + }, { variant, options: { type: settings.type, quality: settings.quality } }); +} +try { + browser = await ({ chromium, firefox, webkit })[settings.browser].launch({ + ...(settings.browser === 'chromium' && process.env.HEIC_CHROME ? { executablePath: process.env.HEIC_CHROME } : {}), + }); + const context = await browser.newContext(); + const page = await context.newPage(); await page.goto(origin); + const userAgent = await page.evaluate(() => navigator.userAgent); + await context.close(); + for (let index = 0; index < files.length; index++) { + const data = new Map(variants.map(v => [v.name, { cold: [], warm: [] }])); + // Rotate execution order between rounds to reduce systematic order bias. + for (let round = 0; round < settings.cold; round++) { + for (let offset = 0; offset < variants.length; offset++) { + const variant = variants[(round + offset) % variants.length]; + const context = await browser.newContext(); + try { data.get(variant.name).cold.push(await run(await prepare(context, index), variant)); } + finally { await context.close(); } + } + } + const warmed = new Map(); + try { + for (const variant of variants) { + const context = await browser.newContext(); + warmed.set(variant.name, { context, page: await prepare(context, index) }); + await run(warmed.get(variant.name).page, variant); // Untimed warm-up (sample discarded). + } + for (let round = 0; round < settings.iterations; round++) { + for (let offset = 0; offset < variants.length; offset++) { + const variant = variants[(round + offset) % variants.length]; + data.get(variant.name).warm.push(await run(warmed.get(variant.name).page, variant)); + } + } + } finally { for (const { context } of warmed.values()) await context.close(); } + const reference = data.get('heic-to').warm[0]; + const referencePixels = [...reference.pixels]; + for (const variant of variants) { + const samples = data.get(variant.name); + for (const sample of [...samples.cold, ...samples.warm]) { + if (sample.width !== reference.width || sample.height !== reference.height) throw new Error(`Dimension mismatch: ${variant.name}`); + let diff = 0; + for (let p = 0; p < sample.pixels.length; p++) if (p % 4 !== 3) diff += Math.abs(sample.pixels[p] - referencePixels[p]); + sample.thumbnailRgbMadVsHeicTo = diff / (64 * 64 * 3); + delete sample.pixels; + } + const row = { fixture: `fixture-${index + 1}`, inputBytes: fixtureBytes[index].length, variant: variant.name, + cold: summarize(samples.cold), warm: summarize(samples.warm) }; + rows.push(row); + console.log(`${row.fixture} ${variant.name}: cold ${row.cold.medianMs.toFixed(1)} ms; warm ${row.warm.medianMs.toFixed(1)} ms; ${samples.warm[0].strategy}; ${reference.width}x${reference.height}`); + } + } + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + const dependency = name => JSON.parse(readFileSync(`node_modules/${name}/package.json`, 'utf8')).version; + const core = readFileSync('dist/heic.global.js'); + const report = { generatedAt: new Date().toISOString(), source, versions: { ours: pkg.version, libheifJs: dependency('libheif-js'), heicTo: dependency('heic-to') }, + environment: { browser: settings.browser, browserVersion: browser.version(), userAgent, platform: platform(), arch: arch(), cpu: cpus()[0]?.model, logicalCpus: cpus().length }, + settings, coreBundle: { rawBytes: core.length, gzipBytes: gzipSync(core).length, sha256: createHash('sha256').update(core).digest('hex') }, rows, + methodology: 'Full-resolution conversion. Input fetch excluded. Cold: fresh context including module loading and codec initialization on local loopback. Warm: one discarded conversion, then interleaved repeats including cached dynamic import. Output inspection excluded. No network throttling, memory or hardware-HEVC claims. Filenames and photo bytes omitted from report.' }; + mkdirSync(resolve(settings.output, '..'), { recursive: true }); + writeFileSync(settings.output, JSON.stringify(report, null, 2) + '\n'); + console.log(`Report: ${settings.output}`); +} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } diff --git a/tools/check-package.mjs b/tools/check-package.mjs index cb30742..23ff31b 100644 --- a/tools/check-package.mjs +++ b/tools/check-package.mjs @@ -21,6 +21,8 @@ assert.equal(typeof context.HeicDecoder.probeSupport, 'function'); const core = await import('../dist/index.js'); const fallback = await import('../dist/wasm.js'); assert.equal(typeof core.decodeHeic, 'function'); +assert.equal(typeof core.convertHeic, 'function'); +assert.equal(typeof context.HeicDecoder.convertHeic, 'function'); assert.equal(typeof fallback.createWasmAdapter, 'function'); console.log(`Verified ${targets.size} package targets, ESM entry points, and browser global.`); @@ -28,7 +30,7 @@ console.log(`Verified ${targets.size} package targets, ESM entry points, and bro // real browser bundler sees them, without shipping test assets in dist/. await build({ stdin: { - contents: "export { decodeHeic } from '@su-engineering/heic'; export { wasmDecoder } from '@su-engineering/heic/wasm';", + contents: "export { decodeHeic, convertHeic } from '@su-engineering/heic'; export { wasmDecoder } from '@su-engineering/heic/wasm';", resolveDir: process.cwd(), }, bundle: true, diff --git a/wasm/index.ts b/wasm/index.ts index 6e461ac..6b1a27d 100644 --- a/wasm/index.ts +++ b/wasm/index.ts @@ -20,10 +20,13 @@ interface LibheifImage { free?(): void; } interface LibheifDecoder { + /** Context allocated by libheif-js's decode method. */ + decoder?: number | null; decode(buffer: Uint8Array | ArrayBuffer): LibheifImage[]; } interface LibheifModule { HeifDecoder: new () => LibheifDecoder; + heif_context_free?: (context: number) => void; } export interface WasmAdapterOptions { @@ -70,62 +73,73 @@ export function createWasmAdapter(options: WasmAdapterOptions = {}): DecoderAdap if (request.signal?.aborted) throw new HeicAbortError(); const decoder = new libheif.HeifDecoder(); - const images = decoder.decode(request.data); - if (!images || images.length === 0) { - throw new HeicDecodeError('libheif returned no images', { strategy: 'wasm' }); - } + let images: LibheifImage[] = []; + try { + images = decoder.decode(request.data); + if (!images || images.length === 0) { + throw new HeicDecodeError('libheif returned no images', { strategy: 'wasm' }); + } - // Index 0 is the primary image; any others are aux or sequence entries, - // which v0.1 does not decode. - const image = images[0]!; - const width = image.get_width(); - const height = image.get_height(); - if (width <= 0 || height <= 0) { - throw new HeicDecodeError(`libheif reported ${width}x${height}`, { strategy: 'wasm' }); - } + // Index 0 is the primary image; any others are aux or sequence entries, + // which v0.1 does not decode. + const image = images[0]!; + const width = image.get_width(); + const height = image.get_height(); + if (width <= 0 || height <= 0) { + throw new HeicDecodeError(`libheif reported ${width}x${height}`, { strategy: 'wasm' }); + } - if (typeof OffscreenCanvas === 'undefined') { - throw new HeicDecodeError('OffscreenCanvas is required to receive libheif output', { - strategy: 'wasm', - }); - } - const canvas = new OffscreenCanvas(width, height); - const ctx = canvas.getContext('2d', { - colorSpace: request.colorSpace, - alpha: false, - }); - if (!ctx) { - throw new HeicDecodeError('Could not get a 2d context for libheif output', { - strategy: 'wasm', + if (typeof OffscreenCanvas === 'undefined') { + throw new HeicDecodeError('OffscreenCanvas is required to receive libheif output', { + strategy: 'wasm', + }); + } + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d', { + colorSpace: request.colorSpace, + alpha: false, }); - } - - const imageData = ctx.createImageData(width, height, { colorSpace: request.colorSpace }); - await new Promise((resolve, reject) => { - try { - image.display(imageData, (result) => { - if (!result) reject(new HeicDecodeError('libheif failed to render', { strategy: 'wasm' })); - else resolve(); + if (!ctx) { + throw new HeicDecodeError('Could not get a 2d context for libheif output', { + strategy: 'wasm', }); - } catch (error) { - reject( - new HeicDecodeError(`libheif threw while rendering: ${String(error)}`, { - strategy: 'wasm', - }), - ); } - }); - image.free?.(); - ctx.putImageData(imageData, 0, 0); + const imageData = ctx.createImageData(width, height, { colorSpace: request.colorSpace }); + await new Promise((resolve, reject) => { + try { + image.display(imageData, (result) => { + if (!result) reject(new HeicDecodeError('libheif failed to render', { strategy: 'wasm' })); + else resolve(); + }); + } catch (error) { + reject( + new HeicDecodeError(`libheif threw while rendering: ${String(error)}`, { + strategy: 'wasm', + }), + ); + } + }); - if (request.signal?.aborted) { - canvas.width = 0; - canvas.height = 0; - throw new HeicAbortError(); - } + ctx.putImageData(imageData, 0, 0); - return { image: canvas, width, height }; + if (request.signal?.aborted) { + canvas.width = 0; + canvas.height = 0; + throw new HeicAbortError(); + } + + return { image: canvas, width, height }; + } finally { + // Each decode allocates a context. libheif-js only frees it on the next + // decode on the same instance; this adapter creates a fresh instance. + // Release every returned handle before its owning context, even on error. + for (const image of images ?? []) image.free?.(); + if (decoder.decoder && libheif.heif_context_free) { + libheif.heif_context_free(decoder.decoder); + decoder.decoder = null; + } + } }, }; } From 893bd6576cd09fb2581893f9fdb268c1e633ff98 Mon Sep 17 00:00:00 2001 From: Mahir Senturk Date: Fri, 18 Sep 2026 23:29:27 +0200 Subject: [PATCH 2/3] Record Firefox comparison and bound benchmark samples --- docs/benchmarks.md | 9 +++++++++ package.json | 5 ++++- tools/benchmark.mjs | 43 +++++++++++++++++++++++-------------------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index fa8f338..46aaab4 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -48,6 +48,15 @@ September 18, 2026 conversion candidate, Linux x64, AMD Ryzen 7 PRO 8840U, Playw Both `ours-auto` runs used **WASM**, not native/WebCodecs. Forced WASM warm medians were 362/475 ms. All outputs matched full-resolution dimensions; their 64×64 RGB thumbnail MAD against heic-to was zero in this browser. This is a narrow local result using private inputs, not an independently reproducible public corpus or a general performance guarantee. The harness is reproducible with the committed fixtures or your own photos. +An isolated Firefox 155 run on the same machine used three cold and five warm samples per variant, with the same photos and JPEG settings: + +| Input | ours-auto cold | heic-to cold | ours-auto warm | heic-to warm | +| --- | ---: | ---: | ---: | ---: | +| Photo 1 | 493 ms | 1,076 ms | 402 ms | 894 ms | +| Photo 2 | 607 ms | 1,350 ms | 518 ms | 1,117 ms | + +These auto runs also used WASM; output dimensions matched and thumbnail RGB MAD was zero. Raw/gzip core size is reported separately from codec assets. In this bundled harness, our WASM path loaded approximately 2.02 MB of uncompressed JavaScript versus 3.00 MB for heic-to; production transfer sizes depend on bundling and compression. + ## Interpretation Our fallback stays unloaded when native or WebCodecs succeeds. `ours-auto` reports the actual successful strategy so a fallback result cannot be presented as a native/hardware speedup. Without platform HEVC, this compares software WASM against heic-to's bundled software implementation. diff --git a/package.json b/package.json index 377da7e..9971f13 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,10 @@ "webcodecs", "image", "browser", - "iphone" + "iphone", + "conversion", + "jpeg", + "png" ], "devDependencies": { "@changesets/cli": "^2.27.10", diff --git a/tools/benchmark.mjs b/tools/benchmark.mjs index f880e99..bc7e959 100644 --- a/tools/benchmark.mjs +++ b/tools/benchmark.mjs @@ -11,8 +11,8 @@ import { execFileSync } from 'node:child_process'; let source = { commit: null, dirty: null }; try { - source = { commit: execFileSync('rtk', ['git', 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), - dirty: execFileSync('rtk', ['git', 'status', '--porcelain'], { encoding: 'utf8' }).trim().length > 0 }; + source = { commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + dirty: execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim().length > 0 }; } catch { /* The harness also works in a source snapshot without .git. */ } const args = process.argv.slice(2).filter(arg => arg !== '--'); @@ -75,24 +75,27 @@ async function prepare(context, fixtureIndex) { return page; } async function run(page, variant) { - return page.evaluate(async ({ variant, options }) => { - const started = performance.now(); - const api = await import(`/assets/${variant.entry}/entry.js`); - const result = await api.convert(window.input, options, variant.strategy); - const ms = performance.now() - started; - // Validate every output outside the measured region, then release it. - if (result.blob.type !== options.type || !result.blob.size) throw new Error('Invalid encoded output'); - const bitmap = await createImageBitmap(result.blob); - const canvas = new OffscreenCanvas(64, 64); - const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: true }); - ctx.drawImage(bitmap, 0, 0, 64, 64); - const pixels = Array.from(ctx.getImageData(0, 0, 64, 64).data); - const summary = { ms, strategy: result.strategy, width: bitmap.width, height: bitmap.height, - outputBytes: result.blob.size, pixels, - loadedJsBytes: performance.getEntriesByType('resource').filter(e => e.name.includes('/assets/')).reduce((sum, e) => sum + e.decodedBodySize, 0) }; - bitmap.close(); canvas.width = 0; canvas.height = 0; - return summary; - }, { variant, options: { type: settings.type, quality: settings.quality } }); + const timeout = setTimeout(() => { void page.close().catch(() => {}); }, 120000); + try { + return await page.evaluate(async ({ variant, options }) => { + const started = performance.now(); + const api = await import(`/assets/${variant.entry}/entry.js`); + const result = await api.convert(window.input, options, variant.strategy); + const ms = performance.now() - started; + // Validate every output outside the measured region, then release it. + if (result.blob.type !== options.type || !result.blob.size) throw new Error('Invalid encoded output'); + const bitmap = await createImageBitmap(result.blob); + const canvas = new OffscreenCanvas(64, 64); + const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0, 64, 64); + const pixels = Array.from(ctx.getImageData(0, 0, 64, 64).data); + const summary = { ms, strategy: result.strategy, width: bitmap.width, height: bitmap.height, + outputBytes: result.blob.size, pixels, + loadedJsBytes: performance.getEntriesByType('resource').filter(e => e.name.includes('/assets/')).reduce((sum, e) => sum + e.decodedBodySize, 0) }; + bitmap.close(); canvas.width = 0; canvas.height = 0; + return summary; + }, { variant, options: { type: settings.type, quality: settings.quality } }); + } finally { clearTimeout(timeout); } } try { browser = await ({ chromium, firefox, webkit })[settings.browser].launch({ From 7367c14b4d125b8e966933e89892f3cc95dfb7d0 Mon Sep 17 00:00:00 2001 From: Mahir Senturk Date: Fri, 18 Sep 2026 23:30:37 +0200 Subject: [PATCH 3/3] Close benchmark server connections and bundler service --- tools/benchmark.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/benchmark.mjs b/tools/benchmark.mjs index bc7e959..2c1a695 100644 --- a/tools/benchmark.mjs +++ b/tools/benchmark.mjs @@ -1,5 +1,5 @@ /** Local browser benchmark. Photo bytes never leave the loopback server. */ -import { build } from 'esbuild'; +import { build, stop } from 'esbuild'; import { chromium, firefox, webkit } from '@playwright/test'; import { createServer } from 'node:http'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; @@ -157,4 +157,12 @@ try { mkdirSync(resolve(settings.output, '..'), { recursive: true }); writeFileSync(settings.output, JSON.stringify(report, null, 2) + '\n'); console.log(`Report: ${settings.output}`); -} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); } +} finally { + if (browser) await browser.close(); + await new Promise(resolve => { + server.close(resolve); + server.closeAllConnections(); + }); + stop(); + console.log('Benchmark complete; browser, server, and bundler stopped.'); +}