rapier-jxl 2.1.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 CHANGED
@@ -26,3 +26,8 @@
26
26
 
27
27
  Nothing here reads files, fetches or touches the DOM: it runs in a worker, Node or Deno alike. Publish the version
28
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.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 |
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,8 +1,23 @@
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.
@@ -19,6 +34,10 @@ JavaScript or WebAssembly JPEG XL encoder among the payloads [we measured](ENCOD
19
34
  - **sRGB or Display P3.** `{colorSpace: 'display-p3'}` declares a wide-gamut canvas's samples. A JPEG's profile is
20
35
  read by what it does, not what it says: sRGB and Display P3 are carried and declared.
21
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
+
22
41
  ## Use it
23
42
 
24
43
  ```js
@@ -89,6 +108,10 @@ also retain the ordinary prefix searches, and photo effort 5 retains its quantis
89
108
  group buffer of 1,376,256 bytes plus histogram tables; encoding both candidates costs more CPU and may raise peak
90
109
  RSS. Hurry keeps a completed candidate even when it arrives at the final group's yield.
91
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
+
92
115
  Version 2.1.0 fits the photo door's existing quantisation constants across thumbnail and source-sized photos.
93
116
  Lossy photo bytes change deliberately; lossless and ordinary JPEG streams retain their hashes. Pin a package
94
117
  version when exact lossy output bytes matter. Quality numbers remain a setting, not a guarantee of equal
@@ -125,16 +148,17 @@ New: `rapier-jxl/effort`, each door's twin in steps with `hurry`, `colorSpace: '
125
148
 
126
149
  | file | bytes | gzip | Brotli | added to the core, gzip |
127
150
  | --- | ---: | ---: | ---: | ---: |
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`
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`
138
162
  carries their hashes and tools (terser 5.51.2, Node v22.22.2; gzip 9, Brotli 11). Each minified file stands alone
139
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
140
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
@@ -143,8 +167,10 @@ that column and in the bundle of every door.
143
167
  What it writes: bare codestreams, 8-bit, prefix codes (or ANS in the optional doors), one frame, no preview, animation, ICC (sRGB or
144
168
  Display P3 is declared), XYB, chroma-from-luma or filters. Lossless in modular mode, a palette of up to 2,048
145
169
  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
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
148
174
  with exact alpha. Carried JPEGs in VarDCT with the JPEG's own tables. The photo door
149
175
  writes DCT8 coefficients for the same writer.
150
176
 
@@ -162,7 +188,7 @@ headers, `npm run fuzz:scale -- --out fuzz-run --workers 4` repeats the fixed bu
162
188
 
163
189
  ## Why
164
190
 
165
- 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
166
192
  it must be, small where it may be. Drawings stay SVG. An editor carrying an encoder offline in a small page needed
167
193
  one this size, and none existed. The standard is at [rapier.website](https://rapier.website); for an agent, see
168
194
  `AGENTS.md`.
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);
package/effort-job.mjs ADDED
@@ -0,0 +1,229 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The effort door's work (effort.mjs): effort 1's stream, the rungs above it, and the group work of the weighted search
3
+ // that a pool of workers shares (pool.mjs). Every choice is integer arithmetic: the same input and options write the
4
+ // same bytes everywhere, on one thread or many.
5
+ import {BitWriter, part, scaled} from './bits.mjs';
6
+ import {writeImageHeader, writeModularFrameHeader, groupLayout, groupRect, groupPass, addCounts, assembleCodestream, GROUP_DIM} from './frame.mjs';
7
+ import {buildCode, writePrefixCode} from './prefix.mjs';
8
+ import {AVERAGE_PREDICTOR, GRADIENT_PREDICTOR, ALPHABET, leaf, split, channelTree, writeTree, writeModularHeader, writeChannelHistograms, codeChannel} from './modular.mjs';
9
+ import {inspectPixels, losslessSteps, planeFill} from './lossless.mjs';
10
+ import {lossySteps} from './lossy.mjs';
11
+ import {WEIGHTED_PREDICTOR, WEIGHTED_PROPERTY, WEIGHTED_CUTS, codeWeighted} from './weighted.mjs';
12
+ import {fault, admitOptions, admitPixels} from './admit.mjs';
13
+ import {localSteps} from './local.mjs';
14
+ import {rctSearchSteps} from './rct-search.mjs';
15
+
16
+ // The hurry in a step's reply: the caller's flag, or for a pooled pass a reply of null (ended) or {hurried}.
17
+ const hurryOf = reply => reply === null || (typeof reply === 'object' ? reply.hurried : reply);
18
+
19
+ // The door's options as every door reads them, and the effort, which this door alone reads (the core, one rung, does
20
+ // not): a whole number from 1 to 9, 1 when absent. Admitted at once; the steps follow. With `pool` (pool.mjs drives
21
+ // them), an exact picture of more than one group yields each pass over its groups as one request: the same bytes.
22
+ export function effortJob(data, width, height, options, pool) {
23
+ const {quality, colorSpace} = admitOptions(options), effort = options?.effort === undefined ? 1 : options.effort;
24
+ if (!Number.isInteger(effort) || effort < 1 || effort > 9) throw fault('JXL_INPUT', 'Effort is a whole number from 1 to 9.');
25
+ admitPixels(data, width, height);
26
+ return effortSteps(data, width, height, quality, colorSpace, effort, pool && quality >= 100 && !groupLayout(width, height).single);
27
+ }
28
+
29
+ // Effort 1 is the core's work (index.mjs), step for step; the rungs run after it and keep a smaller stream if they
30
+ // find one. A search that runs out of memory leaves the smallest completed stream standing.
31
+ function* effortSteps(data, width, height, quality, colorSpace, effort, pooled) {
32
+ const shape = inspectPixels(data, width, height);
33
+ if (quality < 100) {
34
+ if (!shape.palette) return yield* lossySteps(data, width, height, {quality, shape, colorSpace});
35
+ const exact = yield* part(losslessSteps(data, width, height, {shape, colorSpace}), 0, 2);
36
+ let bytes;
37
+ try { bytes = yield* part(lossySteps(data, width, height, {quality, shape, colorSpace}), 1, 2); }
38
+ catch (error) { if (error.code !== 'JXL_SIZE' && (!(error instanceof RangeError) || error.code)) throw error; bytes = exact; }
39
+ return exact.length <= bytes.length ? exact : bytes;
40
+ }
41
+ if (effort < 2) return yield* losslessSteps(data, width, height, {shape, colorSpace, pooled});
42
+ let best = yield* part(losslessSteps(data, width, height, {shape, colorSpace, pooled}), 0, 2);
43
+ const direct = shape.palette ? {...shape, palette: null} : shape;
44
+ if (effort >= 4) {
45
+ const searches = [searchSteps(data, width, height, direct, colorSpace, 3, pooled)];
46
+ if (shape.palette) searches.push(localSteps(data, width, height, shape, colorSpace, 4, true, pooled));
47
+ if (effort >= 6) {
48
+ searches.push(localSteps(data, width, height, shape, colorSpace, 6, false, pooled));
49
+ if (shape.palette) searches.push(localSteps(data, width, height, shape, colorSpace, 6, true, pooled));
50
+ }
51
+ searches.push(rctSearchSteps(data, width, height, shape, colorSpace, pooled));
52
+ for (let i = 0; i < searches.length; i++) {
53
+ try {
54
+ let step, reply, hurried = false;
55
+ while (!(step = searches[i].next(reply)).done) hurried = hurryOf(reply = yield scaled(step.value, done => 0.5 + (i + done) / (2 * searches.length)));
56
+ if (step.value && step.value.length < best.length) best = step.value;
57
+ if (hurried) return best;
58
+ } catch (error) { if (error.code !== 'JXL_SIZE' && (!(error instanceof RangeError) || error.code)) throw error; }
59
+ }
60
+ return best;
61
+ }
62
+ try {
63
+ const bytes = yield* part(searchSteps(data, width, height, direct, colorSpace, effort, pooled), 1, 2);
64
+ if (bytes && bytes.length < best.length) best = bytes;
65
+ } catch (error) { if (error.code !== 'JXL_SIZE' && (!(error instanceof RangeError) || error.code)) throw error; }
66
+ return best;
67
+ }
68
+
69
+ // A token's raw bits: a residual token s above zero carries s - 1 of them, an LZ77 length token above 235 s - 236.
70
+ const raw = s => Math.max(0, s < 224 ? s - 1 : s - 236);
71
+ // A histogram's complete cost in bits: its prefix code's header and every token's code and raw bits.
72
+ function cost(freqs) {
73
+ const code = buildCode(freqs), writer = new BitWriter(128);
74
+ writePrefixCode(writer, code);
75
+ let bits = writer.bitLength;
76
+ for (let s = 0; s < freqs.length; s++) if (freqs[s]) bits += freqs[s] * (code.lengths[s] + raw(s));
77
+ return bits;
78
+ }
79
+ const sum = histograms => { const out = new Uint32Array(ALPHABET); for (const h of histograms) for (let s = 0; s < ALPHABET; s++) out[s] += h[s]; return out; };
80
+
81
+ const CONTEXTS = WEIGHTED_CUTS.length + 1, IDENTITY = Int32Array.from({length: CONTEXTS}, (_, k) => k);
82
+
83
+ // A group of the search, here or in a pool's worker. Counting, every channel goes into `target` twice: under effort 1's
84
+ // predictor (`first`, one histogram per channel) and under the weighted one, its tokens kept by error interval
85
+ // (CONTEXTS histograms per channel after those). Writing, each channel through its plan (`plans`: the leaf, its codes
86
+ // and the intervals' contexts) into `target`, or, when none, as the group's own section, whose bytes it returns.
87
+ export function searchGroup(setup) {
88
+ const {channels, first, plans} = setup, fill = planeFill(setup), leaves = first?.map(p => leaf(p));
89
+ const planes = Array.from({length: channels}, () => new Int16Array(GROUP_DIM * GROUP_DIM));
90
+ return (rgba, stride, x0, y0, w, h, target) => {
91
+ fill(planes, rgba, stride, x0, y0, w, h);
92
+ if (!plans) {
93
+ for (let c = 0; c < channels; c++) {
94
+ codeChannel(null, target[c], planes[c], w, h, leaves[c]);
95
+ codeWeighted(null, target.slice(channels + c * CONTEXTS, channels + (c + 1) * CONTEXTS), planes[c], w, h, 0, IDENTITY);
96
+ }
97
+ return;
98
+ }
99
+ const section = target || new BitWriter(w * h * channels + 64);
100
+ if (!target) writeModularHeader(section, {useGlobalTree: true, transforms: []});
101
+ plans.forEach(({leaf: l, codes, contextOf}, c) => {
102
+ if (l.predictor === WEIGHTED_PREDICTOR) codeWeighted(section, codes, planes[c], w, h, 0, contextOf);
103
+ else codeChannel(section, codes[0], planes[c], w, h, l);
104
+ });
105
+ return target ? undefined : section.finish();
106
+ };
107
+ }
108
+
109
+ // The direct plan of lossless.mjs (YCoCg, 256-pixel groups), every channel counted over the whole picture under effort
110
+ // 1's predictor (chosen by samples, as the core chooses it) and under the weighted one; each rung's plan takes every
111
+ // channel's cheapest, and the plans are written the cheapest way. Nothing written (null) when no plan leaves effort 1.
112
+ function* searchSteps(rgba, width, height, shape, colorSpace, effort, pooled) {
113
+ const {channels} = shape, layout = groupLayout(width, height), groups = layout.groupsX * layout.groupsY;
114
+ const setup = {channels, alpha: shape.alpha, palette: null}, fill = planeFill(setup);
115
+ const planes = Array.from({length: channels}, () => new Int16Array(32 * 32));
116
+ const rect = g => groupRect(layout, width, height, g);
117
+ // Effort 1's predictor per channel: three 32-pixel samples priced under gradient and average, as the core does.
118
+ const sw = Math.min(width, 32), sh = Math.min(height, 32);
119
+ const sampled = [GRADIENT_PREDICTOR, AVERAGE_PREDICTOR].map(predictor => ({predictor, freqs: planes.map(() => new Uint32Array(ALPHABET))}));
120
+ for (const fraction of [0, 0.5, 1]) {
121
+ fill(planes, rgba, width, Math.floor((width - sw) * fraction), Math.floor((height - sh) * fraction), sw, sh);
122
+ for (const candidate of sampled) for (let c = 0; c < channels; c++) codeChannel(null, candidate.freqs[c], planes[c], sw, sh, leaf(candidate.predictor));
123
+ }
124
+ const first = planes.map((_, c) => leaf(sampled[cost(sampled[1].freqs[c]) < cost(sampled[0].freqs[c]) ? 1 : 0].predictor));
125
+ // Every channel counted under effort 1's predictor and under the weighted one, its tokens kept by error interval.
126
+ // A step is a group of the counting pass (the first half of the search's fractions) or of a plan's writing (the
127
+ // second half, shared by the plans written), so the pace of the steps holds whether one plan is written or two.
128
+ const contexts = CONTEXTS;
129
+ const firstCounts = planes.map(() => new Uint32Array(ALPHABET)), intervals = planes.map(() => Array.from({length: contexts}, () => new Uint32Array(ALPHABET)));
130
+ const counts = [...firstCounts, ...intervals.flat()], counted = {...setup, first: first.map(l => l.predictor)}, count = searchGroup(counted);
131
+ if ((yield* groupPass({pooled, kind: 'search', setup: {...counted, sizes: counts.map(h => h.length)}, at: g => (g + 1) / (2 * groups), stop: () => true},
132
+ groups, g => count(rgba, width, ...rect(g), counts), (g, partial) => addCounts(counts, partial))) === null) return null;
133
+ // Rung 2: each channel's cheaper of effort 1's predictor and the weighted one in one context.
134
+ const rung2 = planes.map((_, c) => {
135
+ const plan = {cost: cost(firstCounts[c]), leaves: [first[c]], freqs: [firstCounts[c]], cuts: []};
136
+ const whole = sum(intervals[c]), single = cost(whole);
137
+ return single < plan.cost ? {cost: single, leaves: [leaf(WEIGHTED_PREDICTOR)], freqs: [whole], cuts: []} : plan;
138
+ });
139
+ // Rung 3: or its intervals grouped into runs of neighbours, the grouping of least cost found exactly by dynamic
140
+ // programming. best[j] is the least cost of intervals 0..j-1 grouped, `from[j]` where its last group begins; an
141
+ // interval no token reaches leaves the merged cost as it was.
142
+ const rung3 = effort < 3 ? rung2 : rung2.map((plan, c) => {
143
+ const filled = intervals[c].map(h => h.some(v => v));
144
+ const best = new Float64Array(contexts + 1).fill(Infinity), from = new Int32Array(contexts + 1);
145
+ best[0] = 0;
146
+ for (let j = 1; j <= contexts; j++) {
147
+ const merged = new Uint32Array(ALPHABET);
148
+ let bits = 0;
149
+ for (let i = j - 1; i >= 0; i--) {
150
+ if (filled[i]) { const h = intervals[c][i]; for (let s = 0; s < ALPHABET; s++) merged[s] += h[s]; bits = cost(merged); }
151
+ // Every group is a leaf of the tree and an entry of the context map: 24 bits more, so an interval no token
152
+ // reaches joins its neighbour.
153
+ const price = best[i] + bits + 24;
154
+ if (price < best[j]) { best[j] = price; from[j] = i; }
155
+ }
156
+ }
157
+ if (!(best[contexts] < plan.cost)) return plan;
158
+ const groupsOf = [];
159
+ for (let j = contexts; j > 0; j = from[j]) groupsOf.unshift([from[j], j]);
160
+ return {cost: best[contexts], leaves: groupsOf.map(() => leaf(WEIGHTED_PREDICTOR)), freqs: groupsOf.map(([i, j]) => sum(intervals[c].slice(i, j))), cuts: groupsOf.slice(0, -1).map(([, j]) => WEIGHTED_CUTS[j - 1]),
161
+ contextOf: Int32Array.from({length: contexts}, (_, k) => groupsOf.findIndex(([i, j]) => k >= i && k < j))};
162
+ });
163
+ const candidates = [rung2, rung3].filter((plans, k) => k ? plans.some((plan, c) => plan !== rung2[c]) : plans.some((plan, c) => plan.leaves[0] !== first[c]));
164
+ if (!candidates.length) return null;
165
+ // The tree: one subtree per channel on the channel property, and inside a split channel a balanced tree on the
166
+ // weighted predictor's property, values above a cut to the left.
167
+ const byError = (leaves, cuts) => {
168
+ const build = (lo, hi) => { if (lo === hi) return leaves[lo]; const mid = (lo + hi) >> 1; return split(WEIGHTED_PROPERTY, cuts[mid], build(mid + 1, hi), build(lo, mid)); };
169
+ return build(0, leaves.length - 1);
170
+ };
171
+ const assemble = plans => ({tree: channelTree(plans.map(plan => byError(plan.leaves, plan.cuts))), leaves: plans.flatMap(plan => plan.leaves), freqs: plans.flatMap(plan => plan.freqs)});
172
+ // A plan's price in bits: its tree and histograms as written, and every token's code and raw bits. Two plans' streams
173
+ // differ by their prices and at most 27 bits a section more (a section's padding, and its size's field in the table
174
+ // of contents, 12 to 32 bits) and a byte: a plan that far cheaper is written alone, closer plans both.
175
+ const price = plans => {
176
+ const {tree, leaves, freqs} = assemble(plans), w = new BitWriter(4096);
177
+ const histograms = writeChannelHistograms(w, writeTree(w, tree), freqs, l => leaves.indexOf(l));
178
+ let bits = w.bitLength;
179
+ freqs.forEach((f, i) => { const {lengths} = histograms[i + 1].code; for (let s = 0; s < f.length; s++) if (f[s]) bits += f[s] * (lengths[s] + raw(s)); });
180
+ return bits;
181
+ };
182
+ let chosen = candidates;
183
+ if (candidates.length > 1) {
184
+ const [a, b] = candidates.map(price), margin = 27 * (layout.single ? 1 : groups + 1) + 8;
185
+ if (b + margin <= a) chosen = [candidates[1]];
186
+ else if (a + margin <= b) chosen = [candidates[0]];
187
+ else if (b < a) chosen = [candidates[1], candidates[0]];
188
+ }
189
+ let written = 0, hurried = false;
190
+ let smallest = null;
191
+ for (const plans of chosen) {
192
+ const bytes = yield* write(plans);
193
+ if (!bytes) return smallest;
194
+ // Price the likely winner first, but retain rung 2's byte choice on an equal-length completed pair.
195
+ if (!smallest || bytes.length < smallest.length || (bytes.length === smallest.length && plans === candidates[0])) smallest = bytes;
196
+ if (hurried) return smallest;
197
+ }
198
+ return smallest;
199
+
200
+ // A plan's stream, a group per step; a hurry at its final group keeps the already-completed candidate.
201
+ function* write(plans) {
202
+ const {tree, leaves, freqs} = assemble(plans);
203
+ const transforms = channels >= 3 ? [{type: 'rct', beginC: 0, rctType: 6}] : [];
204
+ const header = new BitWriter(256);
205
+ writeImageHeader(header, width, height, shape.colour, shape.alpha, {colorSpace});
206
+ writeModularFrameHeader(header, {alpha: shape.alpha});
207
+ const global = new BitWriter(4096);
208
+ global.write(1, 1); // default DC quantisation
209
+ global.write(1, 1); // a global tree
210
+ const histograms = writeChannelHistograms(global, writeTree(global, tree), freqs, l => leaves.indexOf(l));
211
+ writeModularHeader(global, {useGlobalTree: true, transforms});
212
+ const coded = {...setup, plans: plans.map(plan => ({leaf: plan.leaves[0], codes: plan.leaves.map(l => histograms[leaves.indexOf(l) + 1].code), contextOf: plan.contextOf}))};
213
+ const group = searchGroup(coded), sections = [];
214
+ if (layout.single) {
215
+ group(rgba, width, 0, 0, width, height, global);
216
+ hurried = yield 0.5 + ++written / (2 * chosen.length * groups);
217
+ sections.push(global.finish());
218
+ } else {
219
+ sections.push(global.finish());
220
+ for (let i = 0; i < layout.dcGroupsX * layout.dcGroupsY + 1; i++) sections.push(new Uint8Array(0));
221
+ const at = written, base = sections.length, place = (g, section) => { sections[base + g] = section; };
222
+ hurried = yield* groupPass({pooled, kind: 'search', setup: coded, at: g => 0.5 + (at + g + 1) / (2 * chosen.length * groups), stop: g => g + 1 < groups},
223
+ groups, g => place(g, group(rgba, width, ...rect(g))), place);
224
+ if (hurried === null) return null;
225
+ written += groups;
226
+ }
227
+ return assembleCodestream(header, sections);
228
+ }
229
+ }
package/effort.d.mts CHANGED
@@ -4,7 +4,8 @@ export {LIMITS} from './index.mjs';
4
4
  export type {Pixels, Limits, EncodeOptions, ErrorCode, EncoderError, Job} from './index.mjs';
5
5
  /** The core's encode with a search above it for exact pictures: effort 1 (the default) writes the core's bytes; 2 tries
6
6
  * the weighted predictor on every channel, 3 splits its contexts by that predictor's error, 4 models palette indices
7
- * per group, and 6 also learns gradient-property splits. A level without another rung uses the preceding one.
7
+ * per group and tries the other reversible colour transforms, and 6 also learns gradient-property splits. A level
8
+ * without another rung uses the preceding one.
8
9
  * Never larger than the effort below; slower. */
9
10
  export function encode(data: Pixels, width: number, height: number, options?: EncodeOptions): Uint8Array;
10
11
  /** encode's work and bytes as steps. Above effort 1 an exact picture's first half (fractions up to 0.5) writes effort