Skip to content

Commit eae6c43

Browse files
matmenclaude
andcommitted
bench: AVIF and JPEG XL
Decoding and encoding AVIF and JPEG XL, in a chart of their own: the inputs made when the benchmark starts (AVIF by sharp/libaom, JPEG XL by libjxl through jsquash, at the benchmark's qualities). Encoders run at the same nominal quality (AVIF 60, JPEG XL 90); as the libraries' scales differ, the sizes table gives their PSNR (color over black, as libavif and libjxl decode them). New library jsquash (@jsquash/avif and @jsquash/jxl: Squoosh's WebAssembly builds, single threaded in Node; it gets the other inputs as pixels). sharp and PureJsImage encode and decode AVIF; a format a library reads but fails on (PureJsImage on libjxl's file) is recorded as failing. installs.js installs all of a library's packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent fc1dcfc commit eae6c43

7 files changed

Lines changed: 134 additions & 33 deletions

File tree

‎bench/bench.js‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import fs from 'node:fs';
55
import sharp from 'sharp';
66
import { Bench } from 'tinybench';
7-
import { libraries } from './libs.js';
7+
import { libraries, referenceDecode, AVIF_QUALITY } from './libs.js';
88

99
const lib = libraries.find(l => l.name === process.argv[2]);
1010
if (!lib) throw new Error(`unknown library ${process.argv[2]}, one of: ${libraries.map(l => l.name).join(', ')}`);
@@ -17,9 +17,14 @@ const files = {
1717
};
1818
files['photo.png'] = await sharp(files['photo.jpg']).png().toBuffer();
1919
files['photo.webp'] = await sharp(files['photo.jpg']).webp({ quality: 80 }).toBuffer();
20+
// (and AVIF by libaom, JPEG XL by libjxl: sharp's and jsquash's encoders at the benchmark's qualities)
21+
files['photo.avif'] = await sharp(files['photo.jpg']).avif({ quality: AVIF_QUALITY }).toBuffer();
22+
const rawOf = async file => { const { data, info } = await sharp(files[file]).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); return { data, width: info.width, height: info.height }; };
23+
const pixels = { 'photo.jpg': await rawOf('photo.jpg'), 'graphic.png': await rawOf('graphic.png'), 'overlay.png': await rawOf('overlay.png') };
24+
files['photo.jxl'] = new Uint8Array(await (libraries.find(l => l.name === 'jsquash').encode.jxl)({ ...pixels['photo.jpg'], data: new Uint8ClampedArray(pixels['photo.jpg'].data) }));
2025

2126
const speed = {}, sizes = {};
22-
const formatName = format => ({ jpeg: 'JPEG', png: 'PNG', webp: 'WebP' })[format];
27+
const formatName = format => ({ jpeg: 'JPEG', png: 'PNG', webp: 'WebP', avif: 'AVIF', jxl: 'JPEG XL' })[format];
2328

2429
// collect: canvases (native memory) and Photon images (WebAssembly memory) hold their pixels outside the V8 heap and
2530
// only free them in finalizers that run on a later event-loop turn, which tinybench's loop never yields to. So between
@@ -37,10 +42,16 @@ async function measure(task, fn) {
3742
}
3843

3944
const decode = data => Promise.resolve().then(() => lib.decode(data));
40-
const photo = await decode(files['photo.jpg']), overlay = await decode(files['overlay.png']);
45+
// (libraries without the inputs' formats get their pixels)
46+
const input = file => lib.fromPixels ? lib.fromPixels(pixels[file]) : decode(files[file]);
47+
const photo = await input('photo.jpg'), overlay = await input('overlay.png');
4148

42-
for (const [task, file] of [['decode JPEG', 'photo.jpg'], ['decode PNG', 'photo.png'], ['decode WebP', 'photo.webp']])
43-
if (await decode(files[file]).then(() => true, () => false)) await measure(task, () => decode(files[file]));
49+
// a format the library reads but not this file (PureJsImage's JPEG XL) is recorded as failing
50+
for (const [task, file, format] of [['decode JPEG', 'photo.jpg'], ['decode PNG', 'photo.png'], ['decode WebP', 'photo.webp'], ['decode AVIF', 'photo.avif', 'avif'], ['decode JPEG XL', 'photo.jxl', 'jxl']]) {
51+
const error = await decode(files[file]).then(() => null, e => e);
52+
if (!error) await measure(task, () => decode(files[file]));
53+
else if (lib.decodes?.includes(format)) speed[task] = { error: String(error.message ?? error) };
54+
}
4455
for (const [format, encode] of Object.entries(lib.encode))
4556
await measure(`encode ${formatName(format)}`, () => encode(photo));
4657
for (const op of ['resize', 'rotate', 'blur', 'composite', 'crop', 'clone'])
@@ -58,11 +69,25 @@ if (lib.animation) {
5869
await measure('encode GIF', () => lib.animation.encode(animation));
5970
}
6071

61-
// encoded sizes, with each library's default settings (JPEG at quality 80, WebP lossless)
72+
// encoded sizes, with each library's default settings (JPEG at quality 80, WebP lossless, AVIF and JPEG XL at the same
73+
// quality: with their PSNR, of the color over black, as libavif and libjxl decode them)
74+
const quality = {};
75+
const psnr = (a, b) => {
76+
let se = 0;
77+
for (let i = 0; i < a.length; i += 4) for (let c = 0; c < 3; c++) {
78+
const d = a[i + c] * a[i + 3] / 255 - b[i + c] * b[i + 3] / 255;
79+
se += d * d;
80+
}
81+
return Math.round(10 * Math.log10(255 * 255 * 3 * a.length / 4 / se) * 10) / 10;
82+
};
6283
for (const [name, file] of [['photo', 'photo.jpg'], ['graphic', 'graphic.png']]) {
63-
const image = await decode(files[file]);
64-
for (const [format, encode] of Object.entries(lib.encode)) sizes[`${name} ${formatName(format)}`] = (await encode(image)).length;
84+
const image = await input(file);
85+
for (const [format, encode] of Object.entries(lib.encode)) {
86+
const encoded = new Uint8Array(await encode(image));
87+
sizes[`${name} ${formatName(format)}`] = encoded.length;
88+
if (format === 'avif' || format === 'jxl') quality[`${name} ${formatName(format)}`] = psnr(pixels[file].data, await referenceDecode(encoded, format));
89+
}
6590
}
6691
if (lib.animation) sizes['animation GIF'] = (await lib.animation.encode(await lib.animation.decode(files['animation.gif']))).length;
6792

68-
console.log(JSON.stringify({ speed, sizes }));
93+
console.log(JSON.stringify({ speed, sizes, quality }));

‎bench/installs.js‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// Measures what installing each library takes: npm install <package>@<version> in an empty project (the versions
2-
// installed here), then the apparent size of node_modules and the number of packages in it. Writes installs.json
1+
// Measures what installing each library takes: npm install <package>@<version> (all of a library's packages) in an
2+
// empty project (the versions installed here), then the apparent size of node_modules and the number of packages in it. Writes installs.json
33
import { execFileSync } from 'node:child_process';
44
import fs from 'node:fs';
55
import os from 'node:os';
@@ -13,14 +13,14 @@ const size = dir => fs.readdirSync(dir, { withFileTypes: true }).reduce((sum, en
1313

1414
const installs = {};
1515
for (const lib of libraries) {
16-
const installed = JSON.parse(fs.readFileSync(new URL(`./node_modules/${lib.package}/package.json`, import.meta.url)));
16+
const installed = (lib.packages ?? [lib.package]).map(p => JSON.parse(fs.readFileSync(new URL(`./node_modules/${p}/package.json`, import.meta.url))));
1717
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imagescript-bench-'));
1818
try {
1919
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
20-
execFileSync('npm', ['install', `${installed.name}@${installed.version}`, '--no-audit', '--no-fund', '--loglevel=error'], { cwd: dir, stdio: 'inherit' });
20+
execFileSync('npm', ['install', ...installed.map(i => `${i.name}@${i.version}`), '--no-audit', '--no-fund', '--loglevel=error'], { cwd: dir, stdio: 'inherit' });
2121
const lock = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json')));
2222
installs[lib.name] = {
23-
package: `${installed.name}@${installed.version}`,
23+
package: installed.map(i => `${i.name}@${i.version}`).join(' '),
2424
bytes: size(path.join(dir, 'node_modules')),
2525
// the lockfile also lists optional packages for other platforms, which aren't installed
2626
packages: Object.keys(lock.packages).filter(p => p.startsWith('node_modules/') && fs.existsSync(path.join(dir, p))).length

‎bench/libs.js‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ const photon = require('@silvia-odwyer/photon-node');
2020
const { createImageLibrary } = await import('purejsimage');
2121
const { allCodecs } = await import('purejsimage/codecs/all');
2222
const pureJsImage = createImageLibrary(allCodecs);
23+
// jsquash: Squoosh's libavif and libjxl builds (their threaded builds are off in Node: one thread), the modules given
24+
const jsquash = {};
25+
for (const [name, file] of [['avif/encode', 'avif/codec/enc/avif_enc.wasm'], ['avif/decode', 'avif/codec/dec/avif_dec.wasm'],
26+
['jxl/encode', 'jxl/codec/enc/jxl_enc.wasm'], ['jxl/decode', 'jxl/codec/dec/jxl_dec.wasm']]) {
27+
const codec = await import(`@jsquash/${name}.js`);
28+
await codec.init(await WebAssembly.compile(fs.readFileSync(new URL(`./node_modules/@jsquash/${file}`, import.meta.url))));
29+
jsquash[name] = codec.default;
30+
}
31+
32+
/** libavif's or libjxl's RGBA of an AVIF or JPEG XL (for the quality of the encoded files) */
33+
export const referenceDecode = async (data, format) => (await jsquash[`${format}/decode`](data)).data;
34+
35+
// lossy AVIF and JPEG XL at the same nominal quality in every library (their scales differ: the file sizes come with
36+
// their PSNR)
37+
export const AVIF_QUALITY = 60, JXL_QUALITY = 90;
2338

2439
// the same work in every library: halve the size (bilinear where it can be chosen), rotate by 30° (growing the
2540
// image to fit), blur with a radius/sigma of 5, place a semi-transparent overlay, cut out a quarter
@@ -33,13 +48,15 @@ const font = fs.readFileSync(FONT_FILE), svg = fs.readFileSync(SVG_FILE, 'utf8')
3348
const svgScale = SVG_WIDTH / Number(svg.match(/<svg[^>]*\swidth="(\d+)/)[1]);
3449
const svgAtWidth = svg.replace(/<svg[^>]*>/, tag => tag.replace(/\s(width|height)="(\d+)"/g, (_, name, value) => ` ${name}="${Math.round(value * svgScale)}"`));
3550

36-
const imageScript = (IS, { version, package: pkg, kind, blur = true, webp = true, bilinear = true }) => ({
51+
const imageScript = (IS, { version, package: pkg, kind, blur = true, webp = true, bilinear = true, modern = true }) => ({
3752
name: `ImageScript ${version}`, package: pkg, kind,
53+
...modern && { decodes: ['avif', 'jxl'] },
3854
decode: data => IS.Image.decode(data),
3955
encode: {
4056
png: image => image.encode(),
4157
jpeg: image => image.encodeJPEG(80),
42-
...webp && { webp: image => image.encodeWebP() }
58+
...webp && { webp: image => image.encodeWebP() },
59+
...modern && { avif: image => image.encodeAVIF({ quality: AVIF_QUALITY }), jxl: image => image.encodeJXL({ quality: JXL_QUALITY }) }
4360
},
4461
resize: image => image.clone().resize(...RESIZE, bilinear ? IS.Image.RESIZE_BILINEAR : IS.Image.RESIZE_NEAREST_NEIGHBOR),
4562
rotate: image => image.clone().rotate(ANGLE),
@@ -71,7 +88,7 @@ const drawn = (width, height, draw) => {
7188

7289
export const libraries = [
7390
imageScript(ImageScript, { version: '3', package: 'imagescript', kind: 'JavaScript' }),
74-
imageScript(ImageScriptV1, { version: '1', package: 'imagescript-v1', kind: 'WebAssembly', blur: false, webp: false, bilinear: false }),
91+
imageScript(ImageScriptV1, { version: '1', package: 'imagescript-v1', kind: 'WebAssembly', blur: false, webp: false, bilinear: false, modern: false }),
7592
{
7693
name: 'Jimp', package: 'jimp', kind: 'JavaScript',
7794
decode: data => Jimp.fromBuffer(data),
@@ -104,12 +121,14 @@ export const libraries = [
104121
// PureJsImage works on pipelines from encoded data to encoded data, without decoded images: a decoded image is
105122
// a BMP, its fastest uncompressed format (a BMP to BMP pipeline takes about 40 ms on the photo), and every
106123
// operation runs a pipeline from it back to a BMP, like sharp's raw pixels
107-
name: 'PureJsImage', package: 'purejsimage', kind: 'JavaScript',
124+
name: 'PureJsImage', package: 'purejsimage', kind: 'JavaScript', decodes: ['avif', 'jxl'],
108125
decode: async data => toBmp(await pureJsImage.open(data)),
109126
encode: {
110127
png: async image => (await pureJsImage.open(image)).png().toUint8Array(),
111128
jpeg: async image => (await pureJsImage.open(image)).jpeg({ quality: 80 }).toUint8Array(),
112-
webp: async image => (await pureJsImage.open(image)).webp({ lossless: true }).toUint8Array()
129+
webp: async image => (await pureJsImage.open(image)).webp({ lossless: true }).toUint8Array(),
130+
// (it has no quality option)
131+
avif: async image => (await pureJsImage.open(image)).avif().toUint8Array()
113132
},
114133
resize: async image => toBmp((await pureJsImage.open(image)).resize({ width: RESIZE[0], height: RESIZE[1], kernel: 'bilinear', fit: 'fill' })),
115134
rotate: async image => toBmp((await pureJsImage.open(image)).rotate(ANGLE, { background: 'transparent' })),
@@ -132,12 +151,13 @@ export const libraries = [
132151
clone: image => clonePhoton(image)
133152
},
134153
{
135-
name: 'sharp', package: 'sharp', kind: 'native (libvips)',
154+
name: 'sharp', package: 'sharp', kind: 'native (libvips)', decodes: ['avif'],
136155
decode: async data => toRaw(sharp(data).ensureAlpha()),
137156
encode: {
138157
png: image => pipeline(image).png().toBuffer(),
139158
jpeg: image => pipeline(image).jpeg({ quality: 80 }).toBuffer(),
140-
webp: image => pipeline(image).webp({ lossless: true }).toBuffer()
159+
webp: image => pipeline(image).webp({ lossless: true }).toBuffer(),
160+
avif: image => pipeline(image).avif({ quality: AVIF_QUALITY }).toBuffer()
141161
},
142162
resize: image => toRaw(pipeline(image).resize(...RESIZE, { kernel: 'linear', fit: 'fill' })),
143163
rotate: image => toRaw(pipeline(image).rotate(ANGLE, { background: { r: 0, g: 0, b: 0, alpha: 0 } })),
@@ -156,6 +176,16 @@ export const libraries = [
156176
.gif({ delay: info.delay, loop: info.loop }).toBuffer()
157177
}
158178
},
179+
{
180+
// AVIF and JPEG XL only: a decoded image is an ImageData, made from raw pixels for the other inputs
181+
name: 'jsquash', package: '@jsquash/avif', packages: ['@jsquash/avif', '@jsquash/jxl'], kind: 'WebAssembly', decodes: ['avif', 'jxl'],
182+
fromPixels: ({ data, width, height }) => ({ data: new Uint8ClampedArray(data), width, height, colorSpace: 'srgb' }),
183+
decode: data => data[4] === 0x66 && data[5] === 0x74 ? jsquash['avif/decode'](data) : jsquash['jxl/decode'](data),
184+
encode: {
185+
avif: image => jsquash['avif/encode'](image, { quality: AVIF_QUALITY }),
186+
jxl: image => jsquash['jxl/encode'](image, { quality: JXL_QUALITY })
187+
}
188+
},
159189
{
160190
name: '@napi-rs/canvas', package: '@napi-rs/canvas', kind: 'native (Skia)', collect: true,
161191
decode: async data => { const image = await canvas.loadImage(data); return drawn(image.width, image.height, ctx => ctx.drawImage(image, 0, 0)); },

‎bench/package-lock.json‎

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎bench/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
"bench": "node run.js && node installs.js && node report.js"
88
},
99
"dependencies": {
10+
"@jsquash/avif": "2.1.1",
11+
"@jsquash/jxl": "1.3.0",
1012
"@napi-rs/canvas": "1.0.11",
1113
"@silvia-odwyer/photon-node": "0.3.4",
1214
"image-js": "1.7.1",

0 commit comments

Comments
 (0)