diff --git a/CHANGELOG.md b/CHANGELOG.md
index a008b9c9..e047b19d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,47 @@ All notable changes to PureJsImage are documented in this file.
## [Unreleased]
+- Fetch the pinned tundra JPEG from its byte-identical Wikimedia copy after Flickr
+ began returning HTTP 403 to the browser CI jobs. Keep the existing checksum.
+
+- Update JPEG XL browser checks for the separate tool pages and committed fixtures.
+ Allow the existing two-encode quality regression test enough time on shared CI runners.
+
+- Clarify README API choices, browser examples and JPEG XL tool links. Keep the
+ whole-slide showcase near the opening and retain the benchmark results. Move format support near the top, with
+ concise format notes, optional WASM coverage and dated speed examples.
+
+- Improve JPEG XL effort-7 lossy grain, text edges and PQ texture with bounded
+ local quantizer refinement before transform selection. Small sRGB RGBA artwork can use a smaller Modular stream
+ that preserves visible RGB and every alpha sample. Lossy mode may normalize
+ RGB under zero alpha. Native HDR precision is preserved. Lossy encoding
+ is Stable for the documented static integer subset. Lossy animation remains
+ Experimental. Missing quality comparisons stay unscored.
+- Reduce JPEG XL effort-1 RGB8 encoding work with fixed AC quantization scales
+ and an aligned-block color path. Independently decoded output stays unchanged
+ on the 12 MP runtime case and the approved 2 MP quality matrix.
+- Record completed lossless compression qualification and preserve missing
+ lossy quality brackets as unresolved comparisons.
+
+- Add explicit JPEG XL binary16/binary32 gray and RGB display conversion with
+ mixed integer or IEEE alpha, straightening before range mapping. Preserve
+ native samples and source color meaning. Write shifted native planes across
+ multiple Modular groups, including shift-3 DC sections. Add explicit direct
+ 16-bit ICC conversion for supported GRAY, RGB, and CMYK native layers with
+ gray-alpha expansion and straight RGBA16 output.
+- Pin libjxl float-plane and RGBA16 references for 17 mixed gray/RGB
+ color and alpha cases, including integer, IEEE, associated and shifted alpha.
+- Pin independently decoded native alpha, black, and binary16 depth grids for
+ odd JPEG XL multi-group shifted output.
+- Extend selective JPEG XL VarDCT DC and pass sessions to supported SDR alpha,
+ SDR16, linear16, and PQ16 cases, including associated shifted alpha. Add
+ exact one-component 8-bit Huffman JPEG transcode and reconstruction for
+ baseline and progressive scans.
+- Verify early linear 16-bit JPEG XL RGBA stages against a pinned partial
+ jxl-oxide pass image and final pixels against pinned libjxl color and alpha.
+- Pin partial jxl-oxide first-pass alpha references for SDR8 and PQ16 JPEG XL,
+ before the complete encoded payload is available.
+
### 2026-09-19 UTC
- Add bounded JPEG XL Level 10 native decode and lossless writing for unsigned
diff --git a/README.md b/README.md
index 79fe57c3..78ebc81d 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
PureJsImage
-Zero-runtime-dependency image processing in strict TypeScript
+Zero-runtime-dependency image processing in strict TypeScript
-Portable image codecs · native scientific rasters · optional explicit WASM acceleration
+Images, scientific data and geographic rasters · optional WASM acceleration
@@ -20,20 +20,21 @@
Image demo ·
Scientific explorer ·
4D-STEM explorer ·
- Whole-slide demo ·
- OME-Zarr demo ·
- Raster X-Ray ·
- HDR Surgery ·
- PureJsImage Lab
+ Whole-slide demo ·
+ OME-Zarr demo ·
+ Raster X-Ray ·
+ HDR Surgery ·
+ PureJsImage Lab
-
-
Sometimes the wrong tool for the job is the right one 😉
-
+Sometimes the wrong tool for the job is the right one 😉
Why I started building PureJsImage
+Whole-slide viewing reads the tiles needed for a viewport where the source supports selective
+access. The screenshot records one session, not a promise that every operation reads the same fraction.
+
@@ -41,60 +42,109 @@
Measured HTTP Range session from the live browser viewer: only the visible pyramid tiles were read.
-
-
-
-
-
-Raster X-Ray connects a bounded output block or tile to its recorded logical reads and physical ranges.
+## Choose the API for your data
+PureJsImage opens, inspects, transforms and writes images in Node.js and modern browsers.
+Its first-party TypeScript codecs have no runtime dependency tree, native addon or system executable.
+It is useful when the same image workflow needs to run in a browser and on a server, or when
+source-sized RGBA buffers make a JavaScript pipeline too expensive in AWS Lambda.
+- **Lower memory use:** process rows, strips, tiles or regions when the format allows it. Some
+ operations need a full image or saved coefficients; those limits are documented.
+- **Clear errors:** unsupported files and options fail with an explanation. Benchmarks check
+ that the output is correct before reporting speed.
+- **Optional acceleration:** add JPEG, PNG or WebP WASM accelerators when they help your application.
+ You import and register them yourself; the default TypeScript path never loads them.
-## What PureJsImage is best at
+| API | Use it for | What to know |
+| --- | --- | --- |
+| [Image codecs and processing](#ordinary-image-pipeline) | Open, orient, crop, resize, convert and encode | Read and write support vary by codec. Ordinary images can also have high-precision samples. |
+| [Scientific datasets](#scientific-datasets) | Original numeric samples, axes, channels, calibration, spectra, planes and volumes | A reader does not imply a writer or every instrument/container variant. You choose how numbers become display colors. |
+| [Geographic rasters](#geographic-rasters) | Spatial reference, georeferencing, dimensions, regions, overviews and supported reprojection | Uses the supported raster readers. Check the format’s region and projection support. |
-- **Low peak memory usage** - bounded rows, scanlines, strips, and tiles avoid source-sized RGBA working sets where formats permit it.
-- **Portable across Node.js and modern browsers** - strict TypeScript runs without native addons or system executables.
-- **Zero dependencies** - the published package has no runtime dependency tree. You can also easily tree-shake our exports.
-- **Native scientific raster processing** - direct-range reads and native-precision data avoid forcing scientific samples through RGBA.
-- **Benchmarks that check correctness** - ordinary codec and scientific-reader suites measure speed, peak RSS, output validity, source I/O, and package footprint.
-- **Optional WASM accelerators** - explicit JPEG, PNG, and WebP accelerator imports never load or activate automatically.
-- **HUGE tested compatibility matrix** - we used the Imazen image corpus to cover a huge range of real files. PureJsImage can open more format variations than nearly any other JS image library we compared.
-- **Benchmaxxed performance** - we painstakingly hillclimbed performance metrics vs other JS image libraries. Most of the hot loops have been unrolled. We are still slower than Sharp but mostly faster than anything else that is pure JS.
+A TIFF can be an ordinary image, a scientific dataset or a geographic raster depending on its data
+and the API you use. These areas overlap; their format counts are not additive. JPEG XL's
+native-channel API does not register it as a scientific reader, and recognizing a container does
+not imply support for every schema it can hold.
-PureJsImage provides portable image codecs and low-memory raster workflows for Node.js
-and modern browsers. The permanent reference engine is strict TypeScript with no runtime dependency
-tree, native addon, system executable, or implicit WebAssembly download. Optional JPEG,
-PNG, and WebP WASM accelerators use separate imports and explicit registration.
+[Install](#install) · [JPEG XL tools](#jpeg-xl) · [Format tables](#supported-formats) ·
+[Package size](#package-sizes) · [Benchmarks](#current-benchmark-snapshots) ·
+[Development](#development)
-Low memory is a primary product requirement. PureJsImage exists to avoid the source-sized RGBA
-working sets that make common JavaScript image pipelines expensive or unsafe in constrained
-environments such as AWS Lambda. Codecs push crops and resize toward decode, retain bounded rows or
-tiles where their structure permits it, and label full-frame fallbacks explicitly.
+## Supported formats
-
-
-## Current package surface
+
+All formats below have a TypeScript implementation. WASM is optional and covers only the listed work; other supported operations use TypeScript. Click a format for its full support list.
+
+### Stable ordinary codecs
-PureJsImage 0.17.0 is a zero-runtime-dependency strict TypeScript image-processing package for Node.js and modern browsers. The default path uses portable TypeScript implementations; optional JPEG, PNG, and WebP WASM accelerators require explicit registration.
+| Format | Read | Write | Optional WASM | What’s supported |
+| --- | --- | --- | --- | --- |
+| [JPEG](jpeg-codec-support.md) | Yes | Yes | [Baseline read/write](https://purejsimage.com/api/#wasm-acceleration) | 8-bit baseline and progressive; grayscale, RGB and CMYK. Gain maps use the HDR API. |
+| [PNG](png-codec-support.md) | Yes | Yes | [8-bit read/write](https://purejsimage.com/api/#wasm-acceleration) | 1–16-bit decode; 8/16-bit grayscale, RGB and RGBA output. No APNG frame decode. |
+| [WebP](webp-codec-support.md) | Yes | Yes | [Pixel/transform steps](https://purejsimage.com/api/#wasm-acceleration) | Static lossy, lossless and near-lossless output; alpha supported. No animation. |
+| [BMP](bmp-codec-support.md) | Yes | Yes | No | Common Windows/OS2 and palette/RLE inputs; 24-bit RGB or 32-bit RGBA output. |
+| [TIFF](tiff-codec-support.md) | Yes | Yes | No | Classic TIFF/BigTIFF, strips, tiles and pages. Scientific, OME and whole-slide APIs are separate. |
+| [GIF](gif-codec-support.md) | Static / explicit frame 0 | No | No | Static images; animated files require frame 0 selection. No GIF output. |
+| [ICO](ico-codec-support.md) | Yes | No | No | One selected PNG or DIB icon; transparency supported. No CUR or ICO output. |
+| [JPEG 2000 / JP2](jpeg2000-codec-support.md) | Yes | No | No | Static Part 1 JP2, 1–16-bit samples. No HTJ2K, JPX or encoding. |
+| [AVIF](avif-codec-support.md) | Yes | Limited | No | Common 8/10/12-bit stills and alpha. Writer is limited to opaque 8-bit YUV420; no general animation. |
+| [JPEG XL](jpegxl-codec-support.md) | Stable common static | Stable lossless, static lossy and exact transcode | No | Lossless/static lossy, exact JPEG recovery, high-depth samples and separate animation APIs. Lossy animation is Experimental. |
+| [Radiance HDR / RGBE](hdr-codec-support.md) | Yes | Yes | No | RGBE decode to float32 RGB and RGBE output. No XYZE. |
+| [QOI](qoi-codec-support.md) | Yes | Yes | No | QOI v1 RGB/RGBA still images. |
+| [Netpbm and PFM](netpbm-codec-support.md) | Yes | Yes | No | PBM, PGM, PPM, PAM and float32 PFM; one image per file. |
+| [TGA / TARGA](tga-codec-support.md) | Yes | Yes | No | Palette, grayscale and RGB/RGBA inputs, including RLE; RGB/RGBA output. |
-**14 stable ordinary codecs:** JPEG, PNG, WebP, BMP, TIFF, GIF, ICO, JPEG 2000 / JP2, AVIF, JPEG XL, Radiance HDR / RGBE, QOI, Netpbm and PFM, TGA / TARGA.
+### Experimental codecs
-**Experimental:** HEIF / HEIC (experimental) remains a separate explicit import, is excluded from `allCodecs`, and carries the documented HEVC/H.265 patent notice.
+| Format | Read | Write | Optional WASM | What’s supported |
+| --- | --- | --- | --- | --- |
+| [HEIF / HEIC (experimental)](heif-codec-support.md) | Experimental | No | No | Opt-in HEVC and uncompressed still subsets, including alpha and grids. Patent notice below. |
-**33 scientific readers:** Common raster and whole-slide (9); Electron microscopy (7); AFM, SPM, and surface metrology (5); Medical and volume interchange (5); Spectroscopy and detector interchange (6); Raw numeric interchange (1). Direct-range readers request selected source spans, and specialized scientific readers retain native numeric precision instead of forcing data through RGBA.
+“Limited” means only the documented subset is supported. Experimental codecs need a separate import and are excluded from `allCodecs`.
-| Current measured surface | Minified JS | gzip | Brotli |
-| --- | ---: | ---: | ---: |
-| Core API | 19.5 KiB | 6.6 KiB | 5.9 KiB |
-| Common web codecs | 651.5 KiB | 238.7 KiB | 198.2 KiB |
-| All stable codecs | 1198.2 KiB | 414.4 KiB | 334.0 KiB |
-| Scientific platform | 197.5 KiB | 56.3 KiB | 47.4 KiB |
-| All scientific readers | 1242.8 KiB | 359.4 KiB | 286.7 KiB |
-| Geo raster platform | 138.1 KiB | 37.5 KiB | 32.0 KiB |
-| All Geo readers | 624.9 KiB | 189.1 KiB | 153.0 KiB |
+WASM does not cover every file the TypeScript codec can read. JPEG acceleration handles common baseline images; PNG handles common non-interlaced 8-bit images; WebP accelerates selected color and transform steps. [Setup, supported cases and fallback behavior](https://purejsimage.com/api/#wasm-acceleration).
-The extracted npm package is 7.9 MiB with 1 production package. This is unpacked size, not the compressed npm tarball.
-
+[See the exact codec support matrix →](https://purejsimage.com/codecs/)
+
+
+Full support lists for each format
+
+[JPEG](https://github.com/a-r-d/PureJsImage/blob/main/jpeg-codec-support.md),
+[PNG](https://github.com/a-r-d/PureJsImage/blob/main/png-codec-support.md),
+[WebP](https://github.com/a-r-d/PureJsImage/blob/main/webp-codec-support.md),
+[BMP](https://github.com/a-r-d/PureJsImage/blob/main/bmp-codec-support.md),
+[TIFF](https://github.com/a-r-d/PureJsImage/blob/main/tiff-codec-support.md),
+[GIF](https://github.com/a-r-d/PureJsImage/blob/main/gif-codec-support.md),
+[ICO](https://github.com/a-r-d/PureJsImage/blob/main/ico-codec-support.md),
+[JPEG 2000 / JP2](https://github.com/a-r-d/PureJsImage/blob/main/jpeg2000-codec-support.md),
+[AVIF](https://github.com/a-r-d/PureJsImage/blob/main/avif-codec-support.md),
+[JPEG XL](https://github.com/a-r-d/PureJsImage/blob/main/jpegxl-codec-support.md),
+[Radiance HDR / RGBE](https://github.com/a-r-d/PureJsImage/blob/main/hdr-codec-support.md),
+[QOI](https://github.com/a-r-d/PureJsImage/blob/main/qoi-codec-support.md),
+[Netpbm and PFM](https://github.com/a-r-d/PureJsImage/blob/main/netpbm-codec-support.md),
+[TGA / TARGA](https://github.com/a-r-d/PureJsImage/blob/main/tga-codec-support.md),
+and [HEIF / HEIC (experimental)](https://github.com/a-r-d/PureJsImage/blob/main/heif-codec-support.md).
+
+
+
+
+
+
+### How fast is it?
+
+These examples time the whole decode, resize and encode operation. Median time in milliseconds; lower is faster. WASM does not improve every operation.
+
+| Example operation | TypeScript | With optional WASM |
+| --- | ---: | ---: |
+| 12 MP JPEG → 1200 px wide JPEG (quality 80) | 518 ms | 474 ms |
+| 12 MP RGBA PNG → 1000 px wide PNG | 398 ms | 435 ms |
+| 3.2 MP WebP → 800 px wide JPEG (quality 80) | 345 ms | 314 ms |
+
+Recorded 2026-08-24 with PureJsImage 0.16.0 (workspace), Node v24.16.0, Linux 6.17.0-41-generic/x64, Intel(R) Core(TM) i7-10700 CPU @ 2.90GHz. These are Node results; browser and device speeds differ. The WASM configuration can also use TypeScript for steps without an accelerator.
+
+[Full report, settings and results for other libraries](benchmark/results/public/web-codecs-2026-08-24T20-51-40-120Z.json). Other formats have different costs; see the [benchmark charts](#current-benchmark-snapshots) for more operations.
+
## Install
@@ -107,6 +157,7 @@ PureJsImage requires Node.js 22 or newer. Browser applications import the core A
## Ordinary image pipeline
+This Node.js example reads a JPEG, applies its orientation, resizes it and writes a JPEG.
Register only the codecs an application needs:
```ts
@@ -124,6 +175,34 @@ await image
.toFile("output.jpg");
```
+### In a browser
+
+Import the core from `purejsimage/browser`. Use `File`, `Blob`, `ArrayBuffer`, `Uint8Array` or an
+explicit `ImageSource` as input. A browser cannot open a local path string or call `toFile()`;
+return a `Blob` or bytes instead. This example uses the prebuilt JPEG, PNG, WebP and AVIF group:
+
+```ts
+import { createImageLibrary } from "purejsimage/browser";
+import { allWebCodecs } from "purejsimage/codecs/web";
+
+const webImages = createImageLibrary(allWebCodecs);
+
+export async function resizeUpload(file: File): Promise {
+ const image = await webImages.open(file);
+ return image.autoOrient().resize({ width: 1200, withoutEnlargement: true }).png().toBlob();
+}
+```
+
+Run expensive codec operations in a worker to keep the page responsive. The
+[image demo](https://purejsimage.com/demo/) uses the same public pipeline.
+
+TIFF remains an explicit `purejsimage/codecs/tiff` import because including it would substantially
+increase the web-focused bundle. Use `purejsimage/codecs/all` when you need every stable codec. See the [API reference](https://purejsimage.com/api/) for
+inputs, outputs and limits, and the [native precision guide](docs/native-precision.md) for supported
+high-depth transforms, color handling and explicit sample conversion.
+
+### Memory and optional temporary storage
+
Node orientation and rotation use lazy chunked memory by default. They do not open temporary files.
This default is portable and was 20–28% faster than file storage across the measured orientation
and arbitrary-rotation cases.
@@ -143,20 +222,6 @@ truncation before consuming image rows. Failed setup or later file writes, inclu
the spool to chunked memory and preserve output. An error that prevents recovery of bytes already
written to the file is reported as a structured `ImageError`.
-Use `purejsimage/browser` with `File`, `Blob`, `Uint8Array`, or an explicit `ImageSource` in a
-browser. For the common web formats, register the prebuilt JPEG, PNG, WebP, and AVIF group:
-
-```ts
-import { createImageLibrary } from "purejsimage";
-import { allWebCodecs } from "purejsimage/codecs/web";
-
-const webImages = createImageLibrary(allWebCodecs);
-```
-
-TIFF remains an explicit `purejsimage/codecs/tiff` import because including it would substantially
-increase the web-focused bundle. Use `purejsimage/codecs/all` only when the complete stable codec
-aggregate is appropriate.
-
## Gain-map HDR images
Use the separate `purejsimage/hdr` entry for Ultra HDR and ISO 21496-1 gain-map JPEG or AVIF
@@ -182,25 +247,50 @@ try {
The renderer returns independently owned linear `rgbf32` or `rgbaf32` row blocks and preserves
values above SDR white. Transformed rendering keeps the decoded and transformed 8-bit component
-rasters inside one caller-selected aggregate materialization budget. It does not allocate a complete
+rasters inside one shared memory limit chosen by the caller. It does not allocate a complete
adapted Float32 image. JPEG gain-map input is limited to an SDR primary. Re-encoded JPEG primaries
carry PureJsImage's deterministic sRGB ICC profile. The first gain-map AVIF writer is limited to an
-opaque sRGB SDR base and one-channel semantic map. See the [gain-map HDR guide](docs/hdr-surgery.md) and the local
+opaque sRGB SDR base and one-channel gain map. See the [gain-map HDR guide](docs/hdr-surgery.md) and the local
[HDR Surgery browser workbench](https://purejsimage.com/hdr-surgery/).
+## JPEG XL
+
+> The six new tool pages below are included in this repository’s docs build. Their public URLs
+> will become available when that build is deployed.
+
+Try the [JPEG XL encoder and decoder hub](https://purejsimage.com/jpeg-xl/),
+[converter](https://purejsimage.com/jpeg-xl/convert/) or
+[exact JPEG round-trip tool](https://purejsimage.com/jpeg-xl/jpeg-recompression/).
+Static lossy encoding is Stable for the documented integer subset; lossless remains the default.
+[Lossy animation](https://purejsimage.com/jpeg-xl/animation/) remains Experimental.
+
+Pixel-lossless encoding preserves supported sample values. Exact JPEG recompression preserves the
+eligible original JPEG bytes, including reconstruction data. Native sample preservation is
+separate from a resized or display-mapped export. Explore those differences in the
+[native inspector](https://purejsimage.com/jpeg-xl/native/) and the
+[progressive/region explorer](https://purejsimage.com/jpeg-xl/progressive/).
+
+The [JavaScript comparison](https://purejsimage.com/jpeg-xl/comparison/) reports measured quality,
+size, speed and API boundaries for PureJsImage, jSquash, jxl-oxide-wasm and wasm-vips. It retains
+failures and incomplete comparisons, along with each library’s strengths.
+The tools use the repository build, which can be ahead of npm; the comparison names its tested
+revision. See the [API and precision guide](docs/jpeg-xl.md) and
+[comparison methods and results](benchmark/jpegxl/comparison/REPORT.md).
+
+## Viewing large images
+
+[Raster X-Ray](https://purejsimage.com/xray/) connects an output block or tile to its recorded
+reads and file ranges. Use it to see which parts of a large source an operation reads.
+
+[Whole-slide demo](https://purejsimage.com/wsi/) · [OME-Zarr demo](https://purejsimage.com/ome-zarr/) ·
+[How Raster X-Ray tracks reads](docs/execution-evidence.md) · [Tile memory model](docs/contracts/tile-memory-model.md)
+
## Scientific datasets
Scientific readers preserve labeled axes, calibration, native sample types, and the ability to read
only the parts you need. Numeric data becomes display pixels only when an application explicitly
chooses a range, palette, slice, or projection.
-
-
-
-
-
-The explorer maps native samples to display pixels in the tab. It does not write an RGB conversion of the cube.
-
```ts
import { FileSource } from "purejsimage";
import { createScientificLibrary } from "purejsimage/scientific";
@@ -214,12 +304,43 @@ const document = await science.open({
source: await FileSource.open("input.ome.tif"),
},
});
-const dataset = await document.openDataset(document.datasets[0].id);
+try {
+ const first = document.datasets[0];
+ if (!first) throw new Error("No scientific dataset found");
+ const dataset = await document.openDataset(first.id);
+ console.log(dataset.descriptor.axes); // Choose axes before reading a plane or region.
+} finally {
+ await document.close?.();
+}
```
+In a browser, keep the same scientific library and reader imports. Replace the Node `FileSource`
+with a `BlobSource` from `purejsimage/browser`, or use the file/companion helpers in
+`purejsimage/scientific/browser`. Files with sidecars need those companion resources too.
+See the [browser resource guide](docs/scientific-reader-registry.md).
+
+Direct-range readers can request only the source spans needed for metadata, a native-precision
+region, a spectrum, a volume plane, or a whole-slide tile. This includes workflows across DM3 and
+DM4, TIA SER and EMI, NCEM and Velox EMD, NIfTI, NRRD, MRC, OME-TIFF, Aperio SVS, AFM and surface
+metrology, and 4D-STEM data.
+
+[Scientific format reference →](https://purejsimage.com/scientific-formats/) ·
+[Scientific API reference →](https://purejsimage.com/api/#scientific) ·
+[Scientific application guide →](docs/application-platform.md) ·
+[OME-Zarr reader and validation policy →](docs/scientific-ome-zarr.md) ·
+[OME-Zarr compatibility results →](docs/generated/ome-zarr-compatibility.md) ·
+[Reading numeric tiles →](docs/scientific-numeric-tiles.md) ·
+[Bounded raster analysis →](docs/bounded-raster-analysis.md)
+
+## Geographic rasters
+
+Use `purejsimage/geo` with explicit readers from `purejsimage/geo/readers` when coordinates and
+spatial reference are part of the operation. Displaying a TIFF through the ordinary pipeline does
+not by itself apply its georeferencing or reproject it.
+
Ordinary GeoTIFFs opened through `tiffReader` expose a typed
`dataset.descriptor.spatialReference` with CRS identity/citation, pixel-to-model affine, inverse
-when invertible, model bounds, pixel interpretation, nodata, and JSON-safe GeoTIFF evidence.
+when invertible, model bounds, pixel interpretation, nodata, and GeoTIFF source metadata.
`readPlane()` regions remain raster pixel coordinates.
For Cloud Optimized GeoTIFF workflows, `inspectCog()` reports container, IFD/SubIFD, tile,
@@ -232,7 +353,7 @@ GeoTIFF and GeoZarr are also available as lazy `GeoRasterDataset` readers under
v3 shards, multiscales, HTTP stores, local directories, and ZIP stores without adding another Zarr
decoder. Band, time, vertical, ensemble, and custom dimensions remain selectable axes.
World-file TIFF, JPEG, and PNG images, georeferenced ENVI, Esri ASCII Grid, and SRTM HGT use the same
-geo contract while retaining each format's honest region-read limits.
+geo API, with region-read support that depends on the format.
Classic NetCDF CDF-1 and CDF-2 files with regular rectilinear CF coordinates are available through
`purejsimage/geo/readers/netcdf`. Time and vertical dimensions remain selectable. CDF-5,
HDF5-backed NetCDF4, irregular coordinate lookup, and curvilinear grids are reported explicitly.
@@ -253,89 +374,24 @@ HDF5-backed NetCDF4, irregular coordinate lookup, and curvilinear grids are repo
| Classic NetCDF / CF | Tested | Tested | Tested | Unavailable | Fixture-limited | Out of scope |
“Fixture-limited” is implemented behavior with a narrow current corpus. “Metadata only” does not
-claim the related pixel operation. See the [complete generated geo evidence table](docs/generated/geo-compatibility.md)
+claim the related pixel operation. See the [full geographic support table](docs/generated/geo-compatibility.md)
and the [machine-readable manifest](docs-astro/public/geo-capabilities.json).
-Direct-range readers can request only the source spans needed for metadata, a native-precision
-region, a spectrum, a volume plane, or a whole-slide tile. This includes workflows across DM3 and
-DM4, TIA SER and EMI, NCEM and Velox EMD, NIfTI, NRRD, MRC, OME-TIFF, Aperio SVS, AFM and surface
-metrology, and 4D-STEM data.
-
-The live Scientific Raster Explorer has five curated sample modes and a generated generic reader
-catalog. The generic path loads likely explicit reader chunks from safe hints, probes file bytes
-under fixed limits, and asks for an explicit reader when detection is ambiguous.
-
-[Scientific format reference →](https://purejsimage.com/scientific-formats/) ·
-[Scientific API reference →](https://purejsimage.com/api/#scientific) ·
-[Scientific application guide →](docs/application-platform.md) ·
-[OME-Zarr reader and validation policy →](docs/scientific-ome-zarr.md) ·
-[OME-Zarr public compatibility evidence →](docs/generated/ome-zarr-compatibility.md) ·
[Geo raster architecture →](docs/geo-architecture.md) ·
[GeoZarr reader →](docs/geozarr-reader.md) ·
[Contained geo formats →](docs/geo-contained-formats.md) ·
-[Classic NetCDF and CF grids →](docs/netcdf-cf.md) ·
-[Native numeric tile contract →](docs/scientific-numeric-tiles.md) ·
-[Bounded raster analysis →](docs/bounded-raster-analysis.md)
+[Classic NetCDF and CF grids →](docs/netcdf-cf.md)
-## Supported Formats
-
-
-### Stable ordinary codecs
-
-| Format | Read | Write |
-| --- | --- | --- |
-| JPEG | Yes | Yes |
-| PNG | Yes | Yes |
-| WebP | Yes | Yes |
-| BMP | Yes | Yes |
-| TIFF | Yes | Yes |
-| GIF | Static / explicit frame 0 | No |
-| ICO | Yes | No |
-| JPEG 2000 / JP2 | Yes | No |
-| AVIF | Yes | Limited |
-| JPEG XL | Stable common static | Stable lossless and exact transcode; experimental lossy |
-| Radiance HDR / RGBE | Yes | Yes |
-| QOI | Yes | Yes |
-| Netpbm and PFM | Yes | Yes |
-| TGA / TARGA | Yes | Yes |
-
-### Experimental codecs
-
-| Format | Read | Write |
-| --- | --- | --- |
-| HEIF / HEIC (experimental) | Experimental | No |
-“Limited” means PureJsImage supports a useful subset and clearly rejects files
-outside it.
-“Experimental” means the codec is excluded from `allCodecs` and requires an
-explicit direct import and registration.
-
-[See the exact codec support matrix →](https://purejsimage.com/codecs/)
-
-Detailed codec compatibility roadmaps:
-[JPEG](https://github.com/a-r-d/PureJsImage/blob/main/jpeg-codec-support.md),
-[PNG](https://github.com/a-r-d/PureJsImage/blob/main/png-codec-support.md),
-[WebP](https://github.com/a-r-d/PureJsImage/blob/main/webp-codec-support.md),
-[BMP](https://github.com/a-r-d/PureJsImage/blob/main/bmp-codec-support.md),
-[TIFF](https://github.com/a-r-d/PureJsImage/blob/main/tiff-codec-support.md),
-[GIF](https://github.com/a-r-d/PureJsImage/blob/main/gif-codec-support.md),
-[ICO](https://github.com/a-r-d/PureJsImage/blob/main/ico-codec-support.md),
-[JPEG 2000 / JP2](https://github.com/a-r-d/PureJsImage/blob/main/jpeg2000-codec-support.md),
-[AVIF](https://github.com/a-r-d/PureJsImage/blob/main/avif-codec-support.md),
-[JPEG XL](https://github.com/a-r-d/PureJsImage/blob/main/jpegxl-codec-support.md),
-[Radiance HDR / RGBE](https://github.com/a-r-d/PureJsImage/blob/main/hdr-codec-support.md),
-[QOI](https://github.com/a-r-d/PureJsImage/blob/main/qoi-codec-support.md),
-[Netpbm and PFM](https://github.com/a-r-d/PureJsImage/blob/main/netpbm-codec-support.md),
-[TGA / TARGA](https://github.com/a-r-d/PureJsImage/blob/main/tga-codec-support.md),
-and [HEIF / HEIC (experimental)](https://github.com/a-r-d/PureJsImage/blob/main/heif-codec-support.md).
-
-### Scientific reader package surface
+
-The package currently exposes **33 scientific readers** through explicit purejsimage/scientific/readers/* exports. This family summary is generated from the scientific reader inventory in capabilities/manifest.json, the package exports, and src/scientific/readers/all.ts.
+### Scientific readers
+
+Import the **33 scientific readers** from `purejsimage/scientific/readers/*` or choose a group below. Counts come from the reader manifest and package exports.
| Reader family | Count | Representative formats |
| --- | ---: | --- |
@@ -346,11 +402,9 @@ The package currently exposes **33 scientific readers** through explicit purejsi
| [Spectroscopy and detector interchange](https://purejsimage.com/scientific-formats/#spectroscopy-detector-interchange) | 6 | ENVI, FITS, CBF/imgCIF, Lispix RPL/RAW, EMSA/MAS spectrum, ANG/CTF orientation map |
| [Raw numeric interchange](https://purejsimage.com/scientific-formats/#raw-numeric-interchange) | 1 | NumPy NPY |
-The complete per-reader imports and support boundaries remain on the [scientific format reference](https://purejsimage.com/scientific-formats/), the [API reference](https://purejsimage.com/api/#scientific), and the machine-readable [capability manifest](capabilities/manifest.json).
-
-The live browser explorer provides curated samples for Gwyddion Simple Field (`purejsimage/scientific/readers/gsf`), ENVI (`purejsimage/scientific/readers/envi`), FITS (`purejsimage/scientific/readers/fits`), MRC/CCP4 (`purejsimage/scientific/readers/mrc`), CBF/imgCIF (`purejsimage/scientific/readers/cbf`). Its generic tab builds a reader catalog from this same inventory, loads likely explicit reader chunks from safe filename and media-type hints, and confirms the format with bounded byte probing or explicit user selection.
+Find imports and supported variants in the [scientific format reference](https://purejsimage.com/scientific-formats/), the [API reference](https://purejsimage.com/api/#scientific), and the machine-readable [capability manifest](capabilities/manifest.json).
-The raster APIs preserve native numeric data instead of forcing every source through RGB. The full reader surface includes scientific images and volumes, spectroscopy and instrument data, microscopy and whole-slide data, surface and metrology formats, and ordinary image adapters.
+The browser explorer includes sample files for Gwyddion Simple Field (`purejsimage/scientific/readers/gsf`), ENVI (`purejsimage/scientific/readers/envi`), FITS (`purejsimage/scientific/readers/fits`), MRC/CCP4 (`purejsimage/scientific/readers/mrc`), CBF/imgCIF (`purejsimage/scientific/readers/cbf`). For other files, its generic tab uses the filename and media type to select likely readers, then checks a limited number of bytes to confirm the format. You can also select a reader yourself.
Experimental HEIF/HEIC is available only from `purejsimage/codecs/experimental/heic`. It remains
@@ -362,9 +416,9 @@ distributors must evaluate their own licensing obligations.
## Current benchmark snapshots
-**Web codec benchmarks (2026-08-24):** 122 validated passes, 25 explicit unsupported rows, and no invalid outputs or errors across JPEG, PNG, WebP, TIFF, and AVIF workflows. On the 24-megapixel northstar photo pipeline, the TypeScript path used 84.7% less absolute peak RSS than Jimp (181.8 MiB versus 1189.1 MiB).
+**Web codec benchmarks (2026-08-24):** 122 passing cases, 25 unsupported cases, and no invalid outputs or errors across JPEG, PNG, WebP, TIFF, and AVIF workflows. On the 24-megapixel photo pipeline, the TypeScript path used 84.7% less absolute peak RSS than Jimp (181.8 MiB versus 1189.1 MiB).
-**Scientific readers (2026-08-17):** 43 correctness and startup workflows passed across 31 readers in that snapshot. The separate medium/large scaling profile validated 13 representative workloads; 6 met the under-10% CV publication threshold and the remaining rows stay visible as noisy. Results report first usable block, selected-operation time, absolute peak RSS, source requests and bytes, overfetch, import/initialization, and emitted-block correctness without collapsing formats into one winner score.
+**Scientific readers (2026-08-17):** 43 correctness and startup checks passed across 31 readers in that snapshot. Separate tests of medium and large datasets checked 13 representative workloads; 6 had a coefficient of variation below 10%; the rest are marked noisy in the report. Results include time to first data, operation time, peak memory, requests and bytes read, extra data fetched, startup time and output checks.
> Ordinary and scientific reports use separately fingerprinted harnesses. No cross-section speed or memory ratio is claimed.
@@ -373,14 +427,14 @@ distributors must evaluate their own licensing obligations.
-Median wall time from the current validated web codec benchmark snapshot.
+Median wall time in milliseconds; lower is better. Native Sharp/libvips results are not WASM measurements.
-Absolute process peak RSS from the current validated web codec benchmark snapshot.
+Absolute process peak RSS in MiB; lower is better. This includes runtime overhead, not just codec-managed buffers.
[Web codec benchmarks](https://purejsimage.com/performance/#web-codec-benchmarks) · [Scientific methodology and report](https://purejsimage.com/performance/#scientific-readers) · [Benchmark harness](benchmark/README.md) · [Generated result index](benchmark/results/public/index.json)
@@ -390,28 +444,53 @@ kept separate from current local benchmark headlines. See the
[performance page](https://purejsimage.com/performance/#lambda) for dates, architecture, and exact
artifacts.
+
+
+
+
+## Package sizes
+
+Generated from the repository manifests and recorded package metrics (package version 0.17.0). A repository checkout can include APIs newer than the published package.
+
+**14 stable ordinary codecs** and **1 experimental codec** are listed in the [format tables](#supported-formats). Read/write subsets remain separate.
+
+**33 scientific readers** are grouped in the [reader table](#scientific-reader-package-surface). This count includes ordinary image adapters; it is not an additional count of unique file formats.
+
+| Bundle | Minified JS | gzip | Brotli |
+| --- | ---: | ---: | ---: |
+| Core API | 19.5 KiB | 6.6 KiB | 5.9 KiB |
+| Common web codecs | 651.5 KiB | 238.7 KiB | 198.6 KiB |
+| All stable codecs | 1214.5 KiB | 420.2 KiB | 338.8 KiB |
+| Scientific platform | 197.5 KiB | 56.3 KiB | 47.4 KiB |
+| All scientific readers | 1242.8 KiB | 359.5 KiB | 286.8 KiB |
+| Geo raster platform | 138.1 KiB | 37.5 KiB | 32.0 KiB |
+| All Geo readers | 625.0 KiB | 189.1 KiB | 153.3 KiB |
+
+The extracted npm package is 8.0 MiB with 1 production package, including PureJsImage itself. This is unpacked size, not the compressed npm tarball.
+
+
### Bundle size and npm package size
-Generated for purejsimage 0.17.0. The README keeps only the major entry points; the complete per-codec, per-reader, competitor, gzip, Brotli, installed-package, and WASM measurements are on the performance page and in the machine-readable artifact.
+Generated for purejsimage 0.17.0. Use these imports to select an entry point; the table above also includes the geographic API and readers. Per-codec, per-reader, competitor, installed-package and WASM measurements are linked below.
-| Surface | Import | Minified JS | gzip | Brotli |
+| Bundle | Import | Minified JS | gzip | Brotli |
| --- | --- | ---: | ---: | ---: |
| Core API initial chunk | `purejsimage` | 19.5 KiB | 6.6 KiB | 5.9 KiB |
-| Core + common web codecs | `purejsimage/codecs/web` | 651.5 KiB | 238.7 KiB | 198.2 KiB |
-| Core + all stable codecs | `purejsimage/codecs/all` | 1198.2 KiB | 414.4 KiB | 334.0 KiB |
+| Core + common web codecs | `purejsimage/codecs/web` | 651.5 KiB | 238.7 KiB | 198.6 KiB |
+| Core + all stable codecs | `purejsimage/codecs/all` | 1214.5 KiB | 420.2 KiB | 338.8 KiB |
| Core + scientific platform | `purejsimage/scientific` | 197.5 KiB | 56.3 KiB | 47.4 KiB |
-| Scientific readers: all | `purejsimage/scientific/readers/all` | 1242.8 KiB | 359.4 KiB | 286.7 KiB |
+| Scientific readers: all | `purejsimage/scientific/readers/all` | 1242.8 KiB | 359.5 KiB | 286.8 KiB |
-The extracted npm package is 7.9 MiB and has 1 production package. The eight optional JPEG, PNG, and WebP accelerator assets total 175.7 KiB raw WASM and are loaded only through explicit accelerator imports.
+The 8 optional JPEG, PNG, and WebP accelerator assets total 175.7 KiB raw WASM and are loaded only through explicit accelerator imports. See the unpacked package total above; these assets are separate from the JavaScript transfer sizes.
[Complete size and footprint tables →](https://purejsimage.com/performance/#package-footprint) · [Machine-readable package metrics](benchmark/generated/package-metrics.json)
-## Evidence and methodology
+## Tests and benchmark methods
-- [Execution evidence and Raster X-Ray](docs/execution-evidence.md)
+- [How Raster X-Ray tracks reads](docs/execution-evidence.md)
- [Benchmark methodology and current charts](https://purejsimage.com/performance/)
- [Date-stamped benchmark results and indexes](benchmark/results/)
- [Machine-readable package metrics](benchmark/generated/package-metrics.json)
@@ -428,21 +507,11 @@ The checked 2026-08-13 snapshot compared documented TIFF capabilities separately
[Full grouped capability matrix, methods, sources, and per-library results](https://purejsimage.com/tiff-comparison/)
-## Why PureJsImage?
-
-- Low-memory execution is the main goal: avoid source-sized bitmaps and duplicate full-frame buffers
- wherever codec structure permits bounded rows, tiles, regions, or coefficient storage.
-- Strict TypeScript codecs remain the portable production and fallback path.
-- Zero runtime dependencies simplify browser, serverless, edge, air-gapped, and restricted builds.
-- Bounded rows, tiles, regions, and source reads are used where the format permits them.
-- Unsupported syntax fails explicitly instead of producing plausible corruption.
-- Validation, quality oracles, peak RSS, source I/O, and package footprint remain part of the
- benchmark contract.
+### When to use another library
-If native libvips is deployable and throughput is the primary constraint, use
-[Sharp](https://sharp.pixelplumbing.com/). PureJsImage is aimed at workflows where portable source,
-browser parity, explicit dependency boundaries, native scientific data, or lower-memory JavaScript
-execution matter.
+If you can install native libvips and mainly need high throughput, consider
+[Sharp](https://sharp.pixelplumbing.com/). PureJsImage is useful when you need the same code in Node and a browser, original scientific
+samples, or image processing without installing a native library.
## Citation
@@ -465,6 +534,10 @@ npm run check
Read [CONTRIBUTING.md](CONTRIBUTING.md), the [architecture](project-spec.md), and the
[roadmap](ROADMAP.md) before larger changes.
+## License
+
+[MIT](LICENSE). The HEIF/HEIC patent notice above is separate from the project license.
+
## Special thanks
Thanks to [Imazen](https://github.com/imazen) for the real-world image corpus used in compatibility
diff --git a/benchmark/corpus/manifest.json b/benchmark/corpus/manifest.json
index 6bbd0026..3cffb8d9 100644
--- a/benchmark/corpus/manifest.json
+++ b/benchmark/corpus/manifest.json
@@ -18,7 +18,7 @@
{
"id": "tundra-4000x3000",
"file": "tundra-4000x3000.jpg",
- "url": "https://live.staticflickr.com/4853/31354285547_90629ea22f_o.jpg",
+ "url": "https://upload.wikimedia.org/wikipedia/commons/c/cb/Tundra_Landscape_%2831354285547%29.jpg",
"sourcePage": "https://commons.wikimedia.org/wiki/File:Tundra_Landscape_(31354285547).jpg",
"author": "Pacific Northwest Research Station, Forest Service, USDA; photograph by Janet Prevey",
"license": "Public domain, United States Department of Agriculture",
diff --git a/benchmark/generated/package-metrics.json b/benchmark/generated/package-metrics.json
index 8a4e2870..d06acfff 100644
--- a/benchmark/generated/package-metrics.json
+++ b/benchmark/generated/package-metrics.json
@@ -118,7 +118,7 @@
"readLabel": "Stable common static",
"readStatus": "supported",
"targetId": "codec-jpegxl",
- "writeLabel": "Stable lossless and exact transcode; experimental lossy",
+ "writeLabel": "Stable lossless, static lossy and exact transcode",
"writeStatus": "limited"
},
{
@@ -176,7 +176,7 @@
"package": {
"name": "purejsimage",
"version": "0.17.0",
- "unpackedPackageBytes": 8289569,
+ "unpackedPackageBytes": 8376607,
"productionPackageCount": 1
},
"schemaVersion": 3,
@@ -553,28 +553,28 @@
"packageExports": ["purejsimage/hdr"],
"sourceEntries": ["./src/hdr/index.ts"]
},
- "gzipBytes": 219258,
+ "gzipBytes": 219306,
"id": "hdr",
"implementation": "pure-javascript",
- "minifiedJsBytes": 604667,
+ "minifiedJsBytes": 604709,
"name": "HDR Surgery",
"recordedBaselineMinifiedBytes": 580322,
- "brotliBytes": 184612
+ "brotliBytes": 183833
},
{
"category": "purejsimage-entry",
- "configuredCeilingMinifiedBytes": 519000,
+ "configuredCeilingMinifiedBytes": 548000,
"entry": {
"packageExports": ["purejsimage/jpegxl"],
"sourceEntries": ["./src/jpegxl.ts"]
},
- "gzipBytes": 167628,
+ "gzipBytes": 176620,
"id": "jpegxl-specialized",
"implementation": "pure-javascript",
- "minifiedJsBytes": 518735,
+ "minifiedJsBytes": 545673,
"name": "JPEG XL specialized APIs",
"recordedBaselineMinifiedBytes": 255489,
- "brotliBytes": 133897
+ "brotliBytes": 140466
},
{
"category": "purejsimage-entry",
@@ -613,13 +613,13 @@
"packageExports": ["purejsimage/geo/readers/all"],
"sourceEntries": ["./src/geo/readers/all.ts"]
},
- "gzipBytes": 193647,
+ "gzipBytes": 193688,
"id": "geo-readers-all",
"implementation": "package-core",
- "minifiedJsBytes": 639940,
+ "minifiedJsBytes": 639980,
"name": "Geo readers: all",
"recordedBaselineMinifiedBytes": 626956,
- "brotliBytes": 156697
+ "brotliBytes": 156947
},
{
"category": "purejsimage-entry",
@@ -868,13 +868,13 @@
"packageExports": ["purejsimage/scientific/readers/png"],
"sourceEntries": ["./src/scientific/readers/png.ts"]
},
- "gzipBytes": 25471,
+ "gzipBytes": 25486,
"id": "scientific-reader-png",
"implementation": "package-core",
- "minifiedJsBytes": 77377,
+ "minifiedJsBytes": 77417,
"name": "Scientific reader: PNG",
"recordedBaselineMinifiedBytes": 67385,
- "brotliBytes": 22197
+ "brotliBytes": 22320
},
{
"category": "purejsimage-entry",
@@ -883,13 +883,13 @@
"packageExports": ["purejsimage/scientific/readers/jpeg"],
"sourceEntries": ["./src/scientific/readers/jpeg.ts"]
},
- "gzipBytes": 38865,
+ "gzipBytes": 38901,
"id": "scientific-reader-jpeg",
"implementation": "package-core",
- "minifiedJsBytes": 120364,
+ "minifiedJsBytes": 120406,
"name": "Scientific reader: JPEG",
"recordedBaselineMinifiedBytes": 104815,
- "brotliBytes": 32628
+ "brotliBytes": 32617
},
{
"category": "purejsimage-entry",
@@ -898,13 +898,13 @@
"packageExports": ["purejsimage/scientific/readers/webp"],
"sourceEntries": ["./src/scientific/readers/webp.ts"]
},
- "gzipBytes": 40640,
+ "gzipBytes": 40647,
"id": "scientific-reader-webp",
"implementation": "package-core",
- "minifiedJsBytes": 115552,
+ "minifiedJsBytes": 115592,
"name": "Scientific reader: WebP",
"recordedBaselineMinifiedBytes": 106317,
- "brotliBytes": 34933
+ "brotliBytes": 34948
},
{
"category": "purejsimage-entry",
@@ -928,13 +928,13 @@
"packageExports": ["purejsimage/scientific/readers/jp2"],
"sourceEntries": ["./src/scientific/readers/jp2.ts"]
},
- "gzipBytes": 31015,
+ "gzipBytes": 31022,
"id": "scientific-reader-jp2",
"implementation": "package-core",
- "minifiedJsBytes": 96831,
+ "minifiedJsBytes": 96871,
"name": "Scientific reader: JPEG 2000 / JP2",
"recordedBaselineMinifiedBytes": 93696,
- "brotliBytes": 27004
+ "brotliBytes": 27035
},
{
"category": "purejsimage-entry",
@@ -943,13 +943,13 @@
"packageExports": ["purejsimage/scientific/readers/tiff"],
"sourceEntries": ["./src/scientific/readers/tiff.ts"]
},
- "gzipBytes": 104791,
+ "gzipBytes": 104831,
"id": "scientific-reader-tiff",
"implementation": "package-core",
- "minifiedJsBytes": 333810,
+ "minifiedJsBytes": 333850,
"name": "Scientific reader: TIFF",
"recordedBaselineMinifiedBytes": 262942,
- "brotliBytes": 87219
+ "brotliBytes": 87247
},
{
"category": "purejsimage-entry",
@@ -958,13 +958,13 @@
"packageExports": ["purejsimage/scientific/readers/ome-tiff"],
"sourceEntries": ["./src/scientific/readers/ome-tiff.ts"]
},
- "gzipBytes": 95294,
+ "gzipBytes": 95338,
"id": "scientific-reader-ome-tiff",
"implementation": "package-core",
- "minifiedJsBytes": 302181,
+ "minifiedJsBytes": 302221,
"name": "Scientific reader: OME-TIFF",
"recordedBaselineMinifiedBytes": 267489,
- "brotliBytes": 79047
+ "brotliBytes": 79116
},
{
"category": "purejsimage-entry",
@@ -988,13 +988,13 @@
"packageExports": ["purejsimage/scientific/readers/aperio-svs"],
"sourceEntries": ["./src/scientific/readers/aperio-svs.ts"]
},
- "gzipBytes": 92563,
+ "gzipBytes": 92605,
"id": "scientific-reader-aperio-svs",
"implementation": "package-core",
- "minifiedJsBytes": 295081,
+ "minifiedJsBytes": 295121,
"name": "Scientific reader: Aperio SVS",
"recordedBaselineMinifiedBytes": 259477,
- "brotliBytes": 76843
+ "brotliBytes": 76906
},
{
"category": "purejsimage-entry",
@@ -1153,13 +1153,13 @@
"packageExports": ["purejsimage/scientific/readers/dicom"],
"sourceEntries": ["./src/scientific/readers/dicom.ts"]
},
- "gzipBytes": 118092,
+ "gzipBytes": 118128,
"id": "scientific-reader-dicom",
"implementation": "package-core",
- "minifiedJsBytes": 423966,
+ "minifiedJsBytes": 424006,
"name": "Scientific reader: DICOM Part 10 Image",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 98811
+ "brotliBytes": 98780
},
{
"category": "purejsimage-entry",
@@ -1228,13 +1228,13 @@
"packageExports": ["purejsimage/scientific/readers/all"],
"sourceEntries": ["./src/scientific/readers/all.ts"]
},
- "gzipBytes": 368070,
+ "gzipBytes": 368120,
"id": "scientific-readers-all",
"implementation": "package-core",
- "minifiedJsBytes": 1272580,
+ "minifiedJsBytes": 1272620,
"name": "Scientific readers: all",
"recordedBaselineMinifiedBytes": 844813,
- "brotliBytes": 293609
+ "brotliBytes": 293670
},
{
"category": "purejsimage-entry",
@@ -1243,13 +1243,13 @@
"packageExports": ["purejsimage/codecs/jpeg"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/jpeg.ts"]
},
- "gzipBytes": 55781,
+ "gzipBytes": 55804,
"id": "codec-jpeg",
"implementation": "pure-javascript",
- "minifiedJsBytes": 177896,
+ "minifiedJsBytes": 177936,
"name": "Core + JPEG",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 46509
+ "brotliBytes": 46479
},
{
"category": "purejsimage-entry",
@@ -1258,13 +1258,13 @@
"packageExports": ["purejsimage/codecs/png"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/png.ts"]
},
- "gzipBytes": 42433,
+ "gzipBytes": 42463,
"id": "codec-png",
"implementation": "pure-javascript",
- "minifiedJsBytes": 134905,
+ "minifiedJsBytes": 134945,
"name": "Core + PNG",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 36101
+ "brotliBytes": 36198
},
{
"category": "purejsimage-entry",
@@ -1273,13 +1273,13 @@
"packageExports": ["purejsimage/codecs/webp"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/webp.ts"]
},
- "gzipBytes": 57563,
+ "gzipBytes": 57575,
"id": "codec-webp",
"implementation": "pure-javascript",
- "minifiedJsBytes": 173123,
+ "minifiedJsBytes": 173163,
"name": "Core + WebP",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 48911
+ "brotliBytes": 48875
},
{
"category": "purejsimage-entry",
@@ -1303,13 +1303,13 @@
"packageExports": ["purejsimage/codecs/tiff"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/tiff.ts"]
},
- "gzipBytes": 113184,
+ "gzipBytes": 113217,
"id": "codec-tiff",
"implementation": "pure-javascript",
- "minifiedJsBytes": 364056,
+ "minifiedJsBytes": 364096,
"name": "Core + TIFF",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 93473
+ "brotliBytes": 93507
},
{
"category": "purejsimage-entry",
@@ -1333,13 +1333,13 @@
"packageExports": ["purejsimage/codecs/ico"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/ico.ts"]
},
- "gzipBytes": 45595,
+ "gzipBytes": 45615,
"id": "codec-ico",
"implementation": "pure-javascript",
- "minifiedJsBytes": 144299,
+ "minifiedJsBytes": 144339,
"name": "Core + ICO",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 38821
+ "brotliBytes": 38941
},
{
"category": "purejsimage-entry",
@@ -1348,13 +1348,13 @@
"packageExports": ["purejsimage/codecs/jpeg2000"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/jpeg2000.ts"]
},
- "gzipBytes": 48209,
+ "gzipBytes": 48234,
"id": "codec-jpeg2000",
"implementation": "pure-javascript",
- "minifiedJsBytes": 155207,
+ "minifiedJsBytes": 155247,
"name": "Core + JPEG 2000 / JP2",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 41250
+ "brotliBytes": 41256
},
{
"category": "purejsimage-entry",
@@ -1363,13 +1363,13 @@
"packageExports": ["purejsimage/codecs/avif"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/avif.ts"]
},
- "gzipBytes": 188102,
+ "gzipBytes": 188121,
"id": "codec-avif",
"implementation": "pure-javascript",
- "minifiedJsBytes": 496979,
+ "minifiedJsBytes": 497019,
"name": "Core + AVIF",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 158769
+ "brotliBytes": 158582
},
{
"category": "purejsimage-entry",
@@ -1378,28 +1378,28 @@
"packageExports": ["purejsimage/codecs/experimental/heic"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/experimental/heic.ts"]
},
- "gzipBytes": 67939,
+ "gzipBytes": 67960,
"id": "codec-heif",
"implementation": "pure-javascript",
- "minifiedJsBytes": 216336,
+ "minifiedJsBytes": 216376,
"name": "Core + HEIF / HEIC (experimental)",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 57958
+ "brotliBytes": 57923
},
{
"category": "purejsimage-entry",
- "configuredCeilingMinifiedBytes": 441000,
+ "configuredCeilingMinifiedBytes": 460000,
"entry": {
"packageExports": ["purejsimage/codecs/jpegxl"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/jpegxl.ts"]
},
- "gzipBytes": 143122,
+ "gzipBytes": 149135,
"id": "codec-jpegxl",
"implementation": "pure-javascript",
- "minifiedJsBytes": 440931,
+ "minifiedJsBytes": 457603,
"name": "Core + JPEG XL",
"recordedBaselineMinifiedBytes": 233093,
- "brotliBytes": 115674
+ "brotliBytes": 120344
},
{
"category": "purejsimage-entry",
@@ -1469,13 +1469,13 @@
"packageExports": ["purejsimage/codecs/web"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/web.ts"]
},
- "gzipBytes": 244432,
+ "gzipBytes": 244455,
"id": "codecs-web",
"implementation": "pure-javascript",
- "minifiedJsBytes": 667102,
+ "minifiedJsBytes": 667142,
"name": "Core + common web codecs",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 202952
+ "brotliBytes": 203369
},
{
"category": "purejsimage-entry",
@@ -1500,13 +1500,13 @@
"packageExports": ["purejsimage/codecs/all"],
"sourceEntries": ["./src/index.ts", "./src/codec-entries/all.ts"]
},
- "gzipBytes": 424315,
+ "gzipBytes": 430257,
"id": "codecs-all",
"implementation": "pure-javascript",
- "minifiedJsBytes": 1226906,
+ "minifiedJsBytes": 1243629,
"name": "Core + all stable codecs",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 341976
+ "brotliBytes": 346961
},
{
"category": "competitor",
@@ -1515,13 +1515,13 @@
"entry": {
"packageExports": ["purejsimage"]
},
- "gzipBytes": 65663,
+ "gzipBytes": 65692,
"id": "purejsimage-matched",
"implementation": "pure-javascript",
- "minifiedJsBytes": 209215,
+ "minifiedJsBytes": 209255,
"name": "PureJsImage (matched)",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 54308
+ "brotliBytes": 54370
},
{
"category": "competitor",
@@ -1545,13 +1545,13 @@
"entry": {
"packageExports": ["purejsimage/codecs/all"]
},
- "gzipBytes": 424315,
+ "gzipBytes": 430257,
"id": "purejsimage-all",
"implementation": "pure-javascript",
- "minifiedJsBytes": 1226906,
+ "minifiedJsBytes": 1243629,
"name": "PureJsImage (all stable codecs)",
"recordedBaselineMinifiedBytes": null,
- "brotliBytes": 341976
+ "brotliBytes": 346961
},
{
"category": "competitor",
diff --git a/benchmark/jpegxl/audit-m7-ceiling-controls.ts b/benchmark/jpegxl/audit-m7-ceiling-controls.ts
new file mode 100644
index 00000000..8b244777
--- /dev/null
+++ b/benchmark/jpegxl/audit-m7-ceiling-controls.ts
@@ -0,0 +1,173 @@
+/** Recheck the ten RGB8 high-band controls without changing the frozen metric protocol. */
+import { spawnSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import sharp from 'sharp'
+import ceiling from './production-program/m7-prompt10-native-ceiling.json' with { type: 'json' }
+import corpus from './production-program/m7-corpus-selection.json' with { type: 'json' }
+
+const directory = '.tmp/jpegxl-m7/recovery-ceiling-controls-v2'
+const native = '.tmp/jpegxl-oracles/libjxl-v0.12.0/source/build-pinned/tools'
+const rust = '.tmp/jpegxl-oracles/jxl-rs-07ab48f/target/release/jxl_cli'
+const metric = '.tmp/jpegxl-oracles/libjxl-v0.12.0/source/build-m7-metrics/tools/ssimulacra2'
+const output = process.argv[2]
+if (!output)
+ throw new Error('Usage: node benchmark/jpegxl/audit-m7-ceiling-controls.ts output.json')
+await mkdir(directory, { recursive: true })
+const hash = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
+const run = (path: string, args: readonly string[]): string => {
+ const result = spawnSync(path, args, {
+ encoding: 'utf8',
+ timeout: 600_000,
+ maxBuffer: 4 * 1024 * 1024,
+ })
+ if (result.status !== 0) throw new Error(`${path}: ${result.stderr}; ${result.error ?? ''}`)
+ return result.stdout
+}
+const ppm = (bytes: Uint8Array): { width: number; height: number; pixels: Uint8Array } => {
+ const match = /^P6\s+(\d+)\s+(\d+)\s+255\s/u.exec(
+ new TextDecoder().decode(bytes.subarray(0, 100)),
+ )
+ if (!match) throw new Error('Not RGB8 PPM')
+ const width = Number(match[1]),
+ height = Number(match[2]),
+ pixels = bytes.subarray(match[0].length)
+ if (pixels.length !== width * height * 3) throw new Error('PPM extent mismatch')
+ return { width, height, pixels }
+}
+const score = (original: string, rendered: string): number => {
+ const result = Number.parseFloat(run(metric, [original, rendered]))
+ if (!Number.isFinite(result)) throw new Error('Nonfinite metric')
+ return result
+}
+const controls = []
+for (const entry of ceiling.results.filter((point) => point.ssimulacra2 < 90)) {
+ const selection = corpus.cases.find((row) => row.id === entry.id && row.split === entry.split)
+ if (!selection || selection.sourceSha256 !== entry.sourceSha256)
+ throw new Error(`Frozen source selection mismatch ${entry.id}`)
+ const originalPath = `.tmp/jpegxl-m7/sources/${entry.id}.${selection.format}`
+ if (hash(await readFile(originalPath)) !== selection.sourceSha256)
+ throw new Error(`Original source hash mismatch ${entry.id}`)
+ const originalMetadata = await sharp(originalPath).metadata()
+ if (originalMetadata.width !== selection.width || originalMetadata.height !== selection.height)
+ throw new Error(`Original source geometry mismatch ${entry.id}`)
+ const source = ppm(await readFile(entry.inputPath))
+ const decoded = ppm(await readFile(entry.nativeDecodedPath))
+ const stream = await readFile(entry.artifactPath)
+ if (
+ source.width !== decoded.width ||
+ source.height !== decoded.height ||
+ hash(source.pixels) !== entry.inputPixelsSha256 ||
+ hash(decoded.pixels) !== entry.decodedSha256 ||
+ hash(stream) !== entry.encodedSha256
+ )
+ throw new Error(`Frozen control identity mismatch ${entry.id}`)
+ const selfScore = score(entry.inputPath, entry.inputPath)
+ const lossyRgb8Score = score(entry.inputPath, entry.nativeDecodedPath)
+ if (selfScore !== 100 || Math.abs(lossyRgb8Score - entry.ssimulacra2) > 0.00001)
+ throw new Error(`Frozen RGB8 score changed ${entry.id}`)
+ controls.push({
+ split: entry.split,
+ id: entry.id,
+ inputPixelsSha256: entry.inputPixelsSha256,
+ encodedSha256: entry.encodedSha256,
+ decodedSha256: entry.decodedSha256,
+ width: source.width,
+ height: source.height,
+ channels: 3,
+ bitDepth: 8,
+ originalSourcePath: originalPath,
+ originalSourceSha256: selection.sourceSha256,
+ originalWidth: originalMetadata.width,
+ originalHeight: originalMetadata.height,
+ originalColorSpace: originalMetadata.space,
+ originalHasEmbeddedProfile: originalMetadata.hasProfile,
+ originalStoredOrientation: originalMetadata.orientation ?? null,
+ sourceRendering: 'normalized RGB8 PPM from approved sRGB and stored-orientation preprocessing',
+ selfScore,
+ lossyRgb8Score,
+ })
+}
+if (controls.length !== 10) throw new Error('Expected ten RGB8 below-90 controls')
+const representative = ceiling.results.find(
+ (entry) => entry.id === 'im26-6834' && entry.split === 'development',
+)
+if (!representative) throw new Error('Missing representative source')
+const lossless = `${directory}/im26-6834-lossless.jxl`
+const decoded8 = `${directory}/im26-6834-lossless.ppm`
+const decodedRust = `${directory}/im26-6834-lossless-rust.ppm`
+const decodedPng8 = `${directory}/im26-6834-lossless8.png`
+const decodedPng16 = `${directory}/im26-6834-lossless16.png`
+const lossyPng16 = `${directory}/im26-6834-lossy16.png`
+run(`${native}/cjxl`, [
+ representative.inputPath,
+ lossless,
+ '-d',
+ '0',
+ '-e',
+ '7',
+ '--num_threads=1',
+ '--container=0',
+])
+run(`${native}/djxl`, [lossless, decoded8, '--num_threads=1', '--bits_per_sample=8'])
+run(rust, [lossless, decodedRust, '--num-threads', '1', '--data-type', 'u8'])
+run(`${native}/djxl`, [lossless, decodedPng8, '--num_threads=1', '--bits_per_sample=8'])
+run(`${native}/djxl`, [lossless, decodedPng16, '--num_threads=1', '--bits_per_sample=16'])
+run(`${native}/djxl`, [
+ representative.artifactPath,
+ lossyPng16,
+ '--num_threads=1',
+ '--bits_per_sample=16',
+])
+const inputPixels = ppm(await readFile(representative.inputPath)).pixels
+const nativePixels = ppm(await readFile(decoded8)).pixels
+const rustPixels = ppm(await readFile(decodedRust)).pixels
+if (hash(inputPixels) !== hash(nativePixels) || hash(inputPixels) !== hash(rustPixels))
+ throw new Error('Pixel-lossless independent control changed RGB8 samples')
+const png16Bytes = await readFile(decodedPng16)
+if (png16Bytes[24] !== 16 || png16Bytes[25] !== 2)
+ throw new Error('Unexpected PNG16 depth or color type')
+const raw16 = await sharp(decodedPng16).raw({ depth: 'ushort' }).toBuffer()
+const values = new Uint16Array(raw16.buffer, raw16.byteOffset, raw16.byteLength / 2)
+let maximumStoredSample = 0
+for (const value of values) maximumStoredSample = Math.max(maximumStoredSample, value)
+const precisionControl = {
+ id: representative.id,
+ losslessEncodedSha256: hash(await readFile(lossless)),
+ losslessNativeDecodedSha256: hash(nativePixels),
+ losslessRustDecodedSha256: hash(rustPixels),
+ inputPixelsSha256: hash(inputPixels),
+ sourceSelfScore: score(representative.inputPath, representative.inputPath),
+ losslessPpm8Score: score(representative.inputPath, decoded8),
+ losslessPng8Score: score(representative.inputPath, decodedPng8),
+ losslessPng16Score: score(representative.inputPath, decodedPng16),
+ lossyPpm8Score: score(representative.inputPath, representative.nativeDecodedPath),
+ lossyPng16Score: score(representative.inputPath, lossyPng16),
+ png16DeclaredBitDepth: png16Bytes[24],
+ png16MaximumStoredSample: maximumStoredSample,
+ png16ControlConclusion:
+ 'The 16-bit PNG route does not score the pixel-identical lossless control as 100. It is not a valid replacement for the frozen RGB8 comparison.',
+}
+const tools = await Promise.all(
+ [`${native}/cjxl`, `${native}/djxl`, rust, metric].map(async (path) => ({
+ path,
+ sha256: hash(await readFile(path)),
+ })),
+)
+const report = {
+ schemaVersion: 1,
+ inputReportPath: 'benchmark/jpegxl/production-program/m7-prompt10-native-ceiling.json',
+ inputReportSha256: hash(
+ await readFile('benchmark/jpegxl/production-program/m7-prompt10-native-ceiling.json'),
+ ),
+ policy:
+ 'Original RGB8 PPM protocol retained. PNG output precision is only a diagnostic; an alternative protocol would require new versioning and equal application to both encoders.',
+ tools,
+ sharpVersion: sharp.versions,
+ controls,
+ precisionControl,
+ conclusion:
+ 'Ten pinned native effort-7 distance-0.05 streams remain below SSIMULACRA2 90 in the original RGB8 pipeline. The finding is specific to the tested settings and rendering pipeline, not a JPEG XL format limitation.',
+}
+await writeFile(output, `${JSON.stringify(report, null, 2)}\n`)
+console.log(JSON.stringify({ controls: controls.length, representative: precisionControl }))
diff --git a/benchmark/jpegxl/classify-m7-recovery.ts b/benchmark/jpegxl/classify-m7-recovery.ts
new file mode 100644
index 00000000..70dad96b
--- /dev/null
+++ b/benchmark/jpegxl/classify-m7-recovery.ts
@@ -0,0 +1,192 @@
+/** Read-only classification of the retained M7 RGB8 curves and supplementary endpoints. */
+import { createHash } from 'node:crypto'
+import { readFile, writeFile } from 'node:fs/promises'
+import {
+ recoveryBracket,
+ recoveryFrontier,
+ recoveryMonotonicityViolations,
+ type RecoveryPoint,
+} from './m7-recovery-curves.ts'
+import artifacts from './production-program/m7-prompt2-artifacts.json' with { type: 'json' }
+import ceiling from './production-program/m7-prompt10-native-ceiling.json' with { type: 'json' }
+import lowEndpoints from './production-program/m7-prompt11-low-endpoints.json' with { type: 'json' }
+import development from './production-program/m7-prompt9-quality-development.json' with {
+ type: 'json',
+}
+import holdout from './production-program/m7-prompt9-quality-holdout.json' with { type: 'json' }
+
+const output = process.argv[2]
+if (!output)
+ throw new Error(
+ 'Usage: node benchmark/jpegxl/classify-m7-recovery.ts output.json [maximum-score-width]',
+ )
+const maximumWidth = Number(process.argv[3] ?? 3)
+if (!Number.isFinite(maximumWidth) || maximumWidth <= 0 || maximumWidth > 10)
+ throw new Error('Maximum score interval width must be in (0, 10]')
+const hash = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
+const object = (value: unknown): Record => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
+ throw new Error('Expected object')
+ return value as Record
+}
+const numeric = (value: unknown): number => {
+ if (typeof value !== 'number' || !Number.isFinite(value))
+ throw new Error('Expected finite number')
+ return value
+}
+const text = (value: unknown): string => {
+ if (typeof value !== 'string') throw new Error('Expected string')
+ return value
+}
+const sha256 = async (path: string): Promise => hash(await readFile(path))
+type Engine = 'purejsimage' | 'libjxl'
+interface ScoredPoint extends RecoveryPoint {
+ readonly engine: Engine
+ readonly source: string
+ readonly encodedSha256: string
+}
+const scoredPoint = (value: unknown, source: string): ScoredPoint | undefined => {
+ const row = object(value)
+ if (row.engine !== 'purejsimage' && row.engine !== 'libjxl') return undefined
+ if (row.status !== undefined && row.status !== 'measured') return undefined
+ if (row.ssimulacra2 === undefined) return undefined
+ return {
+ engine: row.engine,
+ setting: numeric(row.setting ?? row.distance),
+ bytes: numeric(row.bytes),
+ score: numeric(row.ssimulacra2),
+ encodedSha256: text(row.encodedSha256),
+ source,
+ }
+}
+const rawCases = new Map()
+for (const summary of [development, holdout]) {
+ const split = summary.split
+ for (const item of summary.results) {
+ const key = `${split}/${item.id}`
+ const expected = artifacts.cases.find((row) => row.split === split && row.id === item.id)
+ if (!expected || item.status !== 'complete' || item.errors.length)
+ throw new Error(`Incomplete approved case ${key}`)
+ const path = `${summary.qualificationOverlay.completeRawCaseDirectory}/${item.id}/report.json`
+ const raw = object(JSON.parse(await readFile(path, 'utf8')) as unknown)
+ if (raw.normalizedSha256 !== expected.normalizedSha256 || raw.id !== item.id)
+ throw new Error(`Normalized source identity mismatch ${key}`)
+ const entries = raw.points
+ if (!Array.isArray(entries)) throw new Error(`Missing raw points ${key}`)
+ const points = entries
+ .map((entry: unknown) => scoredPoint(entry, 'approved-2mp'))
+ .filter((point): point is ScoredPoint => point !== undefined)
+ rawCases.set(key, {
+ points,
+ failed: entries.some((entry: unknown) => object(entry).status === 'failed'),
+ inputSha256: expected.normalizedSha256,
+ })
+ }
+}
+if (rawCases.size !== 240) throw new Error(`Expected 240 approved cases, found ${rawCases.size}`)
+
+for (const entry of lowEndpoints.results) {
+ const key = `${entry.split}/${entry.id}`
+ const item = rawCases.get(key)
+ if (!item || entry.inputPixelsSha256 !== item.inputSha256)
+ throw new Error(`Low endpoint source mismatch ${key}`)
+ const point = scoredPoint(entry, 'supplementary-low')
+ if (point) item.points.push(point)
+}
+for (const entry of ceiling.results) {
+ const key = `${entry.split}/${entry.id}`
+ const item = rawCases.get(key)
+ if (!item || entry.inputPixelsSha256 !== item.inputSha256)
+ throw new Error(`High endpoint source mismatch ${key}`)
+ item.points.push({
+ engine: 'libjxl',
+ setting: entry.distance,
+ bytes: entry.bytes,
+ score: entry.ssimulacra2,
+ encodedSha256: entry.encodedSha256,
+ source: 'supplementary-high',
+ })
+}
+const counts: Record = {}
+const rows = []
+for (const [key, item] of rawCases) {
+ const [split, id] = key.split('/')
+ for (const target of [70, 80, 90]) {
+ const first = item.points.filter((point) => point.engine === 'purejsimage')
+ const native = item.points.filter((point) => point.engine === 'libjxl')
+ const ownBracket = recoveryBracket(first, target)
+ const nativeBracket = recoveryBracket(native, target)
+ const highControl = ceiling.results.find((entry) => entry.split === split && entry.id === id)
+ const referenceFloorMiss =
+ target === 90 && highControl !== undefined && highControl.ssimulacra2 < target
+ const ownAtFloor = first.find((point) => point.setting === 0.25)
+ const firstPartyMiss =
+ ownAtFloor !== undefined &&
+ ownAtFloor.score < target &&
+ first.every((point) => point.score < target)
+ const category = item.failed
+ ? 'execution_or_correctness_failure'
+ : first.length === 0 || native.length === 0
+ ? 'unsupported'
+ : referenceFloorMiss
+ ? 'reference_target_not_reached'
+ : firstPartyMiss
+ ? 'first_party_target_miss'
+ : !ownBracket ||
+ !nativeBracket ||
+ ownBracket.width > maximumWidth ||
+ nativeBracket.width > maximumWidth
+ ? 'insufficient_sampling'
+ : 'measured_adequate_bracket'
+ counts[category] = (counts[category] ?? 0) + 1
+ rows.push({
+ split,
+ id,
+ target,
+ category,
+ inputPixelsSha256: item.inputSha256,
+ rawCasePath: `.tmp/jpegxl-m7/prompt9-qualified-complete-${split}/${id}/report.json`,
+ firstParty: {
+ measured: first.length,
+ minimum: Math.min(...first.map((point) => point.score)),
+ maximum: Math.max(...first.map((point) => point.score)),
+ bracketWidth: ownBracket?.width ?? null,
+ monotonicityViolations: recoveryMonotonicityViolations(first),
+ frontierPoints: recoveryFrontier(first).length,
+ },
+ reference: {
+ measured: native.length,
+ minimum: Math.min(...native.map((point) => point.score)),
+ maximum: Math.max(...native.map((point) => point.score)),
+ bracketWidth: nativeBracket?.width ?? null,
+ monotonicityViolations: recoveryMonotonicityViolations(native),
+ frontierPoints: recoveryFrontier(native).length,
+ },
+ })
+ }
+}
+const sourceReports = await Promise.all(
+ [
+ 'm7-prompt9-quality-development.json',
+ 'm7-prompt9-quality-holdout.json',
+ 'm7-prompt10-native-ceiling.json',
+ 'm7-prompt11-low-endpoints.json',
+ 'm7-prompt2-artifacts.json',
+ ].map(async (name) => ({
+ path: `benchmark/jpegxl/production-program/${name}`,
+ sha256: await sha256(`benchmark/jpegxl/production-program/${name}`),
+ })),
+)
+const report = {
+ schemaVersion: 1,
+ policy:
+ 'RGB8 original protocol scores. Supplementary endpoints retain approved pixels and splits. Width is SSIMULACRA2 score points on the measured nondominated frontier. A reference-limit category is neither a PureJsImage failure nor a comparison pass. No ratio is inferred from a missing or wide bracket.',
+ maximumWidth,
+ cases: rawCases.size,
+ targets: rows.length,
+ counts,
+ sourceReports,
+ rows,
+}
+await writeFile(output, `${JSON.stringify(report, null, 2)}\n`)
+console.log(JSON.stringify({ cases: rawCases.size, targets: rows.length, counts }))
diff --git a/benchmark/jpegxl/classify-m7-visual-qualification.ts b/benchmark/jpegxl/classify-m7-visual-qualification.ts
new file mode 100644
index 00000000..003be7b4
--- /dev/null
+++ b/benchmark/jpegxl/classify-m7-visual-qualification.ts
@@ -0,0 +1,118 @@
+/** Classify final measured brackets without converting reference or sampling limits into passes. */
+import { createHash } from 'node:crypto'
+import { readFile, writeFile } from 'node:fs/promises'
+import {
+ type RecoveryPoint,
+ recoveryBracket,
+ recoveryMonotonicityViolations,
+} from './m7-recovery-curves.ts'
+import selection from './production-program/m7-corpus-selection.json' with { type: 'json' }
+
+const [development, observed, output] = process.argv.slice(2)
+if (!development || !observed || !output)
+ throw new Error('Supply development directory, observed directory, output.json')
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+const record = (value: unknown): Record => {
+ if (!isRecord(value)) throw new Error('Expected report object')
+ return value
+}
+const finite = (value: unknown): number => {
+ if (typeof value !== 'number' || !Number.isFinite(value))
+ throw new Error('Expected finite measurement')
+ return value
+}
+const rows = []
+const counts: Record = {
+ measured_adequate_bracket: 0,
+ insufficient_sampling: 0,
+ first_party_target_miss: 0,
+ reference_target_not_reached_tested_configuration: 0,
+ unsupported: 0,
+ execution_or_correctness_failure: 0,
+}
+for (const entry of selection.cases) {
+ const path = `${entry.split === 'development' ? development : observed}/${entry.id}/report.json`
+ const bytes = await readFile(path)
+ const report = record(JSON.parse(bytes.toString('utf8')))
+ if (
+ report.id !== entry.id ||
+ report.sourceSha256 !== entry.sourceSha256 ||
+ !Array.isArray(report.points)
+ )
+ throw new Error('Missing or changed source identity')
+ const points = report.points.map(record)
+ const curve = (engine: string): RecoveryPoint[] =>
+ points
+ .filter((point) => point.engine === engine && point.status === 'measured')
+ .map((point) => ({
+ setting: finite(point.setting),
+ bytes: finite(point.bytes),
+ score: finite(point.ssimulacra2),
+ }))
+ const firstParty = curve('purejsimage'),
+ reference = curve('libjxl')
+ for (const target of [70, 80, 90]) {
+ const firstBracket = recoveryBracket(firstParty, target),
+ referenceBracket = recoveryBracket(reference, target)
+ const failed = points.some(
+ (point) =>
+ (point.engine === 'purejsimage' || point.engine === 'libjxl') && point.status === 'failed',
+ )
+ const firstMaximum = Math.max(...firstParty.map((point) => point.score)),
+ referenceMaximum = Math.max(...reference.map((point) => point.score))
+ const category =
+ failed || firstParty.length === 0 || reference.length === 0
+ ? 'execution_or_correctness_failure'
+ : referenceMaximum < target
+ ? 'reference_target_not_reached_tested_configuration'
+ : firstMaximum < target
+ ? 'first_party_target_miss'
+ : !firstBracket ||
+ !referenceBracket ||
+ firstBracket.width > 3 ||
+ referenceBracket.width > 3
+ ? 'insufficient_sampling'
+ : 'measured_adequate_bracket'
+ counts[category] = (counts[category] ?? 0) + 1
+ rows.push({
+ split: entry.split,
+ id: entry.id,
+ target,
+ category,
+ firstParty: {
+ maximum: firstMaximum,
+ bracketWidth: firstBracket?.width ?? null,
+ bracket: firstBracket ?? null,
+ monotonicityViolations: recoveryMonotonicityViolations(firstParty),
+ },
+ reference: {
+ maximum: referenceMaximum,
+ bracketWidth: referenceBracket?.width ?? null,
+ bracket: referenceBracket ?? null,
+ monotonicityViolations: recoveryMonotonicityViolations(reference),
+ },
+ firstPartyAlsoBelowTarget: firstMaximum < target,
+ inputPixelsSha256: report.normalizedSha256,
+ rawReportPath: path,
+ rawReportSha256: createHash('sha256').update(bytes).digest('hex'),
+ })
+ }
+}
+await writeFile(
+ output,
+ `${JSON.stringify(
+ {
+ schemaVersion: 1,
+ cases: selection.cases.length,
+ targets: rows.length,
+ policy:
+ 'Final approved six-point RGB8 grid, original family assignments. Three SSIMULACRA2 points is the existing diagnostic width for small rate decisions, not a new promotion gate. Reference-limited means only the tested configuration failed to reach the target. Historical floor controls remain separate. No missing or wide bracket becomes a comparison pass. No lossless points inserted.',
+ counts,
+ rows,
+ },
+ null,
+ 2,
+ )}\n`,
+)
+console.log(counts)
diff --git a/benchmark/jpegxl/comparison/REPORT.md b/benchmark/jpegxl/comparison/REPORT.md
new file mode 100644
index 00000000..518201e6
--- /dev/null
+++ b/benchmark/jpegxl/comparison/REPORT.md
@@ -0,0 +1,298 @@
+# Public JavaScript JPEG XL comparison
+
+## Scope
+
+This is a manual comparison of PureJsImage, `@jsquash/jxl` 1.3.0,
+`jxl-oxide-wasm` 0.12.6 and `wasm-vips` 0.0.19 on one Linux host.
+PureJsImage is the local public-API build of
+`564a4d2d3e1c318f64821322c67329550df6da6b`. This is not a measurement of the
+published 0.17.0 tarball. The encoder implementation and capability decisions
+have not changed in this comparison.
+
+[Generated summary](SUMMARY.md), [website dataset](website-data.json),
+[subject/assets manifest](subjects.json), [fixture manifest](fixtures.json), and
+[dated official-source survey](survey.json) are the handoff artifacts.
+Raw reports live in [results](results). The [artifact index](results/artifact-index.json)
+pins the measured binaries and harness files. The [quality validation](results/quality-correctness.json)
+checks all 87 scored artifacts; five retain nonzero alpha error from comparator defaults. The website dataset is generated from
+those reports. No overall winner score is calculated.
+
+## Supported conclusions
+
+- The tested PureJsImage toolkit has the smallest loaded asset footprint among
+ these four adapters. This includes the benchmark worker and both encode and
+ decode entry points; it is not a survey of every possible JPEG XL bundle.
+- The WASM alternatives decode several native-libjxl lossless streams rejected
+ by this PureJsImage revision's grouped Modular transform support. Those cases
+ remain visible, including the failed PureJsImage roundtrip workload.
+- The three encoders expose different speed/compression tradeoffs. See the
+ generated paired lossless table; the WASM encoders are substantially faster
+ on several measured workloads. Exact validation is required before a row
+ enters a ratio.
+- At SSIMULACRA2 80 on the capped photo, PureJsImage's interpolated size is about
+ 1.212 times wasm-vips, with adequate brackets on both sides. This one result
+ does not establish a corpus-wide lossy ranking. Other targets remain wide or
+ missing under the finite search budget.
+- Oxide preserves the tested 16-bit integer images through its native-depth PNG
+ path. It does not expose raw sample planes or encoding. Its PNG export cost
+ remains part of the measured operation.
+- Specialized APIs have separate results: PureJsImage's native float, extra
+ channel, ICC and exact JPEG reconstruction probes pass; wasm-vips' native
+ float and ICC probes pass. Animation timing has additional limits described
+ below. Exposed APIs that were not tested are not treated as unsupported.
+
+### Animation timing observations
+
+The 3/5 ms fixture is retained as an exact-timing edge case. wasm-vips writes
+100/100 ms in that case. Its upstream
+[jxlsave implementation](https://github.com/libvips/libvips/blob/master/libvips/foreign/jxlsave.c)
+explicitly normalizes delays of 10 ms or less to 100 ms. The ordinary 30/50 ms
+control is reported separately.
+
+Oxide's published duration numerator/denominator pairs yield 3000/5000 for
+that fixture, while its frame access and composition are checked separately.
+The inspected
+[WASM binding source](https://github.com/tirr-c/jxl-oxide/blob/main/crates/jxl-oxide-wasm/src/lib.rs)
+multiplies frame ticks by the tick-rate numerator. This report does not treat
+those values as a verified seconds-based timeline. No workaround changes the
+wrapper's returned timing. These observations concern the tested binding and
+settings, not the full capabilities of the underlying native engines.
+
+## Inputs and semantics
+
+The 16 stills include eight existing M7 development diagnostics, a complete
+12 MP sunset, a complete 1920x1080 screenshot, two alpha references, gray8,
+gray16, RGB16 and RGBA16. Four diagnostics are native-resolution crops; four
+are reduced photographs. `scope: capped` identifies this diagnostic group;
+it does not mean all eight are reduced full images. The large originals remain
+separate. These inspected sources provide regression evidence, not an unseen
+holdout. This comparison does not rerun or replace M7 qualification.
+
+The manifests pin input and output hashes. The imazen sources retain their
+licenses and original source identities in the existing
+[M7 source selection](../production-program/m7-corpus-selection.json).
+Conformance sources come from revision
+`4bf053529c7cefd2951be453475bb3dccc7e7be8` of
+[libjxl/conformance](https://github.com/libjxl/conformance). The selected alpha
+and gray cases are CC0. Small native float, extra-channel, ICC and animation
+probes use repository fixtures and deterministic public-API synthesis.
+
+Common encode inputs are prepared once as sRGB RGBA8, straight alpha, stored
+orientation 1. Gray/RGB expansion adds opaque alpha without changing color
+samples. High-depth inputs remain uint16 and are reported separately. The
+specialized float probe retains IEEE binary32 values above 1. No wrapper is
+credited with native precision by converting its input to 8-bit.
+
+Every decoder receives the same pinned bytes per row. Native libjxl 0.12.0
+creates the shared ordinary decode inputs. A rejected native stream remains an
+unsupported-input result; it is not replaced with a stream selected for that
+decoder. Lossless validation checks every requested sample, including color
+under zero alpha. Rendered lossy comparisons use the existing maximum RGB8
+error of 2 against pinned native libjxl output. This tolerance applies to that
+rendering contract, not to pixel losslessness. A failure under this contract
+does not by itself establish that a wrapper is generally broken.
+
+The [validation amendment](results/validation-amendment.json) corrects an initial
+benchmark assumption that every lossy default promised exact alpha. It preserves
+all original classifications, measurements and alpha errors. Lossless equality,
+the RGB8 decode tolerance, and PureJsImage's exact-alpha guarantee are unchanged.
+
+Independent native decoding validates encoded streams outside the timed region.
+Lossy timing rows establish dimensions and record alpha error. Exact alpha is
+required for PureJsImage, which promises it by default. Other public lossy
+defaults may alter alpha; their errors are retained as a quality tradeoff. Their color quality
+is evaluated separately; successful decoding does not establish matched quality.
+
+## Public API adapters
+
+- PureJsImage uses `purejsimage/browser`, `purejsimage/codecs/jpegxl` and the
+ specialized `purejsimage/jpegxl` exports. Encoder calls include the public
+ selection logic. Typed pixel blocks use the API's big-endian byte layout.
+- jSquash uses its published `decode`, `encode` and `init` functions, plus the
+ documented ImageData shim in Node. Its ImageData API does not expose native
+ 16-bit or float samples. The non-isolated browser origin selects its normal
+ single-thread encoder. Both encode and decode modules are initialized for
+ this adapter toolkit; startup and footprint figures include both.
+- jxl-oxide uses the published npm WASM binding. A decode operation includes
+ `render().encodeToPng()`. This is a PNG-producing workflow, not raw decode.
+ The published glue consumes the render result during PNG export; the image
+ handle is explicitly freed. No native executable replaces this binding.
+- wasm-vips loads its supported JXL dynamic plugin, sets `concurrency(1)` and
+ disables the operation-result cache. `writeToMemory()` materializes pixels;
+ every Image handle is deleted. The runtime is shut down after each timing
+ job. Its browser pthread runtime requires cross-origin isolation and starts
+ a worker pool even when image-processing concurrency is one. These startup
+ and transfer costs remain included.
+
+No adapter adds a missing sequence, exact-reconstruction, or raw-sample API to
+another wrapper. The capability dataset distinguishes absent APIs from features
+that are exposed but not tested. Specialized Node results are labeled Node;
+browser support is not inferred from a Node success. Animation's Experimental
+status in PureJsImage is separate from the measured sequence result.
+
+## Timing and memory
+
+Main measurements use Chromium and Node on the environment recorded in each
+report. Firefox and WebKit run the small compatibility subset. Jobs run serially
+under the existing bounded systemd runner with zero swap and an 8 GiB process-tree
+ceiling. No new CI benchmark is installed.
+
+Each main job uses a fresh process or browser worker/context, measures library
+initialization plus its first operation, then three warm operations. Source
+preparation, fetching the prepared input, independent validation, PNG reading for
+validation and external metrics are outside the operation timer. Adapter input wrapping/copies, codec-required
+conversions, copying the returned typed data and oxide's mandatory PNG export are
+inside. Cold time excludes process/worker harness startup. The representative
+cold report provides three independent startup observations per selected cell.
+
+The library data is fully materialized before stopping the timer. Warm runs reuse
+initialized code and allocated heaps, but wasm-vips cannot serve an operation
+from its result cache. Main cold cells have one observation and cannot support a
+precise cold median by themselves. Raw times and warm median/minimum/maximum are
+retained. This is an interactive workstation, not a dedicated reference host;
+small differences should not be described as durable wins.
+
+Node peak RSS comes from each isolated subject process. It includes the JS
+runtime, prepared source, output, temporary buffers, WASM and worker infrastructure.
+The before/after process memory counters are diagnostic snapshots, not production
+encoder-managed memory or proof of reclaimed warmup allocations. WASM linear memory was not separately instrumented. Browser memory is unavailable
+because these browser/isolation combinations lack a consistent measurement API. No canvas allocation is measured.
+
+The bundle inventory hashes complete loaded JS, WASM and worker assets. The
+summary counts deployment files once, including the shared benchmark worker.
+It also retains actual cold requests, including repeated worker-script fetches.
+Gzip level 9 and Brotli quality 11 are offline transfer estimates. The server
+uses uncompressed responses and `Cache-Control: no-store`; a warm operation
+uses the initialized toolkit without new library fetches. These are toolkit
+sizes, not claims about each library's smallest possible application bundle.
+
+## Matched quality
+
+The finite quality subset contains a photo, a sunset, fine text and transparency.
+It measures public effort-7 encoding and a separately labeled native-libjxl
+reference. It reuses the M7 nondominated frontier, log-byte interpolation,
+monotonicity checks and adaptive next-setting helper.
+
+SSIMULACRA2 80 is the primary requested target. Each subject/source gets at most
+six lossy points, including two initial points; only missing or wide target-80
+intervals are refined. A score width of at most 2 is adequate here. Bands 70/90
+and Butteraugli coordinates 0.5/1/2/3 remain visible even when unresolved.
+Butteraugli width at most 0.25 is marked adequate. No extrapolation or lossless
+point is inserted. Equal effort, quality and distance options do not imply equal
+quality or equal work. No lossy size ranking comes from the fixed-option timing
+rows. Some comparator settings compress alpha lossily; the raw alpha errors and
+both composite scores remain visible. This differs from PureJsImage's exact-alpha
+default and must accompany a transparency size comparison. Native-libjxl results never enter JavaScript runtime comparisons.
+
+Transparency is composited onto both black and white backgrounds before scoring.
+The primary score is the lower SSIMULACRA2 value and the secondary distortion is
+the higher Butteraugli value. Each background's raw scores remain available.
+The HDR probe checks native float samples and explicit linear range mappings at
+headroom 1/2/4. It is not a perceptual HDR encoder compression comparison.
+
+## Preview and API limits
+
+The separate Chromium preview command measures first nonempty output over
+loopback HTTP. PureJsImage uses a read-at source and requests a 32x32 region at
+scale 2. Oxide receives sequential 4096-byte chunks and exports a full-frame PNG.
+These are different workflows and their latency is not ranked. Requested and
+transferred source bytes remain separate from toolkit asset transfer. The Node companion checks final region pixels against native libjxl, including
+center sampling at scale 2. The first DC/partial preview is checked for nonempty
+output; its visual fidelity and real network latency are not qualified. A crop API alone is never classified as selective decoding.
+
+Exif/XMP, arbitrary animation blend combinations, orientation rendering,
+lossy native-16-bit curves and multithread speedup remain explicitly not tested in this bounded comparison.
+They must not become unsupported or verified claims on the website. The report
+is not a new codec capability promotion or a claim about every legal JXL stream.
+
+## Reproduction
+
+Use Node 24 and the exact root lockfile. Dependencies are development-only.
+The package still has no runtime dependency tree.
+
+```sh
+npm ci
+npm run build
+node benchmark/jpegxl/comparison/restore-inputs.ts
+node benchmark/jpegxl/comparison/prepare.ts
+node benchmark/jpegxl/comparison/build.ts
+```
+
+Preparation reuses the existing pinned M7 normalized diagnostics and originals,
+and generated lossless fixtures. Their paths and hashes are in `fixtures.json`.
+On a fresh checkout, use `restore-inputs.ts` for only the selected imazen sources. It checks original
+hashes and refuses normalization that differs from the committed prepared-input
+hashes. Use the existing `generate-lossless-corpus.ts`,
+`generate-vardct-corpus.ts` and pinned conformance archive for missing small
+codec fixtures. No full-corpus timing run is required. Do not substitute a newer image or resizer output under an existing
+fixture ID. The pinned native oracle and metric binaries live under
+`.tmp/jpegxl-oracles/libjxl-v0.12.0/source/` and have hashes in the reports.
+
+Run one bounded command at a time. The runner deliberately uses a single unit.
+Repeat the following form for the listed commands:
+
+```sh
+PUREJSIMAGE_M7_CPU_PERCENT=800 PUREJSIMAGE_M7_MEMORY_GIB=8 \
+ node benchmark/jpegxl/run-m7-bounded.ts comparison-main-chromium \
+ env node benchmark/jpegxl/comparison/run.ts main chromium
+```
+
+Commands after `env node`:
+
+```text
+benchmark/jpegxl/comparison/run.ts main node
+benchmark/jpegxl/comparison/run.ts compat firefox
+benchmark/jpegxl/comparison/run.ts compat webkit
+benchmark/jpegxl/comparison/run.ts cold chromium
+benchmark/jpegxl/comparison/run.ts cold node
+benchmark/jpegxl/comparison/specialized.ts
+benchmark/jpegxl/comparison/preview.ts
+benchmark/jpegxl/comparison/quality.ts
+```
+
+Choose a unique bounded-run ID for each command. `run.ts smoke node` and
+`run.ts smoke chromium` provide quick initialization checks. `JXL_COMPARE_SUBJECTS`
+can select a comma-separated subject list for diagnostics. Reports are written
+after each row; rerunning a command makes new timings rather than treating an
+old timing as a fresh sample. Preserve an old report before replacing it.
+
+```sh
+node benchmark/jpegxl/comparison/validate-quality.ts
+node benchmark/jpegxl/comparison/amend-validation.ts
+node benchmark/jpegxl/comparison/generate.ts
+node benchmark/jpegxl/comparison/index-evidence.ts
+npx vitest run tests/jpegxl-comparison.test.ts
+npm run check
+```
+
+For the committed run, prior raw classifications are preserved in the validation
+amendment and `results/preflight`. If formatting raw JSON, regenerate its dependent
+validation and dataset hashes afterward. `archive-pins.ts` preserves exact subject
+and fixture manifest bytes before formatting.
+
+The generated dataset retains raw-report links and hashes, classified failures,
+actual settings, artifact identities, and missing quality intervals. Large
+binary artifacts remain in `.tmp/jpegxl-comparison-v1`; raw JSON and the artifact
+index are versioned. Keep that cache for inspecting exact measured bytes.
+
+## Handoff checks
+
+The 11 comparison tests passed. The complete repository gate passed with
+`VITEST_MAX_WORKERS=4 npm run check`: 3,236 tests passed and three were skipped.
+The initial default-worker run timed out in an existing encoder test at five
+seconds. That test passed in isolation in 2.7 seconds; the complete four-worker
+rerun preserved its assertions and timeout. All non-test repository gates passed.
+
+The Chromium/Node main runs, repeated cold subset, Firefox/WebKit compatibility
+checks, preview probes, specialized probes and 87 independently decoded quality
+artifacts are recorded above. The final input-normalization verification reproduced
+all ten selected imazen inputs exactly. No benchmark timing was refreshed by a
+repository test run.
+
+## Prompt 2 boundary
+
+Use this report and the generated data for a later comparison page. Publish
+only claims supported by the measured scope, include competitor advantages,
+and keep original-size results separate from diagnostics. Do not turn missing
+brackets, wrapper limitations or untested features into wins. No production
+codec change, Stable decision, version bump or release is part of this work.
diff --git a/benchmark/jpegxl/comparison/SUMMARY.md b/benchmark/jpegxl/comparison/SUMMARY.md
new file mode 100644
index 00000000..eda7b5ff
--- /dev/null
+++ b/benchmark/jpegxl/comparison/SUMMARY.md
@@ -0,0 +1,141 @@
+# Measured comparison summary
+
+Generated from the raw reports. See `REPORT.md` for methods and limits.
+
+## Correctness and coverage
+
+| Runtime | Subject | Operation | Verified | API absent | Unsupported | Incorrect | Failed |
+| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
+| chromium | purejsimage | decode-lossless | 9 | 0 | 7 | 0 | 0 |
+| chromium | purejsimage | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| chromium | purejsimage | encode | 45 | 0 | 0 | 0 | 0 |
+| chromium | purejsimage | roundtrip | 0 | 0 | 1 | 0 | 0 |
+| chromium | jsquash | decode-lossless | 12 | 3 | 0 | 1 | 0 |
+| chromium | jsquash | decode-lossy | 4 | 0 | 0 | 9 | 0 |
+| chromium | jsquash | encode | 42 | 3 | 0 | 0 | 0 |
+| chromium | jsquash | roundtrip | 1 | 0 | 0 | 0 | 0 |
+| chromium | oxide | decode-lossless | 16 | 0 | 0 | 0 | 0 |
+| chromium | oxide | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| chromium | oxide | encode | 0 | 45 | 0 | 0 | 0 |
+| chromium | oxide | roundtrip | 0 | 1 | 0 | 0 | 0 |
+| chromium | vips | decode-lossless | 16 | 0 | 0 | 0 | 0 |
+| chromium | vips | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| chromium | vips | encode | 45 | 0 | 0 | 0 | 0 |
+| chromium | vips | roundtrip | 1 | 0 | 0 | 0 | 0 |
+| node | purejsimage | decode-lossless | 9 | 0 | 7 | 0 | 0 |
+| node | purejsimage | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| node | purejsimage | encode | 45 | 0 | 0 | 0 | 0 |
+| node | purejsimage | roundtrip | 0 | 0 | 1 | 0 | 0 |
+| node | jsquash | decode-lossless | 12 | 3 | 0 | 1 | 0 |
+| node | jsquash | decode-lossy | 4 | 0 | 0 | 9 | 0 |
+| node | jsquash | encode | 42 | 3 | 0 | 0 | 0 |
+| node | jsquash | roundtrip | 1 | 0 | 0 | 0 | 0 |
+| node | oxide | decode-lossless | 16 | 0 | 0 | 0 | 0 |
+| node | oxide | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| node | oxide | encode | 0 | 45 | 0 | 0 | 0 |
+| node | oxide | roundtrip | 0 | 1 | 0 | 0 | 0 |
+| node | vips | decode-lossless | 16 | 0 | 0 | 0 | 0 |
+| node | vips | decode-lossy | 13 | 0 | 0 | 0 | 0 |
+| node | vips | encode | 45 | 0 | 0 | 0 | 0 |
+| node | vips | roundtrip | 1 | 0 | 0 | 0 | 0 |
+
+## Representative photo workflows
+
+Fixture im26-1030-diagnostic. Times in milliseconds; warm median [minimum, maximum] from three operations. Cold is initialization plus first operation, with sample count. Node peak RSS includes the entire isolated process. Oxide decode includes PNG export. Failed validation stays visible and has no performance result.
+
+| Runtime | Subject | Workflow | Warm ms [range] | Cold ms [range]; n | Peak RSS MiB |
+| --- | --- | --- | --- | --- | ---: |
+| chromium | purejsimage | decode-lossless | unsupported input | unsupported input | unavailable |
+| chromium | purejsimage | decode-lossy | 486.0 [482.0, 632.2] | 607.2 [607.2, 607.2]; 1 | unavailable |
+| chromium | purejsimage | lossless encode, effort 1 | 655.0 [622.9, 665.6] | 680.9 [669.0, 691.5]; 3 | unavailable |
+| chromium | purejsimage | roundtrip | unsupported input | unsupported input | unavailable |
+| chromium | jsquash | decode-lossless | 268.5 [266.8, 269.1] | 347.5 [344.5, 348.5]; 3 | unavailable |
+| chromium | jsquash | decode-lossy | incorrect output | incorrect output | unavailable |
+| chromium | jsquash | lossless encode, effort 1 | 55.8 [54.8, 58.2] | 132.9 [132.4, 136.0]; 3 | unavailable |
+| chromium | jsquash | roundtrip | 322.3 [320.7, 323.8] | 476.4 [476.4, 476.4]; 1 | unavailable |
+| chromium | oxide | decode-lossless | 387.6 [387.5, 388.4] | 461.2 [461.1, 465.0]; 3 | unavailable |
+| chromium | oxide | decode-lossy | 294.2 [293.8, 297.7] | 390.7 [390.7, 390.7]; 1 | unavailable |
+| chromium | oxide | lossless encode, effort 1 | API not exposed | API not exposed | unavailable |
+| chromium | oxide | roundtrip | API not exposed | API not exposed | unavailable |
+| chromium | vips | decode-lossless | 176.8 [173.6, 178.9] | 373.2 [363.8, 381.6]; 3 | unavailable |
+| chromium | vips | decode-lossy | 43.8 [41.4, 44.6] | 224.8 [224.8, 224.8]; 1 | unavailable |
+| chromium | vips | lossless encode, effort 1 | 30.3 [27.3, 30.5] | 211.4 [210.2, 218.8]; 3 | unavailable |
+| chromium | vips | roundtrip | 203.3 [201.0, 203.9] | 419.2 [419.2, 419.2]; 1 | unavailable |
+| node | purejsimage | decode-lossless | unsupported input | unsupported input | unavailable |
+| node | purejsimage | decode-lossy | 405.4 [400.7, 409.8] | 521.6 [521.6, 521.6]; 1 | 231.1 |
+| node | purejsimage | lossless encode, effort 1 | 704.6 [688.3, 705.1] | 752.6 [749.8, 752.9]; 3 | 222.8 |
+| node | purejsimage | roundtrip | unsupported input | unsupported input | unavailable |
+| node | jsquash | decode-lossless | 261.8 [260.9, 262.6] | 327.5 [327.4, 330.5]; 3 | 171.3 |
+| node | jsquash | decode-lossy | incorrect output | incorrect output | unavailable |
+| node | jsquash | lossless encode, effort 1 | 52.3 [51.4, 54.0] | 123.3 [122.5, 124.3]; 3 | 179.5 |
+| node | jsquash | roundtrip | 313.6 [313.0, 317.5] | 434.2 [434.2, 434.2]; 1 | 255.5 |
+| node | oxide | decode-lossless | 388.1 [386.8, 391.7] | 457.3 [456.2, 465.6]; 3 | 124.6 |
+| node | oxide | decode-lossy | 290.7 [289.3, 290.8] | 393.4 [393.4, 393.4]; 1 | 147.9 |
+| node | oxide | lossless encode, effort 1 | API not exposed | API not exposed | unavailable |
+| node | oxide | roundtrip | API not exposed | API not exposed | unavailable |
+| node | vips | decode-lossless | 170.4 [167.6, 175.8] | 401.4 [394.2, 427.3]; 3 | 236.7 |
+| node | vips | decode-lossy | 42.6 [40.9, 44.7] | 265.0 [265.0, 265.0]; 1 | 237.7 |
+| node | vips | lossless encode, effort 1 | 26.7 [26.3, 27.5] | 194.5 [193.3, 195.3]; 3 | 176.2 |
+| node | vips | roundtrip | 200.0 [197.6, 200.3] | 446.6 [446.6, 446.6]; 1 | 240.4 |
+
+## Loaded toolkit assets
+
+Includes required JS, WASM, and worker assets once each. Compression sizes are offline estimates.
+
+| Subject | Deployed bytes | gzip | Brotli | Observed cold transfer |
+| --- | ---: | ---: | ---: | ---: |
+| purejsimage | 419805 | 139790 | 114723 | 419805 |
+| jsquash | 2268276 | 855809 | 638868 | 2268276 |
+| oxide | 1714326 | 615293 | 432861 | 1714326 |
+| vips | 7469166 | 2856343 | 2254865 | 8809406 |
+
+## Specialized public API probes
+
+| Subject | Probe | Status |
+| --- | --- | --- |
+| purejsimage | native float and HDR headroom | verified |
+| purejsimage | extra channel samples and meaning | verified |
+| purejsimage | ICC preservation | verified |
+| purejsimage | animation encode, decode, composition, timing and frame access | verified |
+| purejsimage | exact JPEG recompression and reconstruction | verified |
+| purejsimage | region, reduced resolution, preview and selective reads | verified |
+| oxide | animation decode, frame timing and frame access | incorrect output |
+| oxide | animation frame access and composition | verified |
+| oxide | incremental input and first useful preview | verified |
+| oxide | region reconstruction | verified |
+| oxide | ICC access | verified |
+| vips | native float HDR | verified |
+| vips | animation decode, frame timing and composition | verified |
+| vips | animation frame access | verified |
+| vips | ICC access | verified |
+| vips | animation encode with exact 3/5 ms delays | incorrect output |
+| vips | animation encode at ordinary 30/50 ms delays | verified |
+
+## Paired lossless ratios
+
+PureJsImage divided by each comparator. Only pairs that passed exact validation are included. Capped diagnostics, complete originals and precision fixtures stay separate.
+
+| Runtime | Comparator | Scope | Effort | Pairs | Median bytes ratio | Median warm time ratio |
+| --- | --- | --- | ---: | ---: | ---: | ---: |
+| chromium | jsquash | capped | 1 | 8 | 1.047 | 11.696 |
+| chromium | jsquash | capped | 7 | 8 | 1.242 | 5.789 |
+| chromium | jsquash | original | 1 | 5 | 0.993 | 2.891 |
+| chromium | vips | capped | 1 | 8 | 1.151 | 24.471 |
+| chromium | vips | capped | 7 | 8 | 1.131 | 9.793 |
+| chromium | vips | original | 1 | 5 | 1.018 | 6.687 |
+| chromium | vips | specialized | 1 | 3 | 0.708 | 1.088 |
+| node | jsquash | capped | 1 | 8 | 1.047 | 13.227 |
+| node | jsquash | capped | 7 | 8 | 1.242 | 7.097 |
+| node | jsquash | original | 1 | 5 | 0.993 | 3.183 |
+| node | vips | capped | 1 | 8 | 1.151 | 27.649 |
+| node | vips | capped | 7 | 8 | 1.131 | 11.468 |
+| node | vips | original | 1 | 5 | 1.018 | 7.771 |
+| node | vips | specialized | 1 | 3 | 0.708 | 1.361 |
+
+## Matched lossy quality
+
+Only comparisons with independently validated outputs and adequate brackets on both sides receive a ratio. All unresolved targets and endpoints remain in the dataset.
+
+| Fixture | Comparator | SSIMULACRA2 | Pure/comparator bytes |
+| --- | --- | ---: | ---: |
+| im26-1030-diagnostic | vips | 80 | 1.212 |
diff --git a/benchmark/jpegxl/comparison/adapters.ts b/benchmark/jpegxl/comparison/adapters.ts
new file mode 100644
index 00000000..274208d7
--- /dev/null
+++ b/benchmark/jpegxl/comparison/adapters.ts
@@ -0,0 +1,260 @@
+import type { Adapter, Subject } from './model.ts'
+
+// Only published entry points. Library loading and asset compilation belong to cold initialization.
+export async function initialize(
+ subject: Subject,
+ asset: (path: string) => Promise,
+ vipsLocation?: string,
+): Promise {
+ if (subject === 'purejsimage') {
+ const { MemorySource, Uint8ArraySink, defaultImageLimits } = await import('purejsimage/browser')
+ const { jpegxlCodec } = await import('purejsimage/codecs/jpegxl')
+ return {
+ async decode(bytes) {
+ const decoder = await jpegxlCodec.createDecoder?.(
+ new MemorySource(bytes),
+ defaultImageLimits,
+ )
+ if (!decoder) throw new Error('Missing public decoder')
+ const format = decoder.pixelFormat,
+ channels = format.startsWith('rgba') ? 4 : format.startsWith('rgb') ? 3 : 1
+ const data = format.endsWith('16')
+ ? new Uint16Array(decoder.width * decoder.height * channels)
+ : format.endsWith('f32')
+ ? new Float32Array(decoder.width * decoder.height * channels)
+ : new Uint8Array(decoder.width * decoder.height * channels)
+ for await (const block of decoder.decode()) {
+ try {
+ const view = new DataView(
+ block.data.buffer,
+ block.data.byteOffset,
+ block.data.byteLength,
+ ),
+ size = data.BYTES_PER_ELEMENT
+ for (let y = 0; y < block.height; y++)
+ for (let x = 0; x < block.width * channels; x++) {
+ const offset = y * block.stride + x * size
+ data[((block.y + y) * decoder.width + block.x) * channels + x] =
+ data instanceof Uint16Array
+ ? view.getUint16(offset)
+ : data instanceof Float32Array
+ ? view.getFloat32(offset)
+ : (block.data[offset] ?? 0)
+ }
+ } finally {
+ block.release?.()
+ }
+ }
+ return {
+ kind: 'pixels',
+ pixels: {
+ width: decoder.width,
+ height: decoder.height,
+ channels,
+ data,
+ interpretation:
+ data instanceof Float32Array
+ ? 'linear'
+ : data instanceof Uint16Array
+ ? 'srgb16'
+ : 'srgb',
+ },
+ }
+ },
+ async encode(p, s) {
+ const format =
+ p.data instanceof Uint16Array
+ ? p.channels === 4
+ ? 'rgba16'
+ : p.channels === 3
+ ? 'rgb16'
+ : 'gray16'
+ : p.channels === 4
+ ? 'rgba8'
+ : p.channels === 3
+ ? 'rgb8'
+ : 'gray8'
+ if (p.data instanceof Float32Array)
+ throw new Error('Use native float API probe; no implicit conversion')
+ const sink = new Uint8ArraySink(),
+ encoder = await jpegxlCodec.createEncoder?.(sink, {
+ width: p.width,
+ height: p.height,
+ pixelFormat: format,
+ limits: defaultImageLimits,
+ options: {
+ mode: s.lossless ? 'lossless' : 'lossy',
+ effort: s.effort,
+ ...(s.lossless ? {} : { distance: s.value }),
+ },
+ colorSemantics: {
+ family: p.channels === 1 ? 'gray' : 'rgb',
+ primaries: 'srgb',
+ transfer: { kind: 'srgb' },
+ matrix: 'identity',
+ range: 'full',
+ alpha: p.channels === 4 ? 'straight' : 'none',
+ provenance: 'container-signaled',
+ renderingIntent: 'relative',
+ },
+ })
+ if (!encoder) throw new Error('Missing public encoder')
+ const bytes = new Uint8Array(p.data.byteLength)
+ if (p.data instanceof Uint16Array) {
+ const view = new DataView(bytes.buffer)
+ for (let i = 0; i < p.data.length; i++) view.setUint16(i * 2, p.data[i] ?? 0)
+ } else bytes.set(p.data)
+ await encoder.write({
+ x: 0,
+ y: 0,
+ width: p.width,
+ height: p.height,
+ stride: p.width * p.channels * p.data.BYTES_PER_ELEMENT,
+ format,
+ data: bytes,
+ })
+ await encoder.finish()
+ return { kind: 'jxl', bytes: sink.toUint8Array() }
+ },
+ close() {},
+ }
+ }
+ if (subject === 'jsquash') {
+ const decoder = await import('@jsquash/jxl/decode.js'),
+ encoder = await import('@jsquash/jxl/encode.js')
+ await decoder.init({ wasmBinary: await asset('jsquash-dec.wasm') })
+ await encoder.init({ wasmBinary: await asset('jsquash-enc.wasm') })
+ return {
+ async decode(bytes) {
+ const p = await decoder.default(new Uint8Array(bytes).buffer)
+ return {
+ kind: 'pixels',
+ pixels: {
+ width: p.width,
+ height: p.height,
+ channels: 4,
+ data: new Uint8Array(p.data),
+ interpretation: 'srgb',
+ },
+ }
+ },
+ async encode(p, s) {
+ if (!(p.data instanceof Uint8Array) || p.channels !== 4)
+ throw new Error('jSquash public input requires RGBA8 ImageData')
+ const image = new ImageData(new Uint8ClampedArray(p.data), p.width, p.height)
+ return {
+ kind: 'jxl',
+ bytes: new Uint8Array(
+ await encoder.default(image, {
+ lossless: s.lossless,
+ quality: s.lossless ? 100 : s.value,
+ effort: s.effort,
+ }),
+ ),
+ }
+ },
+ close() {},
+ }
+ }
+ if (subject === 'oxide') {
+ const { default: init, JxlImage } = await import('jxl-oxide-wasm')
+ await init({ module_or_path: await asset('oxide.wasm') })
+ return {
+ async decode(bytes, nativePrecision = false) {
+ const image = new JxlImage()
+ try {
+ image.forceSrgb = !nativePrecision
+ image.feedBytes(bytes)
+ if (!image.tryInit()) throw new Error('Incomplete image header')
+ const frame = image.render(0)
+ // encodeToPng consumes the RenderResult in this published wasm-bindgen binding.
+ return { kind: 'png', bytes: frame.encodeToPng() }
+ } finally {
+ image.free()
+ }
+ },
+ close() {},
+ }
+ }
+ const { default: Vips } = await import('wasm-vips')
+ const vips = await Vips({
+ dynamicLibraries: ['vips-jxl.wasm'],
+ ...(vipsLocation
+ ? {
+ locateFile: (name: string) => `${vipsLocation}/${name}`,
+ mainScriptUrlOrBlob: `${vipsLocation}/vips-es6.js`,
+ }
+ : {}),
+ })
+ vips.concurrency(1)
+ vips.Cache.max(0) // Warm means new decode/encode work, never libvips' operation-result cache.
+ return {
+ async decode(bytes) {
+ const image = vips.Image.jxlloadBuffer(bytes)
+ try {
+ const memory = image.writeToMemory()
+ const data =
+ memory instanceof Uint8Array ||
+ memory instanceof Uint16Array ||
+ memory instanceof Float32Array
+ ? memory.slice()
+ : undefined
+ if (!data) throw new Error(`Unmapped native sample type ${memory.constructor.name}`)
+ return {
+ kind: 'pixels',
+ pixels: {
+ width: image.width,
+ height: image.height,
+ channels: image.bands,
+ data,
+ interpretation:
+ image.interpretation === 'rgb16' || image.interpretation === 'grey16'
+ ? 'srgb16'
+ : image.interpretation === 'scrgb'
+ ? 'linear'
+ : 'srgb',
+ },
+ }
+ } finally {
+ image.delete()
+ }
+ },
+ async encode(p, s) {
+ const image = vips.Image.newFromMemory(
+ p.data,
+ p.width,
+ p.height,
+ p.channels,
+ p.data instanceof Uint16Array
+ ? vips.BandFormat.ushort
+ : p.data instanceof Float32Array
+ ? vips.BandFormat.float
+ : vips.BandFormat.uchar,
+ )
+ const tagged = image.copy({
+ interpretation:
+ p.data instanceof Uint16Array
+ ? p.channels === 1
+ ? 'grey16'
+ : 'rgb16'
+ : p.data instanceof Float32Array
+ ? 'scrgb'
+ : 'srgb',
+ })
+ try {
+ return {
+ kind: 'jxl',
+ bytes: tagged
+ .jxlsaveBuffer({ lossless: s.lossless, distance: s.value, effort: s.effort })
+ .slice(),
+ }
+ } finally {
+ tagged.delete()
+ image.delete()
+ }
+ },
+ close() {
+ vips.shutdown()
+ },
+ }
+}
diff --git a/benchmark/jpegxl/comparison/after-main.ts b/benchmark/jpegxl/comparison/after-main.ts
new file mode 100644
index 00000000..64eee5f4
--- /dev/null
+++ b/benchmark/jpegxl/comparison/after-main.ts
@@ -0,0 +1,31 @@
+import { spawnSync } from 'node:child_process'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { root, work } from './io.ts'
+
+// Run under run-m7-bounded.ts. One process tree and one phase at a time.
+const phases = [
+ ['repair-chromium', 'run.ts', 'repair', 'chromium'],
+ ['compat-firefox', 'run.ts', 'compat', 'firefox'],
+ ['compat-webkit', 'run.ts', 'compat', 'webkit'],
+ ['preview-chromium', 'preview.ts'],
+ ['quality', 'quality.ts'],
+ ['main-node', 'run.ts', 'main', 'node'],
+ ['cold-chromium', 'run.ts', 'cold', 'chromium'],
+ ['cold-node', 'run.ts', 'cold', 'node'],
+] as const
+await mkdir(`${work}/phase-logs`, { recursive: true })
+for (const [id, file, ...args] of phases) {
+ console.log(`Starting ${id}`)
+ const result = spawnSync(process.execPath, [`${root}/${file}`, ...args], {
+ encoding: 'utf8',
+ timeout: 40 * 60 * 1000,
+ maxBuffer: 16 * 1024 * 1024,
+ env: { ...process.env, ...(id === 'repair-chromium' ? { JXL_COMPARE_SUBJECTS: 'vips' } : {}) },
+ })
+ await writeFile(`${work}/phase-logs/${id}.log`, `${result.stdout ?? ''}\n${result.stderr ?? ''}`)
+ if (result.status !== 0)
+ throw new Error(
+ `${id} failed: ${result.error?.message ?? result.signal ?? result.status}; ${(result.stderr ?? '').slice(-1600)}`,
+ )
+ console.log(`Finished ${id}`)
+}
diff --git a/benchmark/jpegxl/comparison/amend-validation.ts b/benchmark/jpegxl/comparison/amend-validation.ts
new file mode 100644
index 00000000..6b3704c2
--- /dev/null
+++ b/benchmark/jpegxl/comparison/amend-validation.ts
@@ -0,0 +1,43 @@
+import { readdir, readFile } from 'node:fs/promises'
+import { hash, json, root } from './io.ts'
+import { object, string } from './model.ts'
+
+const reports = []
+for (const name of await readdir(`${root}/results`)) {
+ if (!/^(main|compat|cold|repair)-.*\.json$/.test(name)) continue
+ const path = `${root}/results/${name}`,
+ bytes = await readFile(path),
+ report = object(JSON.parse(bytes.toString())),
+ rows = report.rows
+ if (!Array.isArray(rows)) throw new Error('Missing rows')
+ const amendments = []
+ for (const value of rows) {
+ const row = object(value),
+ settings = object(row.settings)
+ if (row.operation !== 'encode' || settings.lossless !== false || !row.validation) continue
+ const validation = object(row.validation)
+ if (typeof validation.alphaMaximumError !== 'number') continue
+ const shapePassed = typeof row.detail === 'string' && row.detail.includes('shape true')
+ if (!shapePassed) continue
+ const exactAlphaRequired = row.subject === 'purejsimage',
+ newStatus =
+ exactAlphaRequired && validation.alphaMaximumError !== 0 ? 'incorrect output' : 'verified'
+ amendments.push({
+ key: string(row.key),
+ originalStatus: row.status,
+ status: newStatus,
+ maximumAlphaError: validation.alphaMaximumError,
+ exactAlphaRequired,
+ detail:
+ 'Lossy public-default request: record alpha distortion, enforce exact alpha only where promised. All lossless samples and decode tolerances unchanged.',
+ })
+ }
+ reports.push({ sourceReport: path, sourceReportSha256: hash(bytes), amendments })
+}
+await json(`${root}/results/validation-amendment.json`, {
+ schemaVersion: 1,
+ date: new Date().toISOString(),
+ decision:
+ 'Comparison uses each public lossy default, including its alpha behavior. Exact default alpha is a PureJsImage guarantee; do not call another wrapper incorrect merely for lossy alpha it does not promise to preserve. Preserve every measured alpha error and black/white score. Historical timing values and original statuses are retained.',
+ reports,
+})
diff --git a/benchmark/jpegxl/comparison/archive-pins.ts b/benchmark/jpegxl/comparison/archive-pins.ts
new file mode 100644
index 00000000..9178a7ae
--- /dev/null
+++ b/benchmark/jpegxl/comparison/archive-pins.ts
@@ -0,0 +1,16 @@
+import { readdir, readFile, unlink, writeFile } from 'node:fs/promises'
+import { gzipSync } from 'node:zlib'
+import { hash, root } from './io.ts'
+
+// Preserve exact manifest bytes referenced by completed reports before formatting JSON.
+for (const name of ['subjects', 'fixtures']) {
+ const bytes = await readFile(`${root}/${name}.json`)
+ await writeFile(`${root}/results/${name}-${hash(bytes)}.json.gz`, gzipSync(bytes))
+}
+for (const name of await readdir(`${root}/results`)) {
+ if (!/^subjects-[a-f0-9]{64}\.json$/.test(name)) continue
+ const path = `${root}/results/${name}`,
+ bytes = await readFile(path)
+ await writeFile(`${path}.gz`, gzipSync(bytes))
+ await unlink(path)
+}
diff --git a/benchmark/jpegxl/comparison/browser-worker.ts b/benchmark/jpegxl/comparison/browser-worker.ts
new file mode 100644
index 00000000..575a52bd
--- /dev/null
+++ b/benchmark/jpegxl/comparison/browser-worker.ts
@@ -0,0 +1,25 @@
+import { execute, type Job } from './execute.ts'
+import type { Pixels } from './model.ts'
+
+declare const self: {
+ location: Location
+ onmessage:
+ | ((event: MessageEvent<{ job: Job; input: Uint8Array; pixels: Pixels }>) => Promise)
+ | null
+ postMessage(value: unknown): void
+}
+self.onmessage = async (event: MessageEvent<{ job: Job; input: Uint8Array; pixels: Pixels }>) => {
+ const { job, input, pixels } = event.data
+ const result = await execute(
+ job,
+ input,
+ pixels,
+ async (name) => {
+ const response = await fetch(`/assets/${name}`)
+ if (!response.ok) throw new Error(`Asset HTTP ${response.status}`)
+ return response.arrayBuffer()
+ },
+ `${self.location.origin}/assets/vips`,
+ )
+ self.postMessage(result)
+}
diff --git a/benchmark/jpegxl/comparison/build.ts b/benchmark/jpegxl/comparison/build.ts
new file mode 100644
index 00000000..829266d3
--- /dev/null
+++ b/benchmark/jpegxl/comparison/build.ts
@@ -0,0 +1,104 @@
+import { access, copyFile, cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
+import { brotliCompressSync, constants, gzipSync } from 'node:zlib'
+import { build } from 'esbuild'
+import { hash, json, root, run, work } from './io.ts'
+import { number, object, string } from './model.ts'
+
+let priorAssets: Record[] = []
+try {
+ await access(`${root}/subjects.json`)
+ const previous = await readFile(`${root}/subjects.json`),
+ identity = hash(previous)
+ const inventory = object(JSON.parse(previous.toString())).files
+ if (Array.isArray(inventory)) priorAssets = inventory.map(object)
+ await mkdir(`${work}/asset-history/${identity}`, { recursive: true })
+ await writeFile(`${root}/results/subjects-${identity}.json.gz`, gzipSync(previous))
+ await cp(`${work}/web`, `${work}/asset-history/${identity}/web`, { recursive: true })
+} catch (error) {
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
+}
+await mkdir(`${work}/assets/vips`, { recursive: true })
+const assets = [
+ ['node_modules/@jsquash/jxl/codec/dec/jxl_dec.wasm', 'jsquash-dec.wasm'],
+ ['node_modules/@jsquash/jxl/codec/enc/jxl_enc.wasm', 'jsquash-enc.wasm'],
+ ['node_modules/jxl-oxide-wasm/jxl_oxide_wasm_bg.wasm', 'oxide.wasm'],
+]
+for (const [source, target] of assets) {
+ if (source && target) await copyFile(source, `${work}/assets/${target}`)
+}
+for (const name of ['vips-es6.js', 'vips.wasm', 'vips-jxl.wasm'])
+ await copyFile(`node_modules/wasm-vips/lib/${name}`, `${work}/assets/vips/${name}`)
+await build({
+ entryPoints: [`${root}/browser-worker.ts`],
+ outdir: `${work}/web`,
+ bundle: true,
+ format: 'esm',
+ platform: 'browser',
+ splitting: true,
+ minify: true,
+ metafile: true,
+ external: ['node:*', 'fs', 'path'],
+ alias: { 'wasm-vips': './node_modules/wasm-vips/lib/vips-es6.js' },
+ plugins: [
+ {
+ name: 'vips-external',
+ setup(api) {
+ api.onResolve({ filter: /^wasm-vips$/ }, () => ({
+ path: '/assets/vips/vips-es6.js',
+ external: true,
+ }))
+ },
+ },
+ ],
+ tsconfigRaw: { compilerOptions: {} },
+})
+const packages = []
+for (const name of ['@jsquash/jxl', 'jxl-oxide-wasm', 'wasm-vips']) {
+ const bytes = await readFile(`node_modules/${name}/package.json`)
+ packages.push({
+ name,
+ manifest: object(JSON.parse(bytes.toString())),
+ manifestSha256: hash(bytes),
+ })
+}
+const files = []
+for (const directory of [`${work}/web`, `${work}/assets`, `${work}/assets/vips`])
+ for (const name of await readdir(directory, { withFileTypes: true })) {
+ if (!name.isFile()) continue
+ const path = `${directory}/${name.name}`,
+ bytes = await readFile(path)
+ const cached = priorAssets.find((asset) => asset.sha256 === hash(bytes))
+ files.push({
+ path,
+ sha256: hash(bytes),
+ bytes: bytes.length,
+ gzipBytes: cached ? number(cached.gzipBytes) : gzipSync(bytes, { level: 9 }).length,
+ brotliBytes: cached
+ ? number(cached.brotliBytes)
+ : brotliCompressSync(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
+ })
+ }
+await json(`${root}/subjects.json`, {
+ schemaVersion: 1,
+ date: new Date().toISOString(),
+ implementationRevision: run('git', ['rev-parse', 'HEAD']).trim(),
+ packages,
+ files,
+ build: {
+ esbuild: string(
+ object(JSON.parse(await readFile('node_modules/esbuild/package.json', 'utf8'))).version,
+ ),
+ target:
+ 'ESM modern browser; minified; code splitting; public PureJsImage dist exports; published WASM assets unchanged',
+ node: process.version,
+ },
+ threads: {
+ purejsimage: 1,
+ jsquash: 'single thread on non-isolated browser origin and Node default',
+ oxide: 1,
+ vips: 'concurrency(1), pthread runtime; isolated browser origin required',
+ },
+ cache:
+ 'Cold worker/module initialization and asset fetch; fresh browser context, HTTP no-store. Warm operations reuse initialized module but no operation-result cache. Browser compilation code cache controlled only by fresh context, not OS cache.',
+})
+console.log(`Pinned ${files.length} assets`)
diff --git a/benchmark/jpegxl/comparison/execute.ts b/benchmark/jpegxl/comparison/execute.ts
new file mode 100644
index 00000000..dd219d69
--- /dev/null
+++ b/benchmark/jpegxl/comparison/execute.ts
@@ -0,0 +1,91 @@
+import { initialize } from './adapters.ts'
+import {
+ classifyError,
+ type Fixture,
+ type Output,
+ type Pixels,
+ rgba8,
+ type Settings,
+ type Subject,
+} from './model.ts'
+export interface Job {
+ subject: Subject
+ fixture: Fixture
+ operation: 'decode-lossless' | 'decode-lossy' | 'encode' | 'roundtrip'
+ settings: Settings
+ repeats: number
+}
+export async function execute(
+ job: Job,
+ input: Uint8Array,
+ pixels: Pixels,
+ asset: (path: string) => Promise,
+ vipsLocation?: string,
+) {
+ const coldStart = performance.now()
+ let adapter: Awaited> | undefined
+ try {
+ adapter = await initialize(job.subject, asset, vipsLocation)
+ const initialized = performance.now()
+ if ((job.operation === 'encode' || job.operation === 'roundtrip') && !adapter.encode)
+ return {
+ status: 'API not exposed' as const,
+ detail: 'Published binding has no encoder',
+ output: null,
+ times: [],
+ coldMs: null,
+ initializationMs: initialized - coldStart,
+ }
+ if (job.subject === 'jsquash' && !(pixels.data instanceof Uint8Array))
+ return {
+ status: 'API not exposed' as const,
+ detail: 'ImageData API exposes RGBA8 only; no silent native precision conversion',
+ output: null,
+ times: [],
+ coldMs: null,
+ initializationMs: initialized - coldStart,
+ }
+ const operation = async (): Promise