Skip to content

Commit ebb0742

Browse files
matmenclaude
andcommitted
Lossy JPEG XL encoder (VarDCT)
encodeJXL({ quality }) encodes lossy JPEG XLs, quality mapping to the Butteraugli distance like cjxl -q; without it images stay lossless. The encoder follows libjxl's effort 3: XYB, 8x8 DCTs, the constant quantization field of the distance with libjxl's global scale, LF quantizer and dead zones, default matrices, block contexts and chroma from luma, EPF iterations by distance. LF coefficients use the weighted predictor with libjxl's fixed tree per channel, HF metadata the left predictor, alpha (lossless) the gradient, in the global stream or the pass groups. The new entropy encoder (entropy-enc.ts) clusters contexts, writes the context map and codes tokens with ANS (libjxl's count normalization and histogram format; the alias table comes from the decoder's) or prefix codes. Bit writer and image header moved out of fast-lossless.ts, the lossless output is unchanged. 1920x1080 photo at d1: 240.8 kB at the PSNR of cjxl -e 3's 240.2 kB, 0.9 s. BD-rate vs cjxl -e 3: +0.8 to +2.9% (photos, screenshots); vs -e 7: -1 to +10% on photos, +31-42% on screenshots. Every size, distance and alpha case tried decodes in djxl within 1 of our decoder. Also exports AVIFEncodeOptions, which index.ts was missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 8e0950f commit ebb0742

17 files changed

Lines changed: 1304 additions & 136 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 3.7.0
4+
5+
### Features
6+
7+
- `Image.encodeJXL({ quality })` encodes lossy JPEG XLs (VarDCT): `quality` (0-100) maps to the Butteraugli distance
8+
like `cjxl -q` (90 is cjxl's default, 100 is lossless); without it, images are encoded losslessly as before. The
9+
encoder follows libjxl's effort 3: XYB, 8x8 DCTs, libjxl's quantization, the weighted predictor for the LF, ANS,
10+
the edge preserving filter. Its files are within about 1-3% of `cjxl -e 3` at the same PSNR; a 1920x1080 photo
11+
takes about 0.9 s. Alpha is lossless.
12+
13+
### Fixes
14+
15+
- `AVIFEncodeOptions` is exported (with the new `JXLEncodeOptions`).
16+
317
## 3.6.0
418

519
### Features

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
6868
, lossless and lossy [WebPs](https://imagescript.matmen.dev/classes/Image.html#encodewebp) (lossy ones byte for
6969
byte like libwebp's cwebp)
7070
, [AVIFs](https://imagescript.matmen.dev/classes/Image.html#encodeavif) (lossy and lossless, with alpha)
71-
, lossless [JPEG XLs](https://imagescript.matmen.dev/classes/Image.html#encodejxl) (byte for byte like libjxl's
72-
`cjxl -e 1`)
71+
, [JPEG XLs](https://imagescript.matmen.dev/classes/Image.html#encodejxl) (lossless byte for byte like libjxl's
72+
`cjxl -e 1`, lossy about as small as `cjxl -e 3`)
7373
, [BMPs](https://imagescript.matmen.dev/classes/Image.html#encodebmp)
7474
, [QOIs](https://imagescript.matmen.dev/classes/Image.html#encodeqoi)
7575
, [Netpbm images](https://imagescript.matmen.dev/classes/Image.html#encodepnm) (PBM, PGM, PPM or PAM, whichever is
@@ -129,8 +129,8 @@ the text shaping is ported from [HarfBuzz](https://github.com/harfbuzz/harfbuzz)
129129
[Unicode Character Database](https://www.unicode.org/ucd/). The AV1 decoder is ported from
130130
[dav1d](https://code.videolan.org/videolan/dav1d), AVIFs are converted to RGB like
131131
[libavif](https://github.com/AOMediaCodec/libavif) does, the JPEG XL decoder is ported from
132-
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and [libjxl](https://github.com/libjxl/libjxl), the JPEG XL encoder
133-
from libjxl's fast lossless encoder, the AV1 encoder mirrors the decoder and uses libaom's symbol encoder.
132+
[jxl-oxide](https://github.com/tirr-c/jxl-oxide) and [libjxl](https://github.com/libjxl/libjxl), the JPEG XL encoders
133+
follow libjxl's fast lossless encoder and its effort 3, the AV1 encoder mirrors the decoder and uses libaom's symbol encoder.
134134
See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses.
135135

136136
---

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,9 @@ DEALINGS IN THE SOFTWARE.
290290
## libjxl, JPEG XL conformance
291291

292292
The Modular decoding, the rendering pipeline, blending, the XYB conversion and the rendering of patches, splines and
293-
noise follow libjxl, the JPEG XL reference implementation, and the lossless encoder is ported from its fast lossless
294-
encoder; tests/jxl includes files of its conformance suite. JPEG XL
293+
noise follow libjxl, the JPEG XL reference implementation, the lossless encoder is ported from its fast lossless
294+
encoder, the lossy encoder follows its effort 3 (heuristics, quantization, the ANS histograms, fixed trees and
295+
clustering); tests/jxl includes files of its conformance suite. JPEG XL
295296
is covered by Google's patent grant for libjxl (the "Additional IP Rights Grant" in libjxl's PATENTS file).
296297

297298
```

‎src/codecs/jxl.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/*!
22
* JPEG XL (ISO/IEC 18181): bare codestreams and the container (ISO/IEC 18181-2: jxlc, jxlp boxes), the image header,
33
* the frames up to the first one shown (with its orientation); see ./jxl/ for the decoder, ported from jxl-oxide and
4-
* libjxl (see THIRD_PARTY_NOTICES.md). Encoded images with Exif get the container (signature, ftyp, Exif, jxlc boxes)
4+
* libjxl (see THIRD_PARTY_NOTICES.md); the encoders' codestreams, with Exif in the container (signature, ftyp, Exif, jxlc
5+
* boxes)
56
*/
67
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
78
import { BitReader } from './jxl/bits.ts';
@@ -11,6 +12,7 @@ import { decodeFrame, DecoderState } from './jxl/frame.ts';
1112
import { ColorOutput } from './jxl/color.ts';
1213
import { ToneMapping } from './hdr.ts';
1314
import { encodeRGBA } from './jxl/fast-lossless.ts';
15+
import { encodeVarDCT } from './jxl/vardct-enc.ts';
1416
import { concat } from '../util/mem.ts';
1517

1618
const CONTAINER = [0, 0, 0, 12, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a];
@@ -21,9 +23,12 @@ export function isJXL(d: Uint8Array): boolean {
2123
return d.length >= 12 && CONTAINER.every((v, i) => d[i] === v);
2224
}
2325

24-
/** Encodes RGBA pixels as a lossless JPEG XL: the codestream, in the container if there is Exif data (a TIFF header and IFDs) */
25-
export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array): Uint8Array {
26-
const stream = encodeRGBA(rgba, width, height);
26+
/**
27+
* Encodes RGBA pixels as a JPEG XL, lossless or lossy (with a Butteraugli distance above 0): the codestream, in the
28+
* container if there is Exif data (a TIFF header and IFDs)
29+
*/
30+
export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array, distance = 0): Uint8Array {
31+
const stream = distance > 0 ? encodeVarDCT(rgba, width, height, { distance }) : encodeRGBA(rgba, width, height);
2732
if (!exif?.length) return stream;
2833
// (ftyp: brand jxl, minor version 0, compatible jxl; Exif: the offset of the TIFF header, 0, then the TIFF data)
2934
const jxl = [0x6a, 0x78, 0x6c, 0x20];

‎src/codecs/jxl/bit-writer.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*!
2+
* The bit writer of the JPEG XL encoders, ported with the fast lossless encoder from libjxl
3+
* (lib/jxl/enc_fast_lossless.cc). Copyright (c) the JPEG XL Project Authors, BSD-3-Clause License (see
4+
* THIRD_PARTY_NOTICES.md)
5+
*/
6+
7+
/** Bits in LSB first order, growing as needed */
8+
export class BitWriter {
9+
data = new Uint8Array(256);
10+
length = 0;
11+
private buf = 0;
12+
/** the bits in buf (less than 8 between writes) */
13+
bits = 0;
14+
15+
/** writes the low count (up to 32) bits of v */
16+
write(count: number, v: number): void {
17+
if (count > 24) {
18+
this.write(16, v & 0xffff);
19+
this.write(count - 16, Math.floor(v / 65536));
20+
return;
21+
}
22+
if (this.length + 4 > this.data.length) {
23+
const data = new Uint8Array(this.data.length * 2);
24+
data.set(this.data);
25+
this.data = data;
26+
}
27+
this.buf |= v << this.bits;
28+
this.bits += count;
29+
while (this.bits >= 8) {
30+
this.data[this.length++] = this.buf & 0xff;
31+
this.buf >>>= 8;
32+
this.bits -= 8;
33+
}
34+
}
35+
36+
zeroPadToByte(): void {
37+
if (this.bits) this.write(8 - this.bits, 0);
38+
}
39+
40+
/** the size in bits */
41+
get size(): number {
42+
return this.length * 8 + this.bits;
43+
}
44+
45+
/** appends another writer's bits */
46+
append(w: BitWriter): void {
47+
for (let i = 0; i < w.length; i++) this.write(8, w.data[i]);
48+
if (w.bits) this.write(w.bits, w.buf);
49+
}
50+
51+
bytes(): Uint8Array {
52+
return this.data.subarray(0, this.length);
53+
}
54+
}

0 commit comments

Comments
 (0)