rapier-jxl 1.1.21 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,19 +1,27 @@
1
1
  # For an agent adding Rapier JXL to an app
2
2
 
3
3
  1. `npm install rapier-jxl`, or copy one file from this repository's root: `rapier-jxl.min.mjs` (the core),
4
- `lossless.min.mjs`, `jpeg.min.mjs` or `photo.min.mjs`. No build.
5
- 2. `import {encode, transcode} from 'rapier-jxl'` (or `rapier-jxl/min`); `encodeLosslessRGBA` from
6
- `rapier-jxl/lossless`, `transcode` from `rapier-jxl/jpeg`, `encodePhotoRGBA` from `rapier-jxl/photo`.
7
- 3. Pixels: `encode(rgba, width, height, {quality})`, straight RGBA row by row as `getImageData` gives it. Quality 100
8
- (the default) is lossless, 1 to 99 lossy, alpha exact at every quality. Photographs:
9
- `encodePhotoRGBA(rgba, width, height, {quality: 90})`. The answer is a `Uint8Array` of codestream bytes: save it
4
+ `effort.min.mjs`, `jpeg.min.mjs` or `photo.min.mjs`. No build.
5
+ 2. `import {encode} from 'rapier-jxl'` (or `rapier-jxl/min`), `transcode` from `rapier-jxl/jpeg`, `encodePhoto`
6
+ from `rapier-jxl/photo`. For smaller lossless files at more time, `encode` from `rapier-jxl/effort` with
7
+ `{effort: 3}`.
8
+ 3. Pixels: `encode(rgba, width, height, {quality})`, straight RGBA row by row as `getImageData` gives it; from a
9
+ Display P3 canvas, add `colorSpace: 'display-p3'`. Quality 100 (the default) is lossless, 1 to 99 lossy, alpha
10
+ exact at every quality. Photographs:
11
+ `encodePhoto(rgba, width, height, {quality: 90})`. The answer is a `Uint8Array` of codestream bytes: save it
10
12
  as `.jxl` or in a `Blob` of type `image/jxl`.
11
13
  4. A JPEG: `transcode(jpegBytes)` gives `{bytes, width, height, orientation}` without decoding. On `JXL_JPEG`,
12
- decode it and call `encode` on the pixels.
13
- 5. Anything larger than an icon runs in a worker. Calls are synchronous, so cancel by terminating the worker. A
14
+ decode it and call `encodePhoto` on the pixels. Both doors accept `{effort: 4}` to try a smaller entropy
15
+ representation with identical reconstructed pixels; the default is 1.
16
+ For an additional ANS candidate, use the same API from `rapier-jxl/jpeg-ans` or `rapier-jxl/photo-ans` with
17
+ `{effort: 2}`. These optional imports cost about 1.1 kB gzip more than their ordinary door and keep the smaller
18
+ complete stream; benchmark the extra encode work for your use. Default effort 1 stays prefix-coded.
19
+ 5. Anything larger than an icon runs in a worker. Calls are synchronous, so cancel by terminating the worker, or
20
+ loop over the door's twin (`encodeSteps`, `transcodeSteps`, `encodePhotoSteps`) and leave the loop. A
14
21
  transferred buffer is gone from the sender: copy first if you may retry.
15
- 6. The error codes are `JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` and `JXL_JPEG`, no others. Keep the
16
- original pixels until the encode and its storage succeed.
22
+ 6. The error codes are `JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` and `JXL_JPEG`, no others;
23
+ `JXL_DIMENSIONS` is past the door's own `LIMITS` (24 million pixels for the core and `effort`, 40 million for
24
+ `photo`, 64 million for a carried JPEG). Keep the original pixels until the encode and its storage succeed.
17
25
  7. Show the result only where the browser decodes JPEG XL (`<picture>` with a fallback, or a one-pixel feature test).
18
26
 
19
27
  Nothing here reads files, fetches or touches the DOM: it runs in a worker, Node or Deno alike. Publish the version
@@ -5,10 +5,10 @@ A bounded survey, not proof of a global minimum; the encoders differ in what the
5
5
 
6
6
  | Encoder / entry | Version | Minified JS + WASM bytes | gzip bytes | Included capability |
7
7
  | --- | --- | ---: | ---: | --- |
8
- | Rapier JXL core | this release | 36,284 | 14,987 | 8-bit lossless RGBA, lossy modular with exact alpha, JPEG coefficients |
9
- | Rapier JXL lossless | this release | 13,754 | 6,131 | 8-bit exact RGBA |
10
- | Rapier JXL JPEG | this release | 27,750 | 11,590 | JPEG coefficients, orientation; no JPEG reconstruction |
11
- | Rapier JXL photo | this release | 22,563 | 9,768 | 8-bit photographic VarDCT, exact alpha; q100 lossless |
8
+ | Rapier JXL core | 2.1.0 | 19,539 | 8,511 | 8-bit lossless RGBA, lossy modular with exact alpha |
9
+ | Rapier JXL effort door | 2.1.0 | 30,008 | 12,320 | The core, and the weighted predictor searched for lossless |
10
+ | Rapier JXL JPEG door | 2.1.0 | 32,218 | 13,439 | JPEG coefficients, orientation; no JPEG reconstruction |
11
+ | Rapier JXL photo door | 2.1.0 | 31,288 | 12,872 | 8-bit photographic VarDCT, exact alpha; q100 lossless |
12
12
  | [jSquash](https://github.com/jamsinclair/jSquash/tree/main/packages/jxl) | 1.3.0 | 1,388,572 | 525,782 | 8-bit lossless/lossy RGBA |
13
13
  | [Discourse's jSquash package](https://www.npmjs.com/package/@discourse/jxl) | 1.3.0 | 1,388,572 | 525,782 | The same encoder bytes as jSquash |
14
14
  | [Lacinak's jSquash fork](https://github.com/kelaci/jSquash) | 1.3.0-kelaci.0 | 2,071,514 | 844,866 | High bit-depth input options |
@@ -51,7 +51,7 @@ Gaussian, no downsampling); alpha-bearing inputs are matted on white for the tab
51
51
  reference tool at 80 nits, the worse of the white and black mattes for alpha inputs. Higher PSNR and SSIM and
52
52
  lower Butteraugli are that metric's preference, not a human verdict. **R / N** is Rapier / native.
53
53
 
54
- ### Photo entry
54
+ ### The photo door
55
55
 
56
56
  Native is within 0.402% of the budget in all 20 rows, and better on PSNR and SSIM in 19 of 20 and on
57
57
  Butteraugli in 19 of 20; the exceptions are Grace q99 (Rapier's PSNR and SSIM) and lit surface q99 (Rapier's
@@ -80,7 +80,7 @@ Butteraugli).
80
80
  | Grace Hopper · 90 | 56,543 | 56,519 | 1.110 | 38.605 / 38.944 | 0.950222 / 0.970007 | 2.3300 / 1.4546 |
81
81
  | Grace Hopper · 99 | 110,542 | 110,533 | 0.173 | 49.948 / 47.182 | 0.996348 / 0.995977 | 0.4889 / 0.3406 |
82
82
 
83
- ### Lossy modular entry on drawings and paintings
83
+ ### The core's lossy modular on drawings and paintings
84
84
 
85
85
  Fourteen rows are matched within 0.127%. In the ten **†** rows an exact native stream fits in 3.052% to
86
86
  51.144% fewer bytes. Native SSIM and Butteraugli are better in all 24 rows; PSNR favours Rapier for paint-0001
package/README.md CHANGED
@@ -1,42 +1,56 @@
1
1
  # Rapier JXL
2
2
 
3
- A JPEG XL encoder in pure JavaScript: one file, 36.3 kB, 15.0 kB gzipped, no WebAssembly, no
3
+ A JPEG XL encoder in pure JavaScript: one file, 19.5 kB, 8.5 kB gzipped, no WebAssembly, no
4
4
  dependencies, MIT. The encoder inside [Rapier](https://rapier.website), published on its own. The smallest
5
5
  JavaScript or WebAssembly JPEG XL encoder among the payloads [we measured](ENCODER-COMPARISON.md).
6
6
 
7
7
  - **Lossless.** Every pixel back as it went in: 8-bit grey, grey with alpha, RGB, RGBA.
8
8
  - **Lossy**, quality 1 to 99, for flat-colour rasters (screenshots, pixel art, scanned line art). Alpha stays exact.
9
9
  A picture of few colours is written exact when that is smaller.
10
- - **Photographs**, optionally: `rapier-jxl/photo`, DCT8 compression with exact alpha, adding nothing to the core.
11
- - **A JPEG carried as its coefficients**: one call, no decode, the way libjxl transcodes. Not carried: the
12
- reconstruction data (the JPEG file cannot be rebuilt), ICC, Exif beyond the orientation, XMP.
10
+ - **Smaller exact pictures, slower**: `rapier-jxl/effort`, the same `encode` with `{effort: 2}`, `3`, `4` or `6`, each never
11
+ larger than the effort below. Effort 1, its default, is the core's bytes.
12
+ - **Photographs**: `rapier-jxl/photo`, DCT8 compression with exact alpha, a door of its own. Effort 5 searches
13
+ quantisation per block and keeps a candidate only when its complete stream is smaller.
14
+ - **A JPEG carried as its coefficients**: `rapier-jxl/jpeg`, one call, no decode, the way libjxl transcodes. Not
15
+ carried: the reconstruction data (the JPEG file cannot be rebuilt), the ICC bytes, Exif beyond the orientation, XMP.
16
+ - **Optional ANS entropy coding**: import `transcode` from `rapier-jxl/jpeg-ans` or `encodePhoto` from
17
+ `rapier-jxl/photo-ans` and pass `{effort: 2}`. Each tries ANS after writing the prefix-coded floor and keeps
18
+ the smaller complete stream, with identical reconstructed pixels. These imports leave the core unchanged.
19
+ - **sRGB or Display P3.** `{colorSpace: 'display-p3'}` declares a wide-gamut canvas's samples. A JPEG's profile is
20
+ read by what it does, not what it says: sRGB and Display P3 are carried and declared.
13
21
 
14
22
  ## Use it
15
23
 
16
24
  ```js
17
- import {encode, transcode} from 'rapier-jxl';
25
+ import {encode} from 'rapier-jxl';
26
+ import {transcode} from 'rapier-jxl/jpeg';
27
+ import {encodePhoto} from 'rapier-jxl/photo';
18
28
 
19
29
  const {data, width, height} = context.getImageData(0, 0, canvas.width, canvas.height);
20
30
  const exact = encode(data, width, height); // lossless
21
31
  const small = encode(data, width, height, {quality: 80}); // lossy
32
+ const photo = encodePhoto(data, width, height); // quality 90; 100 is exact
22
33
  const blob = new Blob([small], {type: 'image/jxl'});
23
34
 
24
35
  const {bytes, width: w, height: h, orientation} = transcode(new Uint8Array(await file.arrayBuffer()));
25
36
  ```
26
37
 
27
38
  `encode(data, width, height, {quality = 100})` takes straight RGBA bytes, row by row, and returns a `Uint8Array`
28
- holding a bare JPEG XL codestream. `transcode(jpeg)` returns `{bytes, width, height, orientation}`: the size as
29
- shown, the orientation kept in the header.
30
-
31
- ```js
32
- import {encodePhotoRGBA} from 'rapier-jxl/photo';
33
- const photo = encodePhotoRGBA(data, width, height, {quality: 90}); // 100 is exact
34
- ```
35
-
36
- Entries: `rapier-jxl` (the readable modules), `rapier-jxl/min` (one minified file), `rapier-jxl/lossless`,
37
- `rapier-jxl/jpeg`, `rapier-jxl/photo`. Each minified file carries its MIT notice, TypeScript declarations sit
38
- beside each entry, and a worker and a page are under `public/examples/`. Quality numbers are not the same fidelity
39
- across encoders or pictures, and lossy is not always smaller than lossless.
39
+ holding a bare JPEG XL codestream; `encodePhoto` takes the same. `transcode(jpeg)` returns
40
+ `{bytes, width, height, orientation}`: the size as shown, the orientation kept in the header.
41
+
42
+ Doors: `rapier-jxl` (the core), `rapier-jxl/effort`, `rapier-jxl/jpeg`, `rapier-jxl/photo`, and the two optional
43
+ ANS doors above, each readable, so a
44
+ bundler carries their shared modules once; `rapier-jxl/min` is the core as one minified file. `rapier-jxl/writer`
45
+ gives a module's author the layers beneath the doors (readable only; they change only with the major version).
46
+ TypeScript declarations sit beside each door, and a worker and a page are under `public/examples/`. Quality numbers
47
+ are not the same fidelity across encoders or pictures, and lossy is not always smaller than lossless.
48
+
49
+ The photo door defaults to effort 1. Its higher efforts keep the preceding stream as a candidate; effort 5 also
50
+ bounds effort 1's unclipped RGB sample reconstruction error on edge-extended DCT blocks, before clipping and integer output.
51
+ Decoded integer RGB error can differ from that model. Quality 100 remains exact at every effort.
52
+ The quantisation search keeps the preceding stream when its estimated memory would exceed the photo door's
53
+ working budget; the door's 40-million-pixel admission stays the same.
40
54
 
41
55
  ### In a worker
42
56
 
@@ -44,7 +58,8 @@ Encoding is synchronous. Run it off the main thread:
44
58
 
45
59
  ```js
46
60
  // jxl-worker.mjs
47
- import {encode, transcode} from 'rapier-jxl';
61
+ import {encode} from 'rapier-jxl';
62
+ import {transcode} from 'rapier-jxl/jpeg';
48
63
  self.onmessage = ({data: {id, op, ...ask}}) => {
49
64
  try {
50
65
  const out = op === 'transcode' ? transcode(ask.jpeg) : {bytes: encode(ask.data, ask.width, ask.height, {quality: ask.quality})};
@@ -55,39 +70,92 @@ self.onmessage = ({data: {id, op, ...ask}}) => {
55
70
 
56
71
  To cancel, terminate the worker and drop its request id. Keep the input in the caller if you may retry.
57
72
 
73
+ Each door has a twin that does the same work in steps: `encodeSteps`, `transcodeSteps`, `encodePhotoSteps` return a
74
+ job, `for (const done of job)` runs one group of one pass per step (`done` is the fraction, the last exactly 1), and
75
+ `job.bytes` is the stream after the loop, the same bytes the door writes. Leaving the loop cancels, so a worker can
76
+ take messages and report progress between steps (`public/examples/worker.mjs`). `job.hurry = true` (the example's
77
+ `deadline` sets it) ends a door's search at its next step with the smallest stream written so far, never
78
+ larger than effort 1's.
79
+
80
+ The JPEG and photograph doors also read effort: 3 tries a 32-cluster budget, and 4 also tries an order learned
81
+ from coefficient counts. These entropy rungs keep the smaller complete stream and preserve every reconstructed pixel. Their
82
+ default remains 1; effort 2 keeps the default plan. A hurried JPEG job finishes its effort-1 floor in its first
83
+ half; through effort 4 the photo job first makes coefficients, then writes that floor, and searches in its final
84
+ quarter. At effort 5, its first half finishes the preceding stream and its second half searches quantisation,
85
+ keeping the completed floor on a hurry.
86
+
87
+ The optional ANS doors keep default effort 1's prefix bytes. Effort 2 adds one ANS candidate; efforts 3 and 4
88
+ also retain the ordinary prefix searches, and photo effort 5 retains its quantisation search. ANS uses a bounded
89
+ group buffer of 1,376,256 bytes plus histogram tables; encoding both candidates costs more CPU and may raise peak
90
+ RSS. Hurry keeps a completed candidate even when it arrives at the final group's yield.
91
+
92
+ Version 2.1.0 fits the photo door's existing quantisation constants across thumbnail and source-sized photos.
93
+ Lossy photo bytes change deliberately; lossless and ordinary JPEG streams retain their hashes. Pin a package
94
+ version when exact lossy output bytes matter. Quality numbers remain a setting, not a guarantee of equal
95
+ perceptual quality on every image.
96
+
58
97
  ### Limits and errors
59
98
 
60
- One picture at a time, at most 16,384 pixels a side, 24 million pixels and a 16 MiB stream. Arguments are checked
61
- before any work. A refusal is an `Error` whose `code` is `JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` or
62
- `JXL_JPEG` (arithmetic coding, 12-bit, lossless, CMYK, a DNL height, a colour profile other than sRGB, or a JPEG
63
- cut short: decode it and encode the pixels instead).
99
+ One picture at a time, at most 16,384 pixels a side and a 16 MiB stream. Each door's `LIMITS` sets its pixels by its
100
+ memory, so that none needs more at its limit than the core at its own: 24 million for the core and `effort` (a lossy
101
+ picture holds its planes whole, 15.7 bytes a pixel at its peak besides the input), 40 million for `photo` (6.5), and
102
+ 64 million for a JPEG that `jpeg` carries (3.3 at 4:2:0, 6.4 at 4:4:4), so a phone's 24 and 48 megapixel
103
+ photographs are carried. Arguments are checked before any work. A refusal is an `Error` whose `code` is `JXL_INPUT`,
104
+ `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY` or `JXL_JPEG` (arithmetic coding, 12-bit, lossless, CMYK, a DNL height, a
105
+ colour profile other than sRGB or Display P3, or a JPEG cut short: decode it and encode the pixels instead).
64
106
 
65
- ## Sizes
107
+ ### From 1.x
108
+
109
+ 2.0.0 is the package's own version (1.x took Rapier's), and the core is pixels only:
66
110
 
67
- | file | bytes | gzip | Brotli |
68
- | --- | ---: | ---: | ---: |
69
- | `rapier-jxl.min.mjs`, core | 36,284 | 14,987 | 13,315 |
70
- | `lossless.min.mjs`, `encodeLosslessRGBA` alone | 13,754 | 6,131 | 5,401 |
71
- | `jpeg.min.mjs`, `transcode` alone | 27,750 | 11,590 | 10,217 |
72
- | `photo.min.mjs`, photographic pixels | 22,563 | 9,768 | 8,635 |
73
- | all readable modules, including photo | 102,565 | 31,745 | |
111
+ - `transcode` is in `rapier-jxl/jpeg`, and `encodePhotoRGBA` is `encodePhoto` in `rapier-jxl/photo`.
112
+ - `encodeLosslessRGBA(data, width, height)` and `rapier-jxl/lossless` are `encode(data, width, height)`;
113
+ `encodeLossyRGBA(data, width, height, quality)` is `encode(data, width, height, {quality})`.
114
+ - `encodeLossless`, `encodeLossy`, `inspectPixels`, `parseJPEG` and `transcodeJPEG` are in `rapier-jxl/writer`.
115
+ - A JPEG's colour profile is read by what it does: Display P3 (an iPhone's) is carried and declared, where 1.x
116
+ refused it; a profile of lookup tables is refused, even one named sRGB.
117
+ - Lossless streams and carried JPEGs are 1.x's bytes. Lossy streams of a picture wider or taller than 256 pixels,
118
+ or of a colour picture one pixel wide or high, and every photo stream changed where Chrome's decoder (jxl-rs
119
+ 0.7.4) misread a valid stream; each decodes to the same pixels as before through jxl-oxide and libjxl.
74
120
 
75
- Exact bytes of this release's files, measured by the script that stages this repository; `sizes.json` carries
76
- their hashes and tools (terser 5.51.2, Node v22.22.2; gzip 9, Brotli 11). Each minified file is proved at
77
- staging to write the same bytes as its readable source.
121
+ New: `rapier-jxl/effort`, each door's twin in steps with `hurry`, `colorSpace: 'display-p3'`, each door's own
122
+ `LIMITS`.
123
+
124
+ ## Sizes
78
125
 
79
- What it writes: bare codestreams, 8-bit, prefix codes (never ANS), one frame, no preview, animation, ICC (sRGB is
80
- declared), XYB, chroma-from-luma or filters. Lossless in modular mode, a palette of up to 2,048 colours weighed
81
- against direct coding by actual length, groups of 256, reversible YCoCg, a predictor chosen per channel. Lossy
82
- through Squeeze with exact alpha. Carried JPEGs in VarDCT with the JPEG's own tables. The photo entry writes DCT8
83
- coefficients for the same writer.
126
+ | file | bytes | gzip | Brotli | added to the core, gzip |
127
+ | --- | ---: | ---: | ---: | ---: |
128
+ | `rapier-jxl.min.mjs`, the core: `encode` | 19,539 | 8,511 | 7,515 | |
129
+ | `effort.min.mjs`: `encode` with effort | 30,008 | 12,320 | 10,860 | 3,809 |
130
+ | `jpeg.min.mjs`: `transcode` | 32,218 | 13,439 | 11,874 | 8,497 |
131
+ | `photo.min.mjs`: `encodePhoto` | 31,288 | 12,872 | 11,370 | 6,257 |
132
+ | `jpeg-ans.min.mjs`: optional ANS carrier | 35,281 | 14,506 | 12,804 | 9,564 |
133
+ | `photo-ans.min.mjs`: optional ANS photo | 34,293 | 13,899 | 12,295 | 7,298 |
134
+ | every door in one bundle | 61,101 | 24,091 | 21,147 | |
135
+ | all readable modules | 174,894 | 53,445 | | |
136
+
137
+ Exact bytes of release 2.1.0's files, measured by the script that stages this repository; `sizes.json`
138
+ carries their hashes and tools (terser 5.51.2, Node v22.22.2; gzip 9, Brotli 11). Each minified file stands alone
139
+ and is proved at staging to write the same bytes as its readable source; the last column is what a door adds to a
140
+ bundle that already holds the core. `effort`'s `encode` is the core's at effort 1, so it takes the core's place, in
141
+ that column and in the bundle of every door.
142
+
143
+ What it writes: bare codestreams, 8-bit, prefix codes (or ANS in the optional doors), one frame, no preview, animation, ICC (sRGB or
144
+ Display P3 is declared), XYB, chroma-from-luma or filters. Lossless in modular mode, a palette of up to 2,048
145
+ colours weighed against direct coding by actual length, groups of 256, reversible YCoCg, a predictor chosen per
146
+ channel; at effort 2 and 3 also the weighted predictor, its contexts split by its own error; at 4 local modelling of
147
+ palette indices; at 6 local gradient-property splits. Effort 5 uses rung 4. Lossy through Squeeze
148
+ with exact alpha. Carried JPEGs in VarDCT with the JPEG's own tables. The photo door
149
+ writes DCT8 coefficients for the same writer.
84
150
 
85
151
  ## Checked
86
152
 
87
153
  Tests and seeded structure-aware fuzzing in `public/test/`: `npm test`, decoded through
88
154
  [jxl-oxide](https://github.com/tirr-c/jxl-oxide) 0.12.6 and FFmpeg's native libjxl (a missing native decoder is
89
155
  reported, and fails in CI). Exact pixels and alpha where promised, fidelity where relevant, the accepted JPEG
90
- forms, refusal of malformed input. The 30 September run put 2,000,000 JPEG mutations and 32,768 pixel cases
156
+ forms, refusal of malformed input. The same input writes the same bytes in every JavaScript engine: nothing that
157
+ decides a byte uses a function engines round differently, and `public/test/bytes.test.mjs` holds the streams' hashes
158
+ under Node and Bun. The 30 September run put 2,000,000 JPEG mutations and 32,768 pixel cases
91
159
  through both decoders. The two decoders differ by one RGB unit on some JPEG and photo streams (floating-point
92
160
  reconstruction), kept in `public/test/seeds/`; alpha and modular output are exact. With a C compiler and libjxl
93
161
  headers, `npm run fuzz:scale -- --out fuzz-run --workers 4` repeats the fixed budget.
package/admit.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // Rapier's JPEG XL encoder: what every door admits, and how each refuses. MIT (LICENSE).
2
+ // One owner for the checked doors: the options read before any work, the limits, the five codes, and the size and
3
+ // memory refusals after it. A module's door refuses the same way (writer.mjs).
4
+ import {LIMITS} from './bits.mjs';
5
+
6
+ export const fault = (code, message) => Object.assign(new Error(message), {code});
7
+
8
+ // The options every door reads, before any work: an object, or nothing; quality a number from 1 to 100, the door's
9
+ // own default when absent; the samples' colour space, the web platform's name for it, sRGB when absent.
10
+ export function admitOptions(options = {}, quality = 100) {
11
+ if (options === null || typeof options !== 'object') throw fault('JXL_INPUT', 'Options are an object: {quality, colorSpace}.');
12
+ if (options.quality !== undefined) quality = options.quality;
13
+ if (typeof quality !== 'number' || !Number.isFinite(quality) || quality < 1 || quality > 100) throw fault('JXL_INPUT', 'Quality is a number from 1 to 100.');
14
+ const colorSpace = options.colorSpace === undefined ? 'srgb' : options.colorSpace;
15
+ if (colorSpace !== 'srgb' && colorSpace !== 'display-p3') throw fault('JXL_INPUT', 'The colour space is srgb or display-p3.');
16
+ return {quality, colorSpace};
17
+ }
18
+
19
+ // A door's size, against the door's own limits (the core's when none are named).
20
+ export function admitSize(width, height, limits = LIMITS) {
21
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw fault('JXL_INPUT', 'A picture is at least one pixel wide and high, in whole pixels.');
22
+ if (width > limits.edge || height > limits.edge) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + limits.edge + ' pixels on a side.');
23
+ if (width * height > limits.pixels) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + limits.pixels.toLocaleString('en-US') + ' pixels.');
24
+ }
25
+
26
+ export function admitPixels(data, width, height, limits) {
27
+ admitSize(width, height, limits);
28
+ if (!(data instanceof Uint8Array) && !(data instanceof Uint8ClampedArray)) throw fault('JXL_INPUT', 'Pixels are a Uint8Array or Uint8ClampedArray of RGBA bytes.');
29
+ if (data.length !== width * height * 4) throw fault('JXL_INPUT', 'Pixels are width * height * 4 bytes: straight (not premultiplied) RGBA, row by row.');
30
+ }
31
+
32
+ export function admitOutputSize(length) {
33
+ if (length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
34
+ }
35
+
36
+ export function answer(bytes) {
37
+ admitOutputSize(bytes.length);
38
+ return bytes;
39
+ }
40
+
41
+ // A plain RangeError from inside the work is the engine out of memory; a coded error passes through as it is.
42
+ export function guard(work) {
43
+ try { return work(); }
44
+ catch (error) { if (error instanceof RangeError && !error.code) throw fault('JXL_MEMORY', 'Not enough memory for this picture.'); throw error; }
45
+ }
46
+
47
+ // A door's work as a job: iterate it for the fraction done, in (0, 1], the last exactly 1, and read `bytes` after the
48
+ // last step (the job also returns them). Leaving the loop is a cancel with nothing to release. `hurry` asks a search to
49
+ // finish with the best candidate it has priced; effort 1 searches nothing, so its bytes are the same either way.
50
+ export function job(steps) {
51
+ const it = (function* () {
52
+ let step, last;
53
+ while (!(step = guard(() => steps.next(it.hurry))).done) yield last = step.value;
54
+ // A candidate given up (the stream limit, or memory for the lossy attempt beside an exact stream) ends early.
55
+ if (last < 1) yield 1;
56
+ return it.bytes = answer(step.value);
57
+ })();
58
+ it.bytes = null; it.hurry = false;
59
+ return it;
60
+ }
package/ans.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Optional JPEG XL ANS coding. The core imports none of this module.
3
+ // The normative alias construction follows ISO/IEC 18181-1; libjxl 0.11.2's
4
+ // ans_common.cc and enc_ans.cc were consulted for the wire layout.
5
+ import {BitWriter, ceilLog2, floorLog2} from './bits.mjs';
6
+ import {hybridToken, writeContextMap, writeUintConfig} from './prefix.mjs';
7
+ import {buildTokenCoding} from './entropy.mjs';
8
+
9
+ const LOG_LENGTHS = [5, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 6, 7];
10
+ const LOG_CODES = [17, 11, 15, 3, 9, 7, 4, 2, 5, 6, 0, 33, 1];
11
+ // A VarDCT group has at most three 32x32-block planes. Each block emits one
12
+ // nonzero-count token and at most 63 AC tokens; alpha uses its separate modular stream.
13
+ const GROUP_TOKENS = 3 * 32 * 32 * 64;
14
+
15
+ function varUint8(w, value) {
16
+ if (!value) { w.write(1, 0); return; }
17
+ const n = floorLog2(value);
18
+ w.write(1, 1); w.write(3, n); w.write(n, value - (1 << n));
19
+ }
20
+
21
+ // Correct the largest rounding error until the table totals exactly 4096.
22
+ // Every represented symbol keeps a positive count, including rare symbols.
23
+ function normalise(freqs) {
24
+ let total = 0, last = 0;
25
+ for (let i = 0; i < freqs.length; i++) {
26
+ total += freqs[i];
27
+ if (freqs[i]) last = i + 1;
28
+ }
29
+ const counts = new Uint16Array(last || 1);
30
+ if (!total) { counts[0] = 4096; return counts; }
31
+ let sum = 0;
32
+ for (let i = 0; i < last; i++) if (freqs[i]) sum += counts[i] = Math.max(1, Math.round(freqs[i] * 4096 / total));
33
+ while (sum !== 4096) {
34
+ const direction = sum < 4096 ? 1 : -1;
35
+ let chosen = -1, error = -Infinity;
36
+ for (let i = 0; i < last; i++) if (freqs[i] && (direction > 0 || counts[i] > 1)) {
37
+ const candidate = direction * (freqs[i] * 4096 - counts[i] * total);
38
+ if (candidate > error) { chosen = i; error = candidate; }
39
+ }
40
+ counts[chosen] += direction; sum += direction;
41
+ }
42
+ return counts;
43
+ }
44
+
45
+ function writeDistribution(w, counts) {
46
+ const used = [];
47
+ let omit = 0;
48
+ for (let i = 0; i < counts.length; i++) {
49
+ if (counts[i]) used.push(i);
50
+ if (counts[i] > counts[omit]) omit = i;
51
+ }
52
+ if (used.length <= 2) {
53
+ w.write(1, 1); w.write(1, used.length - 1);
54
+ for (const symbol of used) varUint8(w, symbol);
55
+ if (used.length === 2) w.write(12, counts[used[0]]);
56
+ return;
57
+ }
58
+ w.write(1, 0); w.write(1, 0);
59
+ // Full count precision: shift 12, encoded by the bounded gamma form.
60
+ w.write(3, 7); w.write(3, 5);
61
+ varUint8(w, counts.length - 3);
62
+ const logs = new Uint8Array(counts.length);
63
+ let omitLog = 0;
64
+ for (let i = 0; i < counts.length; i++) if (i !== omit && counts[i]) {
65
+ logs[i] = floorLog2(counts[i]) + 1;
66
+ omitLog = Math.max(omitLog, logs[i] + (i < omit ? 1 : 0));
67
+ }
68
+ logs[omit] = omitLog;
69
+ for (const log of logs) w.write(LOG_LENGTHS[log], LOG_CODES[log]);
70
+ for (let i = 0; i < counts.length; i++) if (i !== omit && logs[i] > 1) {
71
+ const bits = logs[i] - 1;
72
+ w.write(bits, counts[i] - (1 << bits));
73
+ }
74
+ }
75
+
76
+ // Invert the decoder's alias table once per histogram. State remainders then
77
+ // use one indexed load, with no search or approximation in the symbol loop.
78
+ function reverseAlias(counts, logAlphabet) {
79
+ const size = 1 << logAlphabet, entry = 4096 >> logAlphabet;
80
+ const starts = new Uint16Array(counts.length + 1), reverse = new Uint16Array(4096);
81
+ let single = -1;
82
+ for (let i = 0; i < counts.length; i++) {
83
+ starts[i + 1] = starts[i] + counts[i];
84
+ if (counts[i] === 4096) single = i;
85
+ }
86
+ if (single >= 0) {
87
+ for (let i = 0; i < 4096; i++) reverse[i] = i;
88
+ return {starts, reverse};
89
+ }
90
+ const cutoffs = new Int32Array(size), aliases = new Uint16Array(size), offsets = new Int32Array(size);
91
+ const under = [], over = [];
92
+ cutoffs.set(counts);
93
+ for (let i = 0; i < size; i++) {
94
+ if (cutoffs[i] < entry) under.push(i);
95
+ else if (cutoffs[i] > entry) over.push(i);
96
+ }
97
+ while (over.length) {
98
+ const from = over.pop(), to = under.pop(), taken = entry - cutoffs[to];
99
+ cutoffs[from] -= taken;
100
+ aliases[to] = from; offsets[to] = cutoffs[from] - cutoffs[to];
101
+ if (cutoffs[from] < entry) under.push(from);
102
+ else if (cutoffs[from] > entry) over.push(from);
103
+ }
104
+ for (let i = 0; i < size; i++) for (let j = 0; j < entry; j++) {
105
+ const own = j < cutoffs[i], symbol = own ? i : aliases[i], offset = own ? j : j + offsets[i];
106
+ reverse[starts[symbol] + offset] = i * entry + j;
107
+ }
108
+ return {starts, reverse};
109
+ }
110
+
111
+ export function buildAnsCoding(counts, options) {
112
+ const prefix = buildTokenCoding(counts, options);
113
+ const contexts = prefix.histograms.map(() => []);
114
+ for (let ctx = 0; ctx < counts.contexts; ctx++) if (counts.totals[ctx]) contexts[prefix.contextMap[ctx]].push(ctx);
115
+ const histograms = prefix.histograms.map(({config}, i) => ({config, counts: normalise(counts.tokens(contexts[i], config, 256))}));
116
+ const logAlphabet = Math.max(5, ceilLog2(Math.max(...histograms.map(h => h.counts.length))));
117
+ for (const histogram of histograms) Object.assign(histogram, reverseAlias(histogram.counts, logAlphabet));
118
+ const header = new BitWriter();
119
+ header.write(1, 0);
120
+ if (prefix.contextMap.length > 1) writeContextMap(header, prefix.contextMap);
121
+ header.write(1, 0); header.write(2, logAlphabet - 5);
122
+ for (const histogram of histograms) writeUintConfig(header, histogram.config, logAlphabet);
123
+ for (const histogram of histograms) writeDistribution(header, histogram.counts);
124
+
125
+ // One group's storage is reused for every group; image dimensions never
126
+ // multiply this reverse-order workspace. No input or coefficient is mutated.
127
+ // The shared cluster builder caps histogram IDs at 48, so metadata fits one byte.
128
+ const values = new Uint32Array(GROUP_TOKENS), metadata = new Uint8Array(GROUP_TOKENS), emitted = new Uint16Array(GROUP_TOKENS);
129
+ const slots = [0, 0, 0];
130
+ let length = 0;
131
+ const write = (_w, ctx, value) => {
132
+ values[length] = value; metadata[length++] = prefix.contextMap[ctx];
133
+ };
134
+ const flush = w => {
135
+ let state = 0x13 * 65536;
136
+ for (let i = length - 1; i >= 0; i--) {
137
+ const h = histograms[metadata[i]];
138
+ hybridToken(h.config, values[i], slots);
139
+ const symbol = slots[0], freq = h.counts[symbol];
140
+ values[i] = slots[2]; metadata[i] = slots[1];
141
+ if ((state >>> 20) >= freq) {
142
+ emitted[i] = state & 65535; metadata[i] |= 64; state >>>= 16;
143
+ }
144
+ const q = Math.floor(state / freq);
145
+ state = q * 4096 + h.reverse[h.starts[symbol] + state - q * freq];
146
+ }
147
+ w.write(32, state);
148
+ for (let i = 0; i < length; i++) {
149
+ if (metadata[i] & 64) w.write(16, emitted[i]);
150
+ const bits = metadata[i] & 63;
151
+ if (bits) w.write(bits, values[i]);
152
+ }
153
+ length = 0;
154
+ };
155
+ // Preserve the original bucket/context choice to isolate prefix versus ANS.
156
+ return {bits: prefix.bits, write, flush, writeHistograms: w => w.append(header)};
157
+ }
package/bits.mjs CHANGED
@@ -1,13 +1,20 @@
1
1
  // Rapier's JPEG XL encoder: the bit writer, and the limits every part keeps. MIT (LICENSE).
2
2
  // JPEG XL packs bits least-significant first; `write` takes up to 32 bits at a time.
3
3
 
4
- // What one call takes at most: the 16 MiB codestream, 24 million pixels, 16,384 on a side. The checked API refuses a
5
- // larger ask before any work, the JPEG reader before it allocates a plane, and a writer that would grow past the
6
- // stream's bound stops there instead of filling memory first.
4
+ // What one call takes at most, door by door: the 16 MiB codestream, 16,384 pixels on a side, and as many pixels as the
5
+ // door's memory allows within what the core's lossy path needs at its 24 million (a measured peak of 15.7 bytes a
6
+ // pixel besides the input). The core and the effort door take 24 million; the photo door, 6.5 bytes a pixel at its
7
+ // limit, 40 million; the JPEG carrier, 6.4 bytes a pixel at 4:4:4 and 3.3 at 4:2:0, 64 million. The checked API
8
+ // refuses a larger ask before any work, the JPEG reader before it allocates a plane, and a writer that would grow past
9
+ // the stream's bound stops there instead of filling memory first.
7
10
  export const LIMITS = Object.freeze({bytes: 16 * 1024 * 1024, pixels: 24_000_000, edge: 16384});
11
+ export const PHOTO_LIMITS = /*#__PURE__*/ Object.freeze({bytes: 16 * 1024 * 1024, pixels: 40_000_000, edge: 16384});
12
+ export const JPEG_LIMITS = /*#__PURE__*/ Object.freeze({bytes: 16 * 1024 * 1024, pixels: 64_000_000, edge: 16384});
8
13
 
9
- // The hot writer reuses exact powers; unusual counts retain the range check's arithmetic.
10
- const POWERS = Array.from({length: 33}, (_, count) => 2 ** count);
14
+ // The hot writer's powers of two, made by doubling (`**` is not exact in every engine); a count outside 0 to 32 finds
15
+ // none, so its value is out of range.
16
+ const POWERS = [1];
17
+ for (let count = 1; count <= 32; count++) POWERS.push(POWERS[count - 1] * 2);
11
18
 
12
19
  export class BitWriter {
13
20
  constructor(capacity = 4096) {
@@ -17,7 +24,7 @@ export class BitWriter {
17
24
  this.pending = 0; // how many bits `acc` holds, always below 8 between calls
18
25
  }
19
26
  write(count, value) {
20
- if (count > 32 || value < 0 || value >= (POWERS[count] ?? 2 ** count)) throw new Error('bit write out of range: ' + count + ' bits, ' + value);
27
+ if (!(value >= 0 && value < POWERS[count])) throw new Error('bit write out of range: ' + count + ' bits, ' + value);
21
28
  let acc = this.acc + value * (1 << this.pending), pending = this.pending + count;
22
29
  if (this.at + 5 >= this.bytes.length) this.grow();
23
30
  const bytes = this.bytes;
@@ -28,7 +35,7 @@ export class BitWriter {
28
35
  writeU32(choices, value) {
29
36
  for (let selector = 0; selector < 4; selector++) {
30
37
  const [bits, offset] = choices[selector];
31
- if (value >= offset && value - offset < 2 ** bits) { this.write(2, selector); if (bits) this.write(bits, value - offset); return; }
38
+ if (value >= offset && value - offset < POWERS[bits]) { this.write(2, selector); if (bits) this.write(bits, value - offset); return; }
32
39
  }
33
40
  throw new Error('U32 value out of range: ' + value);
34
41
  }
@@ -59,6 +66,16 @@ export class BitWriter {
59
66
  }
60
67
  }
61
68
 
69
+ // The work as steps: a stepped writer yields the fraction of its work done, counted in whole steps so the last is
70
+ // exactly 1, receives whether its caller is in a hurry, and returns its bytes. `complete` runs one to the end; `part`
71
+ // makes a nested one's fractions the index-th of `count` equal shares of its caller's.
72
+ export function complete(steps) { let step; while (!(step = steps.next()).done); return step.value; }
73
+ export function* part(steps, index, count) {
74
+ let step, hurry;
75
+ while (!(step = steps.next(hurry)).done) hurry = yield (index + step.value) / count;
76
+ return step.value;
77
+ }
78
+
62
79
  // Residuals travel unsigned: 0, -1, 1, -2, 2 ... become 0, 1, 2, 3, 4 ...
63
80
  export function packSigned(value) { return value >= 0 ? value * 2 : -value * 2 - 1; }
64
81
 
@@ -71,9 +88,12 @@ export function float16Bits(value) {
71
88
  const sign = value < 0 ? 0x8000 : 0;
72
89
  value = Math.abs(value);
73
90
  if (!(value < 65520)) throw new Error('half float out of range: ' + value);
74
- let exponent = Math.floor(Math.log2(value));
75
- let mantissa = value / 2 ** exponent - 1;
76
- if (exponent < -14) { mantissa = value / 2 ** -14; exponent = -15; }
91
+ // The exponent by halving and doubling, both exact (Math.log2 rounds differently in each engine).
92
+ let exponent = 0, mantissa = value;
93
+ while (mantissa >= 2) { mantissa /= 2; exponent++; }
94
+ while (mantissa < 1) { mantissa *= 2; exponent--; }
95
+ if (exponent < -14) { mantissa = value * 16384; exponent = -15; }
96
+ else mantissa -= 1;
77
97
  let m = Math.round(mantissa * 1024);
78
98
  if (m === 1024) { m = 0; exponent++; }
79
99
  return sign | ((exponent + 15) << 10) | m;
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The optional doors add ANS at effort 2; earlier completed streams remain the
3
+ // answer on a tie, hurry, allocation failure or candidate above the size limit.
4
+ import {coefficientEffortSteps} from './coefficient-effort.mjs';
5
+ import {varDCTSteps} from './vardct.mjs';
6
+ import {part} from './bits.mjs';
7
+ import {buildAnsCoding} from './ans.mjs';
8
+
9
+ export function* coefficientAnsSteps(jpeg, effort, fallback) {
10
+ if (effort < 2) return yield* coefficientEffortSteps(jpeg, effort, fallback);
11
+ let best = yield* part(coefficientEffortSteps(jpeg, effort, fallback), 0, 2);
12
+ if (yield 0.5) return best;
13
+ try {
14
+ const candidate = varDCTSteps(jpeg, {coding: buildAnsCoding});
15
+ let step;
16
+ while (!(step = candidate.next()).done) {
17
+ // The final group has already been written at fraction 1. Let its bounded
18
+ // assembly finish before comparing; a hurry must not discard that work.
19
+ if ((yield 0.5 + step.value / 2) && step.value < 1) { candidate.return(); return best; }
20
+ }
21
+ if (step.value.length < best.length) best = step.value;
22
+ } catch (error) {
23
+ if (!(error instanceof RangeError && !error.code) && error.code !== 'JXL_SIZE') throw error;
24
+ }
25
+ return best;
26
+ }