rapier-jxl 1.1.16
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 +18 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/bits.mjs +71 -0
- package/entropy.mjs +99 -0
- package/frame.mjs +100 -0
- package/index.mjs +77 -0
- package/jpeg.mjs +260 -0
- package/lossless.mjs +131 -0
- package/lossy.mjs +125 -0
- package/modular.mjs +156 -0
- package/package.json +38 -0
- package/prefix.mjs +238 -0
- package/squeeze.mjs +92 -0
- package/vardct.mjs +216 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# For an agent adding Rapier JXL to an app
|
|
2
|
+
|
|
3
|
+
1. Install: `npm install rapier-jxl` (or copy the eleven `.mjs` files at this repository's root into the app; no build needed).
|
|
4
|
+
2. Import `encode` and `transcode` from `rapier-jxl` (or from `index.mjs`).
|
|
5
|
+
3. Encode pixels: `encode(rgba, width, height, {quality})`, `rgba` being straight (not premultiplied) RGBA bytes
|
|
6
|
+
row by row, as `CanvasRenderingContext2D.getImageData` gives them; `quality` 100 (the default) is lossless,
|
|
7
|
+
1 to 99 lossy. The answer is a `Uint8Array` of JPEG XL codestream bytes: save it as `.jxl` or wrap it in a
|
|
8
|
+
`Blob` of type `image/jxl`.
|
|
9
|
+
4. Carry a JPEG: `transcode(jpegBytes)` returns `{bytes, width, height, orientation}`; do not decode the JPEG
|
|
10
|
+
first. If it throws `JXL_JPEG`, decode it (an `<img>` and a canvas) and call `encode` on the pixels.
|
|
11
|
+
5. Run it in a worker for anything larger than an icon; the README shows a complete one in eight lines.
|
|
12
|
+
6. Handle the five error codes (`JXL_INPUT`, `JXL_DIMENSIONS`, `JXL_SIZE`, `JXL_MEMORY`, `JXL_JPEG`); there are no
|
|
13
|
+
others, and a call that returns has returned a stream every JPEG XL decoder reads.
|
|
14
|
+
7. Show the result only where the browser decodes JPEG XL (`image/jxl` in `<picture>` with a fallback, or a
|
|
15
|
+
feature test on a one-pixel stream); keep the original where it does not.
|
|
16
|
+
|
|
17
|
+
Nothing here reads files, fetches, or touches the DOM; the module is safe to run in a worker, in Node and in
|
|
18
|
+
Deno alike. Do not vendor a minified copy under another name: publish the version you took and its licence.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rapier.website
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Rapier JXL
|
|
2
|
+
|
|
3
|
+
A JPEG XL encoder in pure JavaScript. No WebAssembly, no build step, no dependency, nothing fetched at run
|
|
4
|
+
time. It is the encoder inside [Rapier](https://rapier.website), the single-file Markdown editor, offered on its
|
|
5
|
+
own so any app can write JPEG XL pictures with the bytes it can afford: 24.7 KB gzipped
|
|
6
|
+
(83.7 KB of readable source), MIT.
|
|
7
|
+
|
|
8
|
+
- **Lossless.** Every pixel comes back as it went in. 8-bit grey, grey with alpha, RGB and RGBA.
|
|
9
|
+
- **Lossy.** Quality 1 to 99, on libjxl's modular path (the Squeeze transform), for drawings, screenshots and
|
|
10
|
+
pictures of few colours; a picture of few colours is answered exact when that is fewer bytes.
|
|
11
|
+
- **A JPEG carried whole.** A JPEG's coefficients go into a JPEG XL frame untouched, the way libjxl transcodes one,
|
|
12
|
+
so the picture decodes to the JPEG's own pixels at about a fifth fewer bytes, in one call, without decoding.
|
|
13
|
+
|
|
14
|
+
Every browser that reads JPEG XL reads these streams; so do libjxl, jxl-oxide and everything built on them.
|
|
15
|
+
|
|
16
|
+
## Use it
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import {encode, transcode} from 'rapier-jxl';
|
|
20
|
+
|
|
21
|
+
// Pixels from a canvas (straight RGBA, row by row) to a JPEG XL codestream.
|
|
22
|
+
const {data, width, height} = context.getImageData(0, 0, canvas.width, canvas.height);
|
|
23
|
+
const exact = encode(data, width, height); // lossless
|
|
24
|
+
const small = encode(data, width, height, {quality: 80}); // lossy
|
|
25
|
+
const blob = new Blob([small], {type: 'image/jxl'});
|
|
26
|
+
|
|
27
|
+
// A JPEG file to JPEG XL, its pixels kept.
|
|
28
|
+
const jpeg = new Uint8Array(await file.arrayBuffer());
|
|
29
|
+
const {bytes, width: w, height: h, orientation} = transcode(jpeg);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`encode(data, width, height, {quality = 100})` takes a `Uint8Array` or `Uint8ClampedArray` of `width * height * 4`
|
|
33
|
+
bytes and returns a `Uint8Array` holding a bare JPEG XL codestream (the `.jxl` file's bytes). `transcode(jpeg)`
|
|
34
|
+
takes a JPEG's bytes and returns `{bytes, width, height, orientation}`; the width and height are the picture's as
|
|
35
|
+
shown (swapped when the Exif orientation turns it), and the orientation is kept in the JPEG XL header.
|
|
36
|
+
|
|
37
|
+
Named entries for a tighter bundle: `encodeLosslessRGBA`, `encodeLossyRGBA`, `transcode`, and the raw
|
|
38
|
+
`encodeLossless`, `encodeLossy`, `transcodeJPEG`, `parseJPEG`, `inspectPixels` beneath them. The whole module is
|
|
39
|
+
the eleven `.mjs` files at the root of this repository: copy them into an app as they are, or install the package.
|
|
40
|
+
|
|
41
|
+
### In a worker
|
|
42
|
+
|
|
43
|
+
Encoding is synchronous and takes tens to hundreds of milliseconds on a large picture, so do it off the main
|
|
44
|
+
thread. A complete worker is this:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// jxl-worker.mjs
|
|
48
|
+
import {encode, transcode} from 'rapier-jxl';
|
|
49
|
+
self.onmessage = ({data: {id, op, ...ask}}) => {
|
|
50
|
+
try {
|
|
51
|
+
const out = op === 'transcode' ? transcode(ask.jpeg) : {bytes: encode(ask.data, ask.width, ask.height, {quality: ask.quality})};
|
|
52
|
+
self.postMessage({id, ok: true, ...out}, [out.bytes.buffer]);
|
|
53
|
+
} catch (error) { self.postMessage({id, ok: false, code: error.code || 'JXL_ERROR', message: String(error.message || error)}); }
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Post `{id, op: 'encode', data, width, height, quality}` or `{id, op: 'transcode', jpeg}`; receive `{id, ok: true,
|
|
58
|
+
bytes, ...}` or `{id, ok: false, code, message}`, the bytes transferred, never copied.
|
|
59
|
+
|
|
60
|
+
### Limits and errors
|
|
61
|
+
|
|
62
|
+
One picture at a time, at most 16,384 pixels on a side, 24 million pixels, and a 16 MiB stream. Anything else is
|
|
63
|
+
refused before any work with an `Error` whose `code` is one of `JXL_INPUT` (the arguments), `JXL_DIMENSIONS`,
|
|
64
|
+
`JXL_SIZE`, `JXL_MEMORY` (the engine ran out of memory) or `JXL_JPEG` (a JPEG the carrier does not take:
|
|
65
|
+
arithmetic coding, 12-bit, lossless, CMYK or a DNL height; decode it and encode its pixels instead). There is no
|
|
66
|
+
other failure: a call either returns a stream every decoder reads or throws one of these.
|
|
67
|
+
|
|
68
|
+
## Sizes
|
|
69
|
+
|
|
70
|
+
| what | source | gzipped |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| the whole module (the eleven files) | 83.7 KB | 24.7 KB |
|
|
73
|
+
| lossless only (`encodeLosslessRGBA`) | 35.7 KB | 11.0 KB |
|
|
74
|
+
| the JPEG carrier only (`transcode`) | 60.1 KB | 18.0 KB |
|
|
75
|
+
|
|
76
|
+
Measured on this release's files by the script that stages this repository, gzip at level 9, before any
|
|
77
|
+
minifier. A bundler that drops what you do not import lands between the rows.
|
|
78
|
+
|
|
79
|
+
What it writes, so a decoder's author knows what to expect: bare codestreams (no container box), 8-bit only,
|
|
80
|
+
prefix codes only (never ANS), one frame, no preview, no animation, no ICC profile (sRGB is declared), no
|
|
81
|
+
XYB, no chroma-from-luma, no filters. Lossless pictures use the modular mode with a palette of up to 512
|
|
82
|
+
colours, the reversible YCoCg transform, the clamped-gradient predictor and one prefix code per channel, in
|
|
83
|
+
groups of 256 by 256 pixels. Lossy pictures use the modular mode with the Squeeze transform. Carried JPEGs use
|
|
84
|
+
the VarDCT mode with the JPEG's own quantisation tables as raw dequantisation matrices.
|
|
85
|
+
|
|
86
|
+
## Why it exists
|
|
87
|
+
|
|
88
|
+
Rapier keeps pictures inside Markdown documents, and the standard it publishes says the best way to embed a
|
|
89
|
+
picture in a Markdown document is JPEG XL: exact where it must be exact, small where it may be small, one format
|
|
90
|
+
for photographs, paintings and diagrams alike. An editor that follows the standard needs an encoder it can carry
|
|
91
|
+
offline in a page that must stay small, and none existed at the size, so Rapier wrote one. This repository is
|
|
92
|
+
that encoder, unchanged, republished from Rapier's tree at each of its releases.
|
|
93
|
+
|
|
94
|
+
If your app embeds pictures in Markdown, the standard is at [rapier.website](https://rapier.website) and the
|
|
95
|
+
encoder is this one: tell your agent to add `rapier-jxl` (see `AGENTS.md`) and it is done.
|
|
96
|
+
|
|
97
|
+
## How it is checked
|
|
98
|
+
|
|
99
|
+
Rapier's own tree holds the tests: every stream this encoder writes is decoded again through
|
|
100
|
+
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and compared pixel by pixel (exact where exactness is promised,
|
|
101
|
+
above 38 dB where it is not, one stream for every JPEG form of one picture), and the page's retained checks run
|
|
102
|
+
the same before each release. This repository is republished from that tree at each release, so what is here has
|
|
103
|
+
passed them.
|
|
104
|
+
|
|
105
|
+
## Licence
|
|
106
|
+
|
|
107
|
+
MIT, copyright rapier.website. The design follows the JPEG XL specification (ISO/IEC 18181) and libjxl's
|
|
108
|
+
encoders, whose sources were read; none of their code is here.
|
package/bits.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Rapier's JPEG XL encoder: the bit writer. MIT (LICENSE).
|
|
2
|
+
// JPEG XL packs bits least-significant first; `write` takes up to 32 bits at a time.
|
|
3
|
+
|
|
4
|
+
export class BitWriter {
|
|
5
|
+
constructor(capacity = 4096) {
|
|
6
|
+
this.bytes = new Uint8Array(capacity);
|
|
7
|
+
this.at = 0; // whole bytes written
|
|
8
|
+
this.acc = 0; // the pending bits, as a number below 2^40
|
|
9
|
+
this.pending = 0; // how many bits `acc` holds, always below 8 between calls
|
|
10
|
+
}
|
|
11
|
+
write(count, value) {
|
|
12
|
+
if (count > 32 || value < 0 || value >= 2 ** count) throw new Error('bit write out of range: ' + count + ' bits, ' + value);
|
|
13
|
+
let acc = this.acc + value * 2 ** this.pending, pending = this.pending + count;
|
|
14
|
+
if (this.at + 5 >= this.bytes.length) this.grow();
|
|
15
|
+
const bytes = this.bytes;
|
|
16
|
+
while (pending >= 8) { bytes[this.at++] = acc & 255; acc = Math.floor(acc / 256); pending -= 8; }
|
|
17
|
+
this.acc = acc; this.pending = pending;
|
|
18
|
+
}
|
|
19
|
+
// The U32 field of the specification: a two-bit selector, then that choice's bits (`offset` plus the bits).
|
|
20
|
+
writeU32(choices, value) {
|
|
21
|
+
for (let selector = 0; selector < 4; selector++) {
|
|
22
|
+
const [bits, offset] = choices[selector];
|
|
23
|
+
if (value >= offset && value - offset < 2 ** bits) { this.write(2, selector); if (bits) this.write(bits, value - offset); return; }
|
|
24
|
+
}
|
|
25
|
+
throw new Error('U32 value out of range: ' + value);
|
|
26
|
+
}
|
|
27
|
+
zeroPadToByte() {
|
|
28
|
+
if (!this.pending) return;
|
|
29
|
+
if (this.at + 1 >= this.bytes.length) this.grow();
|
|
30
|
+
this.bytes[this.at++] = this.acc; this.acc = 0; this.pending = 0;
|
|
31
|
+
}
|
|
32
|
+
get bitLength() { return this.at * 8 + this.pending; }
|
|
33
|
+
grow(need = 0) {
|
|
34
|
+
const next = new Uint8Array(Math.max(this.bytes.length * 2, this.at + need + 16));
|
|
35
|
+
next.set(this.bytes.subarray(0, this.at)); this.bytes = next;
|
|
36
|
+
}
|
|
37
|
+
// Appends another writer's bits at the current bit position.
|
|
38
|
+
append(other) {
|
|
39
|
+
if (this.at + other.at + 8 >= this.bytes.length) this.grow(other.at + 8);
|
|
40
|
+
if (!this.pending) { this.bytes.set(other.bytes.subarray(0, other.at), this.at); this.at += other.at; }
|
|
41
|
+
else for (let i = 0; i < other.at; i++) this.write(8, other.bytes[i]);
|
|
42
|
+
if (other.pending) this.write(other.pending, other.acc);
|
|
43
|
+
}
|
|
44
|
+
// The bytes so far, the last partial byte zero-padded.
|
|
45
|
+
finish() {
|
|
46
|
+
const length = this.at + (this.pending ? 1 : 0), out = new Uint8Array(length);
|
|
47
|
+
out.set(this.bytes.subarray(0, this.at));
|
|
48
|
+
if (this.pending) out[this.at] = this.acc;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Residuals travel unsigned: 0, -1, 1, -2, 2 ... become 0, 1, 2, 3, 4 ...
|
|
54
|
+
export function packSigned(value) { return value >= 0 ? value * 2 : -value * 2 - 1; }
|
|
55
|
+
|
|
56
|
+
export function floorLog2(value) { return 31 - Math.clz32(value); }
|
|
57
|
+
export function ceilLog2(value) { return value <= 1 ? 0 : 32 - Math.clz32(value - 1); }
|
|
58
|
+
|
|
59
|
+
// IEEE half precision, round to nearest even, as the specification's F16 fields.
|
|
60
|
+
export function float16Bits(value) {
|
|
61
|
+
if (value === 0) return 0;
|
|
62
|
+
const sign = value < 0 ? 0x8000 : 0;
|
|
63
|
+
value = Math.abs(value);
|
|
64
|
+
if (!(value < 65520)) throw new Error('half float out of range: ' + value);
|
|
65
|
+
let exponent = Math.floor(Math.log2(value));
|
|
66
|
+
let mantissa = value / 2 ** exponent - 1;
|
|
67
|
+
if (exponent < -14) { mantissa = value / 2 ** -14; exponent = -15; }
|
|
68
|
+
let m = Math.round(mantissa * 1024);
|
|
69
|
+
if (m === 1024) { m = 0; exponent++; }
|
|
70
|
+
return sign | ((exponent + 15) << 10) | m;
|
|
71
|
+
}
|
package/entropy.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Rapier's JPEG XL encoder: token coding over many contexts. MIT (LICENSE).
|
|
2
|
+
// The AC coefficients of a VarDCT frame are coded in hundreds of contexts; the contexts are clustered into a few
|
|
3
|
+
// dozen histograms by greedy merging (the cheapest entropy increase first), each histogram takes the hybrid
|
|
4
|
+
// integer split that costs least on its own values, and a prefix code is built per histogram.
|
|
5
|
+
import {buildCode, uintConfig, hybridToken} from './prefix.mjs';
|
|
6
|
+
import {floorLog2} from './bits.mjs';
|
|
7
|
+
|
|
8
|
+
const CONFIGS = [uintConfig(0), uintConfig(1), uintConfig(2), uintConfig(3), uintConfig(4), uintConfig(4, 1, 1), uintConfig(5, 1, 1), uintConfig(2, 0, 1), uintConfig(3, 0, 1)];
|
|
9
|
+
const CLUSTER_CONFIG = uintConfig(4, 1, 1);
|
|
10
|
+
|
|
11
|
+
// Value counts per context: values below 64 in a table, larger ones in a map per context.
|
|
12
|
+
export class TokenCounts {
|
|
13
|
+
constructor(contexts) { this.contexts = contexts; this.small = new Uint32Array(contexts * 64); this.large = Array.from({length: contexts}, () => null); this.totals = new Float64Array(contexts); }
|
|
14
|
+
add(ctx, value) {
|
|
15
|
+
this.totals[ctx]++;
|
|
16
|
+
if (value < 64) { this.small[ctx * 64 + value]++; return; }
|
|
17
|
+
const map = this.large[ctx] || (this.large[ctx] = new Map());
|
|
18
|
+
map.set(value, (map.get(value) || 0) + 1);
|
|
19
|
+
}
|
|
20
|
+
// Token histogram of a set of contexts under a configuration.
|
|
21
|
+
tokens(contexts, config, size = 64) {
|
|
22
|
+
const freqs = new Uint32Array(size);
|
|
23
|
+
for (const ctx of contexts) {
|
|
24
|
+
const base = ctx * 64;
|
|
25
|
+
for (let v = 0; v < 64; v++) if (this.small[base + v]) freqs[hybridTokenOf(config, v)] += this.small[base + v];
|
|
26
|
+
const map = this.large[ctx];
|
|
27
|
+
if (map) for (const [v, n] of map) freqs[hybridTokenOf(config, v)] += n;
|
|
28
|
+
}
|
|
29
|
+
return freqs;
|
|
30
|
+
}
|
|
31
|
+
// The bits a set of contexts costs under a configuration: token entropy plus the raw bits.
|
|
32
|
+
cost(contexts, config) {
|
|
33
|
+
const freqs = this.tokens(contexts, config);
|
|
34
|
+
let total = 0, bits = 0;
|
|
35
|
+
for (const f of freqs) total += f;
|
|
36
|
+
for (const f of freqs) if (f) bits += f * Math.log2(total / f);
|
|
37
|
+
for (const ctx of contexts) {
|
|
38
|
+
const base = ctx * 64;
|
|
39
|
+
for (let v = config.splitToken; v < 64; v++) if (this.small[base + v]) bits += this.small[base + v] * (floorLog2(v) - config.msb - config.lsb);
|
|
40
|
+
const map = this.large[ctx];
|
|
41
|
+
if (map) for (const [v, n] of map) bits += n * (floorLog2(v) - config.msb - config.lsb);
|
|
42
|
+
}
|
|
43
|
+
return bits;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const slots = [0, 0, 0];
|
|
48
|
+
function hybridTokenOf(config, value) { hybridToken(config, value, slots); return slots[0]; }
|
|
49
|
+
|
|
50
|
+
function entropyBits(freqs) {
|
|
51
|
+
let total = 0, bits = 0;
|
|
52
|
+
for (const f of freqs) total += f;
|
|
53
|
+
for (const f of freqs) if (f) bits += f * Math.log2(total / f);
|
|
54
|
+
return bits;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clusters the used contexts (unused ones join cluster 0) and builds one prefix code per cluster. Returns the
|
|
58
|
+
// context map and histograms for writeHistograms, and a writer for tokens.
|
|
59
|
+
export function buildTokenCoding(counts, {maxClusters = 48, newClusterCost = 320} = {}) {
|
|
60
|
+
const used = [];
|
|
61
|
+
for (let ctx = 0; ctx < counts.contexts; ctx++) if (counts.totals[ctx] > 0) used.push(ctx);
|
|
62
|
+
used.sort((a, b) => counts.totals[b] - counts.totals[a]);
|
|
63
|
+
const clusters = []; // {contexts, freqs, bits}
|
|
64
|
+
for (const ctx of used) {
|
|
65
|
+
const own = counts.tokens([ctx], CLUSTER_CONFIG), ownBits = entropyBits(own);
|
|
66
|
+
let best = -1, bestIncrease = Infinity;
|
|
67
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
68
|
+
const cluster = clusters[i], merged = new Uint32Array(64);
|
|
69
|
+
for (let t = 0; t < 64; t++) merged[t] = cluster.freqs[t] + own[t];
|
|
70
|
+
const increase = entropyBits(merged) - cluster.bits - ownBits;
|
|
71
|
+
if (increase < bestIncrease) { bestIncrease = increase; best = i; }
|
|
72
|
+
}
|
|
73
|
+
if (best < 0 || (clusters.length < maxClusters && bestIncrease > newClusterCost)) clusters.push({contexts: [ctx], freqs: own, bits: ownBits});
|
|
74
|
+
else {
|
|
75
|
+
const cluster = clusters[best];
|
|
76
|
+
cluster.contexts.push(ctx);
|
|
77
|
+
for (let t = 0; t < 64; t++) cluster.freqs[t] += own[t];
|
|
78
|
+
cluster.bits = entropyBits(cluster.freqs);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!clusters.length) clusters.push({contexts: [], freqs: new Uint32Array(64), bits: 0});
|
|
82
|
+
const contextMap = new Uint8Array(counts.contexts);
|
|
83
|
+
let bits = counts.contexts * 2; // the context map's entries, roughly
|
|
84
|
+
const histograms = clusters.map((cluster, index) => {
|
|
85
|
+
for (const ctx of cluster.contexts) contextMap[ctx] = index;
|
|
86
|
+
let config = CONFIGS[0], bestCost = Infinity;
|
|
87
|
+
for (const candidate of CONFIGS) { const cost = counts.cost(cluster.contexts, candidate); if (cost < bestCost) { bestCost = cost; config = candidate; } }
|
|
88
|
+
const code = buildCode(counts.tokens(cluster.contexts, config, 128));
|
|
89
|
+
bits += bestCost + code.alphabetSize * 3 + 40;
|
|
90
|
+
return {config, code};
|
|
91
|
+
});
|
|
92
|
+
const write = (w, ctx, value) => {
|
|
93
|
+
const histogram = histograms[contextMap[ctx]];
|
|
94
|
+
hybridToken(histogram.config, value, slots);
|
|
95
|
+
w.write(histogram.code.lengths[slots[0]], histogram.code.codes[slots[0]]);
|
|
96
|
+
if (slots[1]) w.write(slots[1], slots[2]);
|
|
97
|
+
};
|
|
98
|
+
return {contextMap, histograms, write, bits};
|
|
99
|
+
}
|
package/frame.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Rapier's JPEG XL encoder: the codestream around a frame. MIT (LICENSE).
|
|
2
|
+
// A bare codestream (no container): signature, size header, image metadata, one frame with its table of contents.
|
|
3
|
+
import {BitWriter} from './bits.mjs';
|
|
4
|
+
|
|
5
|
+
export const GROUP_DIM = 256, DC_GROUP_DIM = 2048;
|
|
6
|
+
|
|
7
|
+
function writeSize(w, size) {
|
|
8
|
+
if (size <= 1 << 9) { w.write(2, 0); w.write(9, size - 1); }
|
|
9
|
+
else if (size <= 1 << 13) { w.write(2, 1); w.write(13, size - 1); }
|
|
10
|
+
else if (size <= 1 << 18) { w.write(2, 2); w.write(18, size - 1); }
|
|
11
|
+
else { w.write(2, 3); w.write(30, size - 1); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 8 bits per sample; `colour` 1 (grey) or 3 (sRGB); an 8-bit alpha channel when `alpha`. Frames start byte-aligned.
|
|
15
|
+
export function writeImageHeader(w, width, height, colour, alpha, {xyb = false, orientation = 1} = {}) {
|
|
16
|
+
w.write(16, 0x0AFF);
|
|
17
|
+
w.write(1, 0); // not the small size form
|
|
18
|
+
writeSize(w, height);
|
|
19
|
+
w.write(3, 0); // no aspect ratio shortcut
|
|
20
|
+
writeSize(w, width);
|
|
21
|
+
w.write(1, 0); // metadata not all default
|
|
22
|
+
const extra = orientation !== 1;
|
|
23
|
+
w.write(1, extra ? 1 : 0); // extra fields: only an orientation (the Exif value, as a JPEG carried it)
|
|
24
|
+
if (extra) { w.write(3, orientation - 1); w.write(1, 0); w.write(1, 0); w.write(1, 0); } // no intrinsic size, preview or animation
|
|
25
|
+
w.write(1, 0); // integer samples
|
|
26
|
+
w.write(2, 0); // 8 bits per sample
|
|
27
|
+
w.write(1, 1); // 16-bit buffers suffice
|
|
28
|
+
if (alpha) { w.write(2, 1); w.write(1, 1); } // one extra channel, all default: 8-bit alpha
|
|
29
|
+
else w.write(2, 0);
|
|
30
|
+
w.write(1, xyb ? 1 : 0); // xyb_encoded
|
|
31
|
+
if (colour === 3) w.write(1, 1); // colour encoding all default: sRGB
|
|
32
|
+
else {
|
|
33
|
+
w.write(1, 0); w.write(1, 0); // not default, no ICC
|
|
34
|
+
w.write(2, 1); // grey
|
|
35
|
+
w.write(2, 1); // D65
|
|
36
|
+
w.write(1, 0); // no gamma
|
|
37
|
+
w.write(2, 2); w.write(4, 11); // transfer function: sRGB
|
|
38
|
+
w.write(2, 1); // relative rendering intent
|
|
39
|
+
}
|
|
40
|
+
if (extra) w.write(1, 1); // default tone mapping
|
|
41
|
+
w.write(2, 0); // no extensions
|
|
42
|
+
w.write(1, 1); // default transform data
|
|
43
|
+
w.zeroPadToByte();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A lossless modular frame, the last frame, no filters, one pass, 256-pixel groups, replace blending.
|
|
47
|
+
export function writeModularFrameHeader(w, {alpha}) {
|
|
48
|
+
w.write(1, 0); // not all default
|
|
49
|
+
w.write(2, 0); // regular frame
|
|
50
|
+
w.write(1, 1); // modular
|
|
51
|
+
w.write(2, 0); // default flags
|
|
52
|
+
w.write(1, 0); // not YCbCr
|
|
53
|
+
w.write(2, 0); // no upsampling
|
|
54
|
+
if (alpha) w.write(2, 0); // no extra-channel upsampling
|
|
55
|
+
w.write(2, 1); // group size shift 1: 256
|
|
56
|
+
w.write(2, 0); // one pass
|
|
57
|
+
w.write(1, 0); // no custom size or origin
|
|
58
|
+
w.write(2, 0); // replace blending
|
|
59
|
+
if (alpha) w.write(2, 0); // replace for the extra channel
|
|
60
|
+
w.write(1, 1); // the last frame
|
|
61
|
+
w.write(2, 0); // no name
|
|
62
|
+
w.write(1, 0); // loop filter not default:
|
|
63
|
+
w.write(1, 0); // no gaborish
|
|
64
|
+
w.write(2, 0); // no edge-preserving filter
|
|
65
|
+
w.write(2, 0); // no filter extensions
|
|
66
|
+
w.write(2, 0); // no frame header extensions
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TOC_OFFSET = [0, 1024, 17408, 4211712], TOC_BITS = [12, 16, 24, 32];
|
|
70
|
+
|
|
71
|
+
export function writeTOC(w, sizes) {
|
|
72
|
+
w.write(1, 0); // no permutation
|
|
73
|
+
w.zeroPadToByte();
|
|
74
|
+
for (const size of sizes) {
|
|
75
|
+
let bucket = 0;
|
|
76
|
+
while (bucket < 3 && size >= TOC_OFFSET[bucket + 1]) bucket++;
|
|
77
|
+
w.write(2, bucket);
|
|
78
|
+
w.write(TOC_BITS[bucket] - 2, size - TOC_OFFSET[bucket]);
|
|
79
|
+
}
|
|
80
|
+
w.zeroPadToByte();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function groupLayout(width, height) {
|
|
84
|
+
const groupsX = Math.ceil(width / GROUP_DIM), groupsY = Math.ceil(height / GROUP_DIM);
|
|
85
|
+
const dcGroupsX = Math.ceil(width / DC_GROUP_DIM), dcGroupsY = Math.ceil(height / DC_GROUP_DIM);
|
|
86
|
+
return {groupsX, groupsY, dcGroupsX, dcGroupsY, single: groupsX === 1 && groupsY === 1};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sections in the specification's order: DC global, the DC groups, AC global, the AC groups (one pass). A single
|
|
90
|
+
// group frame has one section holding everything. Every section ends on a byte.
|
|
91
|
+
export function assembleCodestream(header, sections) {
|
|
92
|
+
const sizes = sections.map(section => section.length);
|
|
93
|
+
writeTOC(header, sizes);
|
|
94
|
+
const head = header.finish();
|
|
95
|
+
const out = new Uint8Array(head.length + sizes.reduce((a, b) => a + b, 0));
|
|
96
|
+
out.set(head);
|
|
97
|
+
let at = head.length;
|
|
98
|
+
for (const section of sections) { out.set(section, at); at += section.length; }
|
|
99
|
+
return out;
|
|
100
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Rapier JXL: a JPEG XL encoder in pure JavaScript, the one Rapier's page carries. This is the module's face for
|
|
3
|
+
// other apps; Rapier's own worker takes the modules directly (images/encoder.mjs). No WebAssembly, no build step,
|
|
4
|
+
// no dependency, nothing read or fetched at run time: pixels in, a bare codestream out.
|
|
5
|
+
import {inspectPixels, encodeLossless} from './lossless.mjs';
|
|
6
|
+
import {encodeLossy} from './lossy.mjs';
|
|
7
|
+
import {transcodeJPEG} from './vardct.mjs';
|
|
8
|
+
import {parseJPEG} from './jpeg.mjs';
|
|
9
|
+
|
|
10
|
+
// What one call takes at most: the 16 MiB codestream, 24 million pixels, 16,384 on a side. Larger asks are refused
|
|
11
|
+
// with a coded error before any work, the way a decoder would refuse them after it.
|
|
12
|
+
export const LIMITS = Object.freeze({bytes: 16 * 1024 * 1024, pixels: 24_000_000, edge: 16384});
|
|
13
|
+
|
|
14
|
+
const fault = (code, message) => Object.assign(new Error(message), {code});
|
|
15
|
+
function admitSize(width, height) {
|
|
16
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw fault('JXL_INPUT', 'A picture is at least one pixel wide and high, in whole pixels.');
|
|
17
|
+
if (width > LIMITS.edge || height > LIMITS.edge) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.edge + ' pixels on a side.');
|
|
18
|
+
if (width * height > LIMITS.pixels) throw fault('JXL_DIMENSIONS', 'A picture is at most ' + LIMITS.pixels.toLocaleString('en-US') + ' pixels.');
|
|
19
|
+
}
|
|
20
|
+
function admitPixels(data, width, height) {
|
|
21
|
+
admitSize(width, height);
|
|
22
|
+
if (!(data instanceof Uint8Array) && !(data instanceof Uint8ClampedArray)) throw fault('JXL_INPUT', 'Pixels are a Uint8Array or Uint8ClampedArray of RGBA bytes.');
|
|
23
|
+
if (data.length !== width * height * 4) throw fault('JXL_INPUT', 'Pixels are width * height * 4 bytes: straight (not premultiplied) RGBA, row by row.');
|
|
24
|
+
}
|
|
25
|
+
function answer(bytes) {
|
|
26
|
+
if (bytes.length > LIMITS.bytes) throw fault('JXL_SIZE', 'The encoded picture exceeds 16 MiB.');
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
function guard(work) {
|
|
30
|
+
try { return work(); }
|
|
31
|
+
catch (error) { if (error instanceof RangeError && !error.code) throw fault('JXL_MEMORY', 'Not enough memory for this picture.'); throw error; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Exact: every pixel comes back as it went in. An opaque alpha is dropped, a grey picture keeps one channel,
|
|
35
|
+
// up to 512 colours become a palette, colour goes through the reversible YCoCg transform.
|
|
36
|
+
export function encodeLosslessRGBA(data, width, height) {
|
|
37
|
+
admitPixels(data, width, height);
|
|
38
|
+
return guard(() => answer(encodeLossless(data, width, height, {shape: inspectPixels(data, width, height)})));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Lossy modular at a quality from 1 to 99 (90 is libjxl's distance 1.0); a picture of few colours is answered
|
|
42
|
+
// exact when that is fewer bytes. Quality 100 is the lossless answer.
|
|
43
|
+
export function encodeLossyRGBA(data, width, height, quality = 90) {
|
|
44
|
+
admitPixels(data, width, height);
|
|
45
|
+
if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw fault('JXL_INPUT', 'Quality is a number from 1 to 100.');
|
|
46
|
+
return guard(() => {
|
|
47
|
+
const shape = inspectPixels(data, width, height);
|
|
48
|
+
if (quality >= 100) return answer(encodeLossless(data, width, height, {shape}));
|
|
49
|
+
let bytes = encodeLossy(data, width, height, {quality, shape});
|
|
50
|
+
if (shape.palette) { const exact = encodeLossless(data, width, height, {shape}); if (exact.length <= bytes.length) bytes = exact; }
|
|
51
|
+
return answer(bytes);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One call for both: quality 100 (the default) is exact, anything lower is lossy.
|
|
56
|
+
export function encode(data, width, height, {quality = 100} = {}) {
|
|
57
|
+
return quality >= 100 ? encodeLosslessRGBA(data, width, height) : encodeLossyRGBA(data, width, height, quality);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A JPEG carried whole into JPEG XL: its coefficients, quantisation tables, colour and subsampling kept, only the
|
|
61
|
+
// entropy coding changed, so the picture decodes to the JPEG's own pixels at about a fifth fewer bytes. Baseline,
|
|
62
|
+
// extended sequential and progressive scans, with restarts; 8-bit; grey or YCbCr/RGB; an Exif orientation kept.
|
|
63
|
+
// A JPEG this path does not take (arithmetic coding, 12-bit, lossless, CMYK, a DNL height) is refused as
|
|
64
|
+
// JXL_JPEG; decode it and encode its pixels instead.
|
|
65
|
+
export function transcode(jpeg) {
|
|
66
|
+
if (!(jpeg instanceof Uint8Array) || !jpeg.length) throw fault('JXL_INPUT', 'A JPEG is a non-empty Uint8Array.');
|
|
67
|
+
if (jpeg.length > LIMITS.bytes) throw fault('JXL_SIZE', 'A JPEG is at most 16 MiB.');
|
|
68
|
+
return guard(() => {
|
|
69
|
+
const parsed = parseJPEG(jpeg);
|
|
70
|
+
admitSize(parsed.width, parsed.height);
|
|
71
|
+
const bytes = answer(transcodeJPEG(jpeg, parsed));
|
|
72
|
+
const swapped = parsed.orientation >= 5;
|
|
73
|
+
return {bytes, width: swapped ? parsed.height : parsed.width, height: swapped ? parsed.width : parsed.height, orientation: parsed.orientation};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export {encodeLossless, encodeLossy, transcodeJPEG, parseJPEG, inspectPixels};
|