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.
- package/AGENTS.md +10 -1
- package/ENCODER-COMPARISON.md +6 -6
- package/KERNELS.md +58 -0
- package/README.md +75 -18
- package/admit.mjs +5 -1
- package/ans.mjs +157 -0
- package/bits.mjs +5 -2
- package/build-jxl-kernels.mjs +29 -0
- package/coefficient-ans.mjs +26 -0
- package/coefficient-effort.mjs +62 -0
- package/effort-job.mjs +229 -0
- package/effort-level.mjs +10 -0
- package/effort.d.mts +4 -2
- package/effort.min.mjs +2 -2
- package/effort.mjs +7 -199
- package/frame.mjs +29 -1
- package/index.mjs +2 -2
- package/jfif.mjs +11 -0
- package/jpeg-ans.d.mts +3 -0
- package/jpeg-ans.min.mjs +2 -0
- package/jpeg-ans.mjs +9 -0
- package/jpeg-job.mjs +29 -0
- package/jpeg.d.mts +6 -2
- package/jpeg.min.mjs +2 -2
- package/jpeg.mjs +12 -36
- package/kernel-control.d.mts +3 -0
- package/kernel-hooks.mjs +3 -0
- package/kernels-bytes.mjs +4 -0
- package/kernels-probe.wat +1 -0
- package/kernels-scalar.wat +831 -0
- package/kernels-simd.wat +252 -0
- package/kernels.mjs +141 -0
- package/local.mjs +201 -0
- package/lossless-coding.mjs +30 -0
- package/lossless.mjs +88 -67
- package/modular.mjs +11 -7
- package/package.json +40 -6
- package/photo-ans.d.mts +3 -0
- package/photo-ans.min.mjs +2 -0
- package/photo-ans.mjs +9 -0
- package/photo-dct.mjs +83 -0
- package/photo-job.mjs +27 -0
- package/photo-quant.mjs +150 -0
- package/photo.d.mts +5 -1
- package/photo.min.mjs +2 -2
- package/photo.mjs +7 -101
- package/pool.mjs +136 -0
- package/rapier-jxl.min.mjs +2 -2
- package/rct-search.mjs +61 -0
- package/sizes.json +237 -56
- package/vardct.mjs +45 -8
- package/wasm.d.mts +4 -0
- package/wasm.min.mjs +2 -0
- package/wasm.mjs +6 -0
- 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.
|
package/ENCODER-COMPARISON.md
CHANGED
|
@@ -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.
|
|
9
|
-
| Rapier JXL effort door | 2.
|
|
10
|
-
| Rapier JXL JPEG door | 2.
|
|
11
|
-
| Rapier JXL photo door | 2.
|
|
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.
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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 `
|
|
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`,
|
|
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
|
|
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` |
|
|
101
|
-
| `effort.min.mjs`: `encode` with effort |
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
107
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
75
|
-
while (!(step = steps.next(
|
|
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
|
+
}
|