rapier-jxl 2.0.0 → 2.2.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.
Files changed (55) hide show
  1. package/AGENTS.md +10 -1
  2. package/ENCODER-COMPARISON.md +6 -6
  3. package/KERNELS.md +58 -0
  4. package/README.md +75 -18
  5. package/admit.mjs +5 -1
  6. package/ans.mjs +157 -0
  7. package/bits.mjs +5 -2
  8. package/build-jxl-kernels.mjs +29 -0
  9. package/coefficient-ans.mjs +26 -0
  10. package/coefficient-effort.mjs +62 -0
  11. package/effort-job.mjs +229 -0
  12. package/effort-level.mjs +10 -0
  13. package/effort.d.mts +4 -2
  14. package/effort.min.mjs +2 -2
  15. package/effort.mjs +7 -199
  16. package/frame.mjs +29 -1
  17. package/index.mjs +2 -2
  18. package/jfif.mjs +11 -0
  19. package/jpeg-ans.d.mts +3 -0
  20. package/jpeg-ans.min.mjs +2 -0
  21. package/jpeg-ans.mjs +9 -0
  22. package/jpeg-job.mjs +29 -0
  23. package/jpeg.d.mts +6 -2
  24. package/jpeg.min.mjs +2 -2
  25. package/jpeg.mjs +12 -36
  26. package/kernel-control.d.mts +3 -0
  27. package/kernel-hooks.mjs +3 -0
  28. package/kernels-bytes.mjs +4 -0
  29. package/kernels-probe.wat +1 -0
  30. package/kernels-scalar.wat +831 -0
  31. package/kernels-simd.wat +252 -0
  32. package/kernels.mjs +141 -0
  33. package/local.mjs +201 -0
  34. package/lossless-coding.mjs +30 -0
  35. package/lossless.mjs +88 -67
  36. package/modular.mjs +11 -7
  37. package/package.json +40 -6
  38. package/photo-ans.d.mts +3 -0
  39. package/photo-ans.min.mjs +2 -0
  40. package/photo-ans.mjs +9 -0
  41. package/photo-dct.mjs +83 -0
  42. package/photo-job.mjs +27 -0
  43. package/photo-quant.mjs +150 -0
  44. package/photo.d.mts +5 -1
  45. package/photo.min.mjs +2 -2
  46. package/photo.mjs +7 -101
  47. package/pool.mjs +136 -0
  48. package/rapier-jxl.min.mjs +2 -2
  49. package/rct-search.mjs +61 -0
  50. package/sizes.json +237 -56
  51. package/vardct.mjs +45 -8
  52. package/wasm.d.mts +4 -0
  53. package/wasm.min.mjs +2 -0
  54. package/wasm.mjs +6 -0
  55. package/weighted.mjs +22 -10
package/AGENTS.md CHANGED
@@ -11,7 +11,11 @@
11
11
  `encodePhoto(rgba, width, height, {quality: 90})`. The answer is a `Uint8Array` of codestream bytes: save it
12
12
  as `.jxl` or in a `Blob` of type `image/jxl`.
13
13
  4. A JPEG: `transcode(jpegBytes)` gives `{bytes, width, height, orientation}` without decoding. On `JXL_JPEG`,
14
- decode it and call `encodePhoto` on the pixels.
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.
15
19
  5. Anything larger than an icon runs in a worker. Calls are synchronous, so cancel by terminating the worker, or
16
20
  loop over the door's twin (`encodeSteps`, `transcodeSteps`, `encodePhotoSteps`) and leave the loop. A
17
21
  transferred buffer is gone from the sender: copy first if you may retry.
@@ -22,3 +26,8 @@
22
26
 
23
27
  Nothing here reads files, fetches or touches the DOM: it runs in a worker, Node or Deno alike. Publish the version
24
28
  you took and its licence; do not vendor a minified copy under another name.
29
+
30
+ For lossless acceleration with unchanged bytes, use `rapier-jxl/wasm` (or its single-file `wasm.min.mjs`)
31
+ in place of the effort door. It falls back to JavaScript when SIMD is missing or blocked. `KERNELS.md`
32
+ describes the 3 MiB private arena, controls and source build. Do not combine separate minified bundles
33
+ to share kernel controls: use the one-file accelerated door.
@@ -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 | 2.0.0 | 19,495 | 8,475 | 8-bit lossless RGBA, lossy modular with exact alpha |
9
- | Rapier JXL effort door | 2.0.0 | 25,229 | 10,606 | The core, and the weighted predictor searched for lossless |
10
- | Rapier JXL JPEG door | 2.0.0 | 29,592 | 12,533 | JPEG coefficients, orientation; no JPEG reconstruction |
11
- | Rapier JXL photo door | 2.0.0 | 25,646 | 10,804 | 8-bit photographic VarDCT, exact alpha; q100 lossless |
8
+ | Rapier JXL core | 2.2.0 | 21,326 | 9,271 | 8-bit lossless RGBA, lossy modular with exact alpha |
9
+ | Rapier JXL effort door | 2.2.0 | 33,584 | 13,793 | The core, and the weighted predictor searched for lossless |
10
+ | Rapier JXL JPEG door | 2.2.0 | 32,354 | 13,505 | JPEG coefficients, orientation; no JPEG reconstruction |
11
+ | Rapier JXL photo door | 2.2.0 | 33,011 | 13,616 | 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 |
@@ -117,7 +117,7 @@ and paint-0002 at q80 and q90.
117
117
 
118
118
  `transcode` against `cjxl --lossless_jpeg=1`. The contracts differ: Rapier carries the coefficients and the
119
119
  orientation and cannot rebuild the JPEG file; native also stores the reconstruction data and rebuilt every
120
- original exactly. So a smaller Rapier row is not a like-for-like win. Six inputs are tiny conformance fixtures;
120
+ accepted original exactly. So a smaller Rapier row is not a like-for-like win. Seven inputs are tiny conformance fixtures;
121
121
  Grace is the one photograph.
122
122
 
123
123
  | JPEG input | Original bytes | Rapier carrier | Native reversible | JPEG rebuilt by native |
@@ -154,7 +154,7 @@ bundled Geist fonts and pinned `@napi-rs/canvas` 0.1.100. Grace is the public-do
154
154
 
155
155
  ```sh
156
156
  cjxl INPUT.pam OUTPUT.jxl --distance=D --effort=7 --num_threads=0 \
157
- --alpha_distance=0 --keep_invisible=1 --premultiply=0 --container=0 \
157
+ --alpha_distance=0 --resampling=1 --ec_resampling=1 --keep_invisible=1 --premultiply=0 --container=0 \
158
158
  -x color_space=RGB_D65_SRG_Per_SRG --quiet
159
159
  djxl INPUT.jxl OUTPUT.pam --bits_per_sample=8 --color_space=RGB_D65_SRG_Per_SRG --num_threads=0 --quiet
160
160
  butteraugli_main REFERENCE.ppm DECODED.ppm --intensity_target 80
package/KERNELS.md ADDED
@@ -0,0 +1,58 @@
1
+ # Optional integer kernels
2
+
3
+ The small `index.mjs` and `effort.mjs` doors remain JavaScript. To opt in, import
4
+ `encode` / `encodeSteps` from `rapier-jxl/wasm` instead of `rapier-jxl/effort`.
5
+ This door keeps the effort API, automatically tries SIMD, and falls back to the
6
+ reference JavaScript if WebAssembly or SIMD is absent or blocked by policy.
7
+ It changes neither lossless bytes nor the hurry rule. Rapier's full image worker
8
+ opts in automatically; the document-only worker still ships no encoder.
9
+
10
+ The public repository also builds **`wasm.min.mjs`**, one self-contained module.
11
+ The package marks these two entry modules as side-effectful so bundlers retain
12
+ their automatic backend configuration.
13
+ Do not combine `kernels.min.mjs` with a separately bundled core: each bundle would
14
+ own different hooks. There is deliberately no separate minified control door.
15
+ Readable modules can share controls through `rapier-jxl/kernels`:
16
+
17
+ ```js
18
+ import {encode, configureKernels, kernelMode} from 'rapier-jxl/wasm';
19
+ configureKernels('auto'); // 'off', 'scalar', or 'simd' are explicit alternatives
20
+ const bytes = encode(rgba, width, height, {effort: 3, quality: 100});
21
+ console.log(kernelMode()); // the actual backend, not merely the requested one
22
+ ```
23
+
24
+ Configure once before an encode, not inside its progress callback. Controls are
25
+ per JavaScript realm; workers have independent arenas. A second argument selects
26
+ kernels for deterministic benchmarks, for example `{channel: true, weighted:
27
+ false, fill: false}`. This only changes where arithmetic runs, never its result.
28
+
29
+ ## What ships
30
+
31
+ `kernels-scalar.wat` implements prediction, weighted prediction and property
32
+ extraction, token histograms and a bulk bit writer. `kernels-simd.wat` implements
33
+ four-pixel average/gradient prediction and RGBA-to-planar conversion; rows and
34
+ odd tails take an explicit scalar path. Weighted prediction stays scalar: its
35
+ west-error state is sequential. Both modules use bounded integer arithmetic,
36
+ including signed i64 floor division for the weighted average. No relaxed SIMD,
37
+ floating-point WASM, imports other than private memory, network access, shared
38
+ memory, threads, or runtime dependencies are used.
39
+
40
+ The private arena is 3 MiB per enabled realm, reused synchronously. No views escape.
41
+ Groups are at most 65,536 samples; Int16 planes, unit multipliers and bounded
42
+ offsets are admitted. Other planes and custom writers keep the JavaScript path.
43
+ Non-little-endian hosts keep JavaScript as well. The existing input and output
44
+ limits remain in force. SIMD detection calls `WebAssembly.validate` on the tiny
45
+ `kernels-probe.wat` module; failure does not disable the encoder.
46
+
47
+ ## Rebuild
48
+
49
+ The checked-in `kernels-bytes.mjs` contains generated base64 bytes, not source to
50
+ edit. In the Rapier tree run `node repo/images/build-jxl-kernels.mjs`; in a staged
51
+ public repository run `node build-jxl-kernels.mjs`. Install **wabt 1.0.37** only as
52
+ a build tool. `WABT_MODULE` may point to its `index.js` outside the source tree.
53
+ Add `--check` to reject a generated file that differs. No compiler ships in the
54
+ inline encoder. Source and build script are retained in the public package.
55
+
56
+ The JX/3 handoff records the bootstrap compiler used in its network-restricted
57
+ sandbox and the outstanding independent wabt rebuild. Do not describe that
58
+ handoff as a wabt-certified build until `--check` has been run with wabt.
package/README.md CHANGED
@@ -1,20 +1,43 @@
1
1
  # Rapier JXL
2
2
 
3
- A JPEG XL encoder in pure JavaScript: one file, 19.5 kB, 8.5 kB gzipped, no WebAssembly, no
4
- dependencies, MIT. The encoder inside [Rapier](https://rapier.website), published on its own. The smallest
5
- JavaScript or WebAssembly JPEG XL encoder among the payloads [we measured](ENCODER-COMPARISON.md).
3
+ A JPEG XL encoder in pure JavaScript, for writing `.jxl` from canvas pixels or a JPEG in a browser, a worker, Node
4
+ or Deno. The core needs no WebAssembly; an optional SIMD door accelerates the same arithmetic. No server.
5
+
6
+ - The core is one file of 21.3 kB, 9.3 kB gzipped: the smallest JavaScript or WebAssembly JPEG XL
7
+ encoder among the payloads [we measured](ENCODER-COMPARISON.md).
8
+ - Lossless and lossy in one `encode` call; alpha stays exact at every quality.
9
+ - Photographs, JPEGs carried without decoding, and smaller exact files at more time are optional doors, added
10
+ only when imported.
11
+ - The same input writes the same bytes in every JavaScript engine; quality is measured against libjxl 0.12.0.
12
+ - No dependencies, MIT. An agent adding it to an app reads `AGENTS.md`.
13
+
14
+ ```sh
15
+ npm install rapier-jxl
16
+ ```
17
+
18
+ The encoder inside [Rapier](https://rapier.website), published on its own.
19
+
20
+ ## What it does
6
21
 
7
22
  - **Lossless.** Every pixel back as it went in: 8-bit grey, grey with alpha, RGB, RGBA.
8
23
  - **Lossy**, quality 1 to 99, for flat-colour rasters (screenshots, pixel art, scanned line art). Alpha stays exact.
9
24
  A picture of few colours is written exact when that is smaller.
10
- - **Smaller exact pictures, slower**: `rapier-jxl/effort`, the same `encode` with `{effort: 2}` or `3`, each never
25
+ - **Smaller exact pictures, slower**: `rapier-jxl/effort`, the same `encode` with `{effort: 2}`, `3`, `4` or `6`, each never
11
26
  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.
27
+ - **Photographs**: `rapier-jxl/photo`, DCT8 compression with exact alpha, a door of its own. Effort 5 searches
28
+ quantisation per block and keeps a candidate only when its complete stream is smaller.
13
29
  - **A JPEG carried as its coefficients**: `rapier-jxl/jpeg`, one call, no decode, the way libjxl transcodes. Not
14
30
  carried: the reconstruction data (the JPEG file cannot be rebuilt), the ICC bytes, Exif beyond the orientation, XMP.
31
+ - **Optional ANS entropy coding**: import `transcode` from `rapier-jxl/jpeg-ans` or `encodePhoto` from
32
+ `rapier-jxl/photo-ans` and pass `{effort: 2}`. Each tries ANS after writing the prefix-coded floor and keeps
33
+ the smaller complete stream, with identical reconstructed pixels. These imports leave the core unchanged.
15
34
  - **sRGB or Display P3.** `{colorSpace: 'display-p3'}` declares a wide-gamut canvas's samples. A JPEG's profile is
16
35
  read by what it does, not what it says: sRGB and Display P3 are carried and declared.
17
36
 
37
+ For the accelerated effort door, use `rapier-jxl/wasm` or the self-contained `wasm.min.mjs`.
38
+ It falls back to JavaScript when SIMD is unavailable and writes identical lossless bytes.
39
+ See [KERNELS.md](KERNELS.md) for controls, memory, source and rebuild instructions.
40
+
18
41
  ## Use it
19
42
 
20
43
  ```js
@@ -35,12 +58,19 @@ const {bytes, width: w, height: h, orientation} = transcode(new Uint8Array(await
35
58
  holding a bare JPEG XL codestream; `encodePhoto` takes the same. `transcode(jpeg)` returns
36
59
  `{bytes, width, height, orientation}`: the size as shown, the orientation kept in the header.
37
60
 
38
- Doors: `rapier-jxl` (the core), `rapier-jxl/effort`, `rapier-jxl/jpeg`, `rapier-jxl/photo`, each readable, so a
61
+ Doors: `rapier-jxl` (the core), `rapier-jxl/effort`, `rapier-jxl/jpeg`, `rapier-jxl/photo`, and the two optional
62
+ ANS doors above, each readable, so a
39
63
  bundler carries their shared modules once; `rapier-jxl/min` is the core as one minified file. `rapier-jxl/writer`
40
64
  gives a module's author the layers beneath the doors (readable only; they change only with the major version).
41
65
  TypeScript declarations sit beside each door, and a worker and a page are under `public/examples/`. Quality numbers
42
66
  are not the same fidelity across encoders or pictures, and lossy is not always smaller than lossless.
43
67
 
68
+ The photo door defaults to effort 1. Its higher efforts keep the preceding stream as a candidate; effort 5 also
69
+ bounds effort 1's unclipped RGB sample reconstruction error on edge-extended DCT blocks, before clipping and integer output.
70
+ Decoded integer RGB error can differ from that model. Quality 100 remains exact at every effort.
71
+ The quantisation search keeps the preceding stream when its estimated memory would exceed the photo door's
72
+ working budget; the door's 40-million-pixel admission stays the same.
73
+
44
74
  ### In a worker
45
75
 
46
76
  Encoding is synchronous. Run it off the main thread:
@@ -63,9 +93,30 @@ Each door has a twin that does the same work in steps: `encodeSteps`, `transcode
63
93
  job, `for (const done of job)` runs one group of one pass per step (`done` is the fraction, the last exactly 1), and
64
94
  `job.bytes` is the stream after the loop, the same bytes the door writes. Leaving the loop cancels, so a worker can
65
95
  take messages and report progress between steps (`public/examples/worker.mjs`). `job.hurry = true` (the example's
66
- `deadline` sets it) ends the effort door's search at its next step with the smallest stream written so far, never
96
+ `deadline` sets it) ends a door's search at its next step with the smallest stream written so far, never
67
97
  larger than effort 1's.
68
98
 
99
+ The JPEG and photograph doors also read effort: 3 tries a 32-cluster budget, and 4 also tries an order learned
100
+ from coefficient counts. These entropy rungs keep the smaller complete stream and preserve every reconstructed pixel. Their
101
+ default remains 1; effort 2 keeps the default plan. A hurried JPEG job finishes its effort-1 floor in its first
102
+ half; through effort 4 the photo job first makes coefficients, then writes that floor, and searches in its final
103
+ quarter. At effort 5, its first half finishes the preceding stream and its second half searches quantisation,
104
+ keeping the completed floor on a hurry.
105
+
106
+ The optional ANS doors keep default effort 1's prefix bytes. Effort 2 adds one ANS candidate; efforts 3 and 4
107
+ also retain the ordinary prefix searches, and photo effort 5 retains its quantisation search. ANS uses a bounded
108
+ group buffer of 1,376,256 bytes plus histogram tables; encoding both candidates costs more CPU and may raise peak
109
+ RSS. Hurry keeps a completed candidate even when it arrives at the final group's yield.
110
+
111
+ Version 2.2.0 adds the `kernels` and `wasm` doors (integer WebAssembly under the lossless hot loops, JavaScript
112
+ fallback), a worker pool for large exact pictures, exact hybrid-integer codes, and a colour-transform search at effort 4.
113
+ Exact pictures stay lossless; 27 of the 154 test streams change bytes, none larger.
114
+
115
+ Version 2.1.0 fits the photo door's existing quantisation constants across thumbnail and source-sized photos.
116
+ Lossy photo bytes change deliberately; lossless and ordinary JPEG streams retain their hashes. Pin a package
117
+ version when exact lossy output bytes matter. Quality numbers remain a setting, not a guarantee of equal
118
+ perceptual quality on every image.
119
+
69
120
  ### Limits and errors
70
121
 
71
122
  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
@@ -97,23 +148,29 @@ New: `rapier-jxl/effort`, each door's twin in steps with `hurry`, `colorSpace: '
97
148
 
98
149
  | file | bytes | gzip | Brotli | added to the core, gzip |
99
150
  | --- | ---: | ---: | ---: | ---: |
100
- | `rapier-jxl.min.mjs`, the core: `encode` | 19,495 | 8,475 | 7,494 | |
101
- | `effort.min.mjs`: `encode` with effort | 25,229 | 10,606 | 9,351 | 2,131 |
102
- | `jpeg.min.mjs`: `transcode` | 29,592 | 12,533 | 11,072 | 7,673 |
103
- | `photo.min.mjs`: `encodePhoto` | 25,646 | 10,804 | 9,537 | 4,242 |
104
- | every door in one bundle | 47,499 | 19,228 | 16,977 | |
105
- | all readable modules | 136,580 | 42,707 | | |
106
-
107
- Exact bytes of release 2.0.0's files, measured by the script that stages this repository; `sizes.json`
151
+ | `rapier-jxl.min.mjs`, the core: `encode` | 21,326 | 9,271 | 8,233 | |
152
+ | `effort.min.mjs`: `encode` with effort | 33,584 | 13,793 | 12,173 | 4,522 |
153
+ | `wasm.min.mjs`: accelerated effort, JS fallback | 43,535 | 18,533 | 16,239 | 9,262 |
154
+ | `jpeg.min.mjs`: `transcode` | 32,354 | 13,505 | 11,946 | 8,524 |
155
+ | `photo.min.mjs`: `encodePhoto` | 33,011 | 13,616 | 12,048 | 6,249 |
156
+ | `jpeg-ans.min.mjs`: optional ANS carrier | 35,491 | 14,608 | 12,892 | 9,580 |
157
+ | `photo-ans.min.mjs`: optional ANS photo | 35,994 | 14,650 | 12,975 | 7,315 |
158
+ | every door in one bundle | 74,625 | 30,285 | 26,433 | |
159
+ | all readable modules | 211,215 | 67,195 | | |
160
+
161
+ Exact bytes of release 2.2.0's files, measured by the script that stages this repository; `sizes.json`
108
162
  carries their hashes and tools (terser 5.51.2, Node v22.22.2; gzip 9, Brotli 11). Each minified file stands alone
109
163
  and is proved at staging to write the same bytes as its readable source; the last column is what a door adds to a
110
164
  bundle that already holds the core. `effort`'s `encode` is the core's at effort 1, so it takes the core's place, in
111
165
  that column and in the bundle of every door.
112
166
 
113
- What it writes: bare codestreams, 8-bit, prefix codes (never ANS), one frame, no preview, animation, ICC (sRGB or
167
+ What it writes: bare codestreams, 8-bit, prefix codes (or ANS in the optional doors), one frame, no preview, animation, ICC (sRGB or
114
168
  Display P3 is declared), XYB, chroma-from-luma or filters. Lossless in modular mode, a palette of up to 2,048
115
169
  colours weighed against direct coding by actual length, groups of 256, reversible YCoCg, a predictor chosen per
116
- channel; at effort 2 and 3 also the weighted predictor, its contexts split by its own error. Lossy through Squeeze
170
+ channel and each channel's integer code (one of four hybrid configurations) chosen by exact cost; at effort 2 and 3
171
+ also the weighted predictor, its contexts split by its own error; at 4 local modelling of palette indices and a
172
+ sampled search of the 42 reversible colour transforms, kept only when its complete stream is smaller; at 6 local
173
+ gradient-property splits. Effort 5 uses rung 4. Lossy through Squeeze
117
174
  with exact alpha. Carried JPEGs in VarDCT with the JPEG's own tables. The photo door
118
175
  writes DCT8 coefficients for the same writer.
119
176
 
@@ -131,7 +188,7 @@ headers, `npm run fuzz:scale -- --out fuzz-run --workers 4` repeats the fixed bu
131
188
 
132
189
  ## Why
133
190
 
134
- Rapier keeps pictures inside Markdown, and its standard says a raster picture in Markdown is JPEG XL: exact where
191
+ Rapier keeps pictures inside Markdown, and its standard defaults to JPEG XL for raster pictures in Markdown: exact where
135
192
  it must be, small where it may be. Drawings stay SVG. An editor carrying an encoder offline in a small page needed
136
193
  one this size, and none existed. The standard is at [rapier.website](https://rapier.website); for an agent, see
137
194
  `AGENTS.md`.
package/admit.mjs CHANGED
@@ -29,8 +29,12 @@ export function admitPixels(data, width, height, limits) {
29
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
30
  }
31
31
 
32
+ export function admitOutputSize(length) {
33
+ if (length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
34
+ }
35
+
32
36
  export function answer(bytes) {
33
- if (bytes.length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
37
+ admitOutputSize(bytes.length);
34
38
  return bytes;
35
39
  }
36
40
 
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
@@ -71,10 +71,13 @@ export class BitWriter {
71
71
  // makes a nested one's fractions the index-th of `count` equal shares of its caller's.
72
72
  export function complete(steps) { let step; while (!(step = steps.next()).done); return step.value; }
73
73
  export function* part(steps, index, count) {
74
- let step, hurry;
75
- while (!(step = steps.next(hurry)).done) hurry = yield (index + step.value) / count;
74
+ let step, reply;
75
+ while (!(step = steps.next(reply)).done) reply = yield scaled(step.value, done => (index + done) / count);
76
76
  return step.value;
77
77
  }
78
+ // A step's fraction mapped by `f`. A pass handed to a pool (frame.mjs, groupPass) is one step carrying its fractions as
79
+ // `at`, mapped alike; its reply passes back unchanged.
80
+ export const scaled = (value, f) => typeof value === 'number' ? f(value) : {...value, at: g => f(value.at(g))};
78
81
 
79
82
  // Residuals travel unsigned: 0, -1, 1, -2, 2 ... become 0, 1, 2, 3, 4 ...
80
83
  export function packSigned(value) { return value >= 0 ? value * 2 : -value * 2 - 1; }
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Build-time only. npm install --no-save wabt@1.0.37 (or set WABT_MODULE to its index.js).
3
+ // node repo/images/build-jxl-kernels.mjs [--check]
4
+ import {readFile, writeFile} from 'node:fs/promises';
5
+ import {fileURLToPath, pathToFileURL} from 'node:url';
6
+ import {resolve} from 'node:path';
7
+ import {existsSync} from 'node:fs';
8
+ const directory = new URL(existsSync(new URL('jxl/kernels-scalar.wat', import.meta.url)) ? 'jxl/' : './', import.meta.url);
9
+ const specifier = process.env.WABT_MODULE ? pathToFileURL(resolve(process.env.WABT_MODULE)).href : 'wabt';
10
+ let factory;
11
+ try { ({default: factory} = await import(specifier)); }
12
+ catch (cause) { throw new Error('The kernel build needs build-time wabt@1.0.37. Set WABT_MODULE to its index.js when installed outside this tree.', {cause}); }
13
+ const wabt = await factory();
14
+ let text = '// Generated from kernels-*.wat. MIT. Do not edit.\n';
15
+ for (const [name, symbol] of [['scalar','SCALAR'], ['simd','SIMD'], ['probe','SIMD_PROBE']]) {
16
+ const source = new URL(`kernels-${name}.wat`, directory);
17
+ const wat = wabt.parseWat(fileURLToPath(source), await readFile(source, 'utf8'), {simd: true});
18
+ try {
19
+ wat.resolveNames(); wat.validate({simd: true});
20
+ const {buffer} = wat.toBinary({log: false, canonicalize_lebs: true, write_debug_names: false});
21
+ if (!WebAssembly.validate(buffer)) throw new Error(`Invalid ${name} module`);
22
+ text += `export const ${symbol} = ${JSON.stringify(Buffer.from(buffer).toString('base64'))};\n`;
23
+ console.log(`${name}: ${buffer.length} WebAssembly bytes`);
24
+ } finally { wat.destroy(); }
25
+ }
26
+ const target = new URL('kernels-bytes.mjs', directory);
27
+ if (process.argv.includes('--check')) {
28
+ if (text !== await readFile(target, 'utf8')) throw new Error('kernels-bytes.mjs differs from the WAT build');
29
+ } else await writeFile(target, text);
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Entropy search shared by the JPEG and photograph doors; coefficients and quantisation never change here.
3
+ import {varDCTSteps} from './vardct.mjs';
4
+ import {ZIGZAG} from './jfif.mjs';
5
+ import {complete} from './bits.mjs';
6
+
7
+ // Frequently nonzero positions first, counted in buckets of eight blocks. Ties keep the natural transposed
8
+ // zigzag, and DC stays first. Integer counts and an explicit tie rule make the permutation deterministic.
9
+ export function coefficientOrders(components) { return complete(coefficientOrderSteps(components)); }
10
+
11
+ export function* coefficientOrderSteps(components) {
12
+ const scan = ZIGZAG.map(n => ((n & 7) << 3) | (n >> 3));
13
+ const histograms = components.map(() => new Uint32Array(64)), total = components.reduce((n, c) => n + c.coeffs.length, 0);
14
+ let done = 0;
15
+ for (let c = 0; c < components.length; c++) {
16
+ const {coeffs} = components[c], counts = histograms[c];
17
+ // At most one 256-pixel group's coefficient count between yields, including JPEG padding blocks.
18
+ for (let from = 0; from < coeffs.length; from += 65536) {
19
+ const end = Math.min(from + 65536, coeffs.length);
20
+ for (let at = from; at < end; at += 64) for (let k = 1; k < 64; k++) if (coeffs[at + scan[k]]) counts[k]++;
21
+ done += end - from;
22
+ if (yield done / total) return null;
23
+ }
24
+ }
25
+ return histograms.map(counts => [0, ...Array.from({length: 63}, (_, k) => k + 1).sort((a, b) => Math.floor(counts[b] / 8) - Math.floor(counts[a] / 8) || a - b)]);
26
+ }
27
+
28
+ // Rung 3 tries a 32-cluster budget at cost 160; rung 4 also tries one learned order. Complete earlier streams
29
+ // remain available on a tie, hurry, memory failure or candidate above the stream limit. No input is mutated.
30
+ // A supplied fallback is already written; it also makes the first writer interruptible for a lossy candidate.
31
+ export function* coefficientEffortSteps(jpeg, effort, fallback) {
32
+ if (effort < 3 && !fallback) return yield* varDCTSteps(jpeg);
33
+ let best = fallback;
34
+ try {
35
+ const first = varDCTSteps(jpeg); let step, hurry;
36
+ while (!(step = first.next()).done) {
37
+ hurry = yield step.value / 2;
38
+ if (hurry && best && step.value < 1) { first.return(); return best; }
39
+ }
40
+ if (!best || step.value.length < best.length) best = step.value;
41
+ if (hurry || effort < 3) return best;
42
+ const parts = effort < 4 ? 1 : 3, clustered = varDCTSteps(jpeg, {clusters: {maxClusters: 32, newClusterCost: 160}});
43
+ while (!(step = clustered.next()).done) {
44
+ hurry = yield 0.5 + step.value / (2 * parts);
45
+ // The final group has already been written. Finish its stream before honoring a late hurry.
46
+ if (hurry && step.value < 1) { clustered.return(); return best; }
47
+ }
48
+ if (step.value.length < best.length) best = step.value;
49
+ if (hurry || effort < 4) return best;
50
+ const learning = coefficientOrderSteps(jpeg.components);
51
+ while (!(step = learning.next()).done) if (yield 0.5 + (1 + step.value) / 6) { learning.return(); return best; }
52
+ const candidate = varDCTSteps(jpeg, {orders: step.value});
53
+ while (!(step = candidate.next()).done) {
54
+ hurry = yield 0.5 + (2 + step.value) / 6;
55
+ if (hurry && step.value < 1) { candidate.return(); return best; }
56
+ }
57
+ return step.value.length < best.length ? step.value : best;
58
+ } catch (error) {
59
+ if (!best || !(error instanceof RangeError && !error.code) && error.code !== 'JXL_SIZE') throw error;
60
+ return best;
61
+ }
62
+ }