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 +5 -0
- package/ENCODER-COMPARISON.md +6 -6
- package/KERNELS.md +58 -0
- package/README.md +42 -16
- package/bits.mjs +5 -2
- package/build-jxl-kernels.mjs +29 -0
- package/effort-job.mjs +229 -0
- package/effort.d.mts +2 -1
- package/effort.min.mjs +2 -2
- package/effort.mjs +5 -220
- package/frame.mjs +25 -0
- package/jpeg-ans.min.mjs +2 -2
- package/jpeg.min.mjs +2 -2
- 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 +40 -36
- package/lossless-coding.mjs +30 -0
- package/lossless.mjs +88 -67
- package/modular.mjs +11 -7
- package/package.json +32 -6
- package/photo-ans.min.mjs +2 -2
- package/photo.min.mjs +2 -2
- package/pool.mjs +136 -0
- package/rapier-jxl.min.mjs +2 -2
- package/rct-search.mjs +61 -0
- package/sizes.json +127 -55
- package/wasm.d.mts +4 -0
- package/wasm.min.mjs +2 -0
- package/wasm.mjs +6 -0
- package/weighted.mjs +2 -0
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.
|
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,8 +1,23 @@
|
|
|
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.
|
|
@@ -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` |
|
|
129
|
-
| `effort.min.mjs`: `encode` with effort |
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
|
|
|
135
|
-
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
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,
|
|
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);
|
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
|
|
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
|