Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/bench.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: bench

# runs the library comparison in bench/ and commits the results (results.json, installs.json, charts, README) to the
# default branch. release.yml runs it after a regular release reached npm, measuring the released version; can also
# be run by hand from the Actions tab, measuring the version bench/package.json has (or the one given)
on:
workflow_call:
inputs:
version:
description: ImageScript version to measure (release.yml passes the released one)
type: string
default: ''
workflow_dispatch:
inputs:
version:
description: ImageScript version to measure (empty keeps bench/package.json's)
type: string
default: ''

permissions:
contents: write

concurrency:
group: bench
cancel-in-progress: false

jobs:
bench:
runs-on: ubuntu-latest
defaults:
run:
working-directory: bench
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- uses: actions/setup-node@v7
with:
node-version: 24.x

- run: npm ci --no-audit --no-fund
# a version that was just published can take a moment to show up on the registry
- if: inputs.version != ''
run: |
for attempt in 1 2 3 4 5; do
npm install --save-exact --no-audit --no-fund "imagescript@$VERSION" && exit 0
sleep 30
done
exit 1
env:
VERSION: ${{ inputs.version }}

- run: node run.js
- run: node installs.js
- run: node report.js

# pushes with GITHUB_TOKEN don't trigger other workflows; rebases onto anything pushed meanwhile
- run: |
git add -A .
if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "benchmark ImageScript $(node -p "require('./node_modules/imagescript/package.json').version")"
for attempt in 1 2 3; do
git push && exit 0
git pull --rebase
done
exit 1
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: release
# publishing a GitHub release with the tag v<version> publishes that version to npm and JSR. The tag is the version:
# the release is checked (semver, changelog, not yet on npm/JSR), tested on every supported platform, published with
# the tag's version to npm (with provenance, through trusted publishing) and JSR (through GitHub OIDC, no tokens),
# then the docs are deployed and, for regular releases, the version is committed to the default branch
# then the docs are deployed and, for regular releases, the version is committed to the default branch and the
# benchmark in bench/ is run against the release
on:
release:
types: [published]
Expand Down Expand Up @@ -110,3 +111,14 @@ jobs:
git push
env:
VERSION: ${{ needs.check.outputs.version }}

# measures the release against the other libraries and commits the results to bench/ on the default branch (after
# the version commit, so the two don't race)
bench:
needs: [check, version]
if: needs.check.outputs.dist-tag == 'latest'
permissions:
contents: write
uses: ./.github/workflows/bench.yml
with:
version: ${{ needs.check.outputs.version }}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ see the [changelog](https://github.com/matmen/ImageScript/blob/master/CHANGELOG.

---

### Benchmarks

[bench/](bench/README.md) compares ImageScript to Jimp, image-js, Photon, sharp and @napi-rs/canvas: speed of decoding,
encoding, image operations and rendering, encoded file sizes and install sizes. It runs for every release.

---

### Credits

The deflate/inflate implementation is derived from [fflate](https://github.com/101arrowz/fflate) and the CRC-32 from
Expand Down
82 changes: 82 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ImageScript benchmark

How [ImageScript](https://github.com/matmen/ImageScript) compares to other image libraries for Node.js: speed, file
sizes and what installing each takes. ImageScript is plain JavaScript with no dependencies, so it runs wherever
JavaScript does; sharp and @napi-rs/canvas wrap native libraries (libvips, Skia), Photon and ImageScript 1 are
WebAssembly.

<img src="charts/install.svg" alt="Install size of each library">

<img src="charts/codecs.svg" alt="Decoding and encoding speed">

<img src="charts/operations.svg" alt="Speed of image operations">

<img src="charts/rendering.svg" alt="Text and SVG rendering speed">

<img src="charts/animation.svg" alt="Animated GIF decoding and encoding speed">

<img src="charts/sizes.svg" alt="Encoded file sizes">

## Numbers

Operations per second (more is faster), measured 2026-10-15 with Node.js v24.11.1 on Intel(R) Core(TM) i3-8109U CPU @ 3.00GHz, ImageScript 3.1.0.

| | **ImageScript 3** | **ImageScript 1** | **Jimp** | **image-js** | **Photon** | **sharp** | **@napi-rs/canvas** |
|---|--:|--:|--:|--:|--:|--:|--:|
| decode JPEG | **9.06** | 3.97 | 4.34 | 5.17 | 16.1 | 37.3 | 42.2 |
| decode PNG | **20.4** | 20.6 | 12.2 | 7.46 | 22.0 | 43.7 | 48.1 |
| decode WebP (lossy) | **7.73** | – | – | – | 7.75 | 30.3 | 53.3 |
| encode JPEG (quality 80) | **9.51** | 14.6 | 1.86 | 6.76 | 6.12 | 49.0 | 30.8 |
| encode PNG | **6.67** | 3.17 | 3.33 | 2.37 | 7.57 | 6.74 | 2.00 |
| encode WebP (lossless) | **0.67** | – | – | – | 17.0 | 0.52 | 1.85 |
| resize to half | **14.9** | 249 | 42.5 | 7.09 | 9.57 | 50.6 | 364 |
| rotate by 30° | **6.39** | 4.22 | 1.20 | 0.85 | 2.64 | 10.6 | 31.7 |
| blur (radius 5) | **4.32** | – | 0.01 | 1.00 | 2.54 | 23.7 | 33.5 |
| composite an overlay | **272** | 408 | 22.4 | 103 | 38.3 | 95.3 | 236 |
| crop | **313** | 428 | 34.4 | 46.3 | 41.1 | 928 | 934 |
| clone | **596** | 575 | 700 | 563 | 42.2 | 712 | 273 |
| render text | **774** | 50.1 | – | – | – | 1,896 | 3,164 |
| render SVG | **3.86** | fails | – | – | – | 12.0 | 51.2 |
| decode animated GIF | **4.95** | 6.77 | – | – | – | 12.5 | – |
| encode animated GIF | **3.39** | 1.01 | – | – | – | 0.80 | – |

Encoded sizes:

| | **ImageScript 3** | **ImageScript 1** | **Jimp** | **image-js** | **Photon** | **sharp** | **@napi-rs/canvas** |
|---|--:|--:|--:|--:|--:|--:|--:|
| photo PNG | 2.2 MB | 3.0 MB | 2.7 MB | 2.8 MB | 2.8 MB | 2.8 MB | 2.1 MB |
| photo JPEG | 217 kB | 257 kB | 260 kB | 260 kB | 257 kB | 218 kB | 218 kB |
| photo WebP | 1.4 MB | – | – | – | 1.7 MB | 1.2 MB | 1.4 MB |
| graphic PNG | 1.1 MB | 802 kB | 2.0 MB | 736 kB | 2.2 MB | 718 kB | 1.0 MB |
| graphic JPEG | 275 kB | 314 kB | 315 kB | 315 kB | 314 kB | 275 kB | 276 kB |
| graphic WebP | 579 kB | – | – | – | 856 kB | 239 kB | 631 kB |
| animation GIF | 1.3 MB | 1.3 MB | – | – | – | 720 kB | – |

| | **ImageScript 3** | **ImageScript 1** | **Jimp** | **image-js** | **Photon** | **sharp** | **@napi-rs/canvas** |
|---|---|---|---|---|---|---|---|
| kind | JavaScript | WebAssembly | JavaScript | JavaScript | WebAssembly | native (libvips) | native (Skia) |
| package | `imagescript@3.1.0` | `imagescript@1.3.1` | `jimp@1.6.1` | `image-js@1.7.1` | `@silvia-odwyer/photon-node@0.3.4` | `sharp@0.35.5` | `@napi-rs/canvas@1.0.11` |
| install size | 639 kB | 6.7 MB | 26.7 MB | 47.8 MB | 2.3 MB | 30.2 MB | 35.0 MB |
| packages | 1 | 1 | 64 | 55 | 1 | 9 | 2 |

## Method

- Inputs (in `inputs/`, sources in [inputs/SOURCES.md](inputs/SOURCES.md)): a 1920×1080 photo (JPEG, and made from it
when the benchmark starts, PNG and lossy WebP), a 1434×1434 graphic with transparency (PNG), a 445×445 diagram with
transparency placed on the photo (PNG), a 300×300 GIF with 101 frames.
- Rendering: "The quick brown fox jumps over the lazy dog, 0123456789" in Source Sans 3 at 64px, and `tests/svgs/features.svg` (gradients, clipping,
CSS) at 1920 pixels wide. Jimp and Photon can't render TrueType/OpenType fonts or SVGs; ImageScript 1 crashes
on this SVG; @napi-rs/canvas (Skia's SVG module) ignores its CSS, so it draws a little less.
- Every operation starts from the same decoded image and returns a new one: libraries that change images in place
clone first, sharp runs a pipeline from raw pixels back to raw pixels, @napi-rs/canvas draws into a new canvas.
- Where the library lets you choose, resizing is bilinear (ImageScript 1 only has nearest neighbor), blurring is
gaussian with a radius or sigma of 5, rotating grows the image to fit. Each library uses its own defaults
otherwise, e.g. its PNG compression level.
- [tinybench](https://github.com/tinylibs/tinybench), at least 1.5 seconds and 3 runs per measurement, every library
in its own process.
- The release workflow runs the benchmark for every release and commits the results, on a GitHub Actions runner;
runners vary, so compare libraries within one run rather than between runs.
- Install sizes: `npm install <library>` in an empty project, the apparent size of `node_modules`.

Run it: `npm install` in this directory, then `node run.js` (on an idle machine; `IMAGESCRIPT=local` measures
this checkout after `npm run build` instead of the version from npm), `node installs.js` and `node report.js`.
67 changes: 67 additions & 0 deletions bench/bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Measures one library (node --expose-gc bench.js <name>) and prints its results as JSON: operations per second for
// decoding, encoding, the image operations and rendering, and the encoded file sizes. run.js runs every library in
// its own process, so one library's memory can't affect the next one's measurements
import fs from 'node:fs';
import sharp from 'sharp';
import { Bench } from 'tinybench';
import { libraries } from './libs.js';

const lib = libraries.find(l => l.name === process.argv[2]);
if (!lib) throw new Error(`unknown library ${process.argv[2]}, one of: ${libraries.map(l => l.name).join(', ')}`);

const files = {
'photo.jpg': fs.readFileSync(new URL('./inputs/photo.jpg', import.meta.url)),
'graphic.png': fs.readFileSync(new URL('./inputs/graphic.png', import.meta.url)),
'overlay.png': fs.readFileSync(new URL('./inputs/overlay.png', import.meta.url)),
'animation.gif': fs.readFileSync(new URL('./inputs/animation.gif', import.meta.url))
};
files['photo.png'] = await sharp(files['photo.jpg']).png().toBuffer();
files['photo.webp'] = await sharp(files['photo.jpg']).webp({ quality: 80 }).toBuffer();

const speed = {}, sizes = {};
const formatName = format => ({ jpeg: 'JPEG', png: 'PNG', webp: 'WebP' })[format];

// collect: canvases hold their pixels in native memory that V8 doesn't count and only free it in finalizers that run
// on a later event-loop turn, which tinybench's loop never yields to. So between iterations (outside the measured
// time) garbage is collected and the loop gets a turn, instead of the pixels piling up until the process is killed
async function measure(task, fn) {
const bench = new Bench({ time: 1500, iterations: 3, warmupTime: 300, warmupIterations: 1 });
bench.add(task, fn, lib.collect && globalThis.gc ? { afterEach: async () => { globalThis.gc(); await new Promise(setImmediate); } } : {});
await bench.run();
const r = bench.tasks[0].result;
if (r.error) throw r.error;
const ms = r.latency?.mean ?? r.mean;
speed[task] = { opsPerSecond: Math.round(1000 / ms * 100) / 100, ms: Math.round(ms * 100) / 100, samples: r.latency?.samples?.length ?? r.samples?.length };
console.error(`${task.padEnd(16)} ${lib.name.padEnd(16)} ${(1000 / ms).toFixed(2).padStart(10)} ops/s ${ms.toFixed(2).padStart(9)} ms`);
}

const decode = data => Promise.resolve().then(() => lib.decode(data));
const photo = await decode(files['photo.jpg']), overlay = await decode(files['overlay.png']);

for (const [task, file] of [['decode JPEG', 'photo.jpg'], ['decode PNG', 'photo.png'], ['decode WebP', 'photo.webp']])
if (await decode(files[file]).then(() => true, () => false)) await measure(task, () => decode(files[file]));
for (const [format, encode] of Object.entries(lib.encode))
await measure(`encode ${formatName(format)}`, () => encode(photo));
for (const op of ['resize', 'rotate', 'blur', 'composite', 'crop', 'clone'])
if (lib[op]) await measure(op, () => lib[op](photo, overlay));
// a library that has the feature but fails on this input (ImageScript 1 on the SVG) is recorded as failing
for (const op of ['text', 'svg']) {
if (!lib[op]) continue;
const error = await Promise.resolve().then(() => lib[op]()).then(() => null, e => e);
if (error) speed[op] = { error: String(error.message ?? error) };
else await measure(op, () => lib[op]());
}
if (lib.animation) {
const animation = await lib.animation.decode(files['animation.gif']);
await measure('decode GIF', () => lib.animation.decode(files['animation.gif']));
await measure('encode GIF', () => lib.animation.encode(animation));
}

// encoded sizes, with each library's default settings (JPEG at quality 80, WebP lossless)
for (const [name, file] of [['photo', 'photo.jpg'], ['graphic', 'graphic.png']]) {
const image = await decode(files[file]);
for (const [format, encode] of Object.entries(lib.encode)) sizes[`${name} ${formatName(format)}`] = (await encode(image)).length;
}
if (lib.animation) sizes['animation GIF'] = (await lib.animation.encode(await lib.animation.decode(files['animation.gif']))).length;

console.log(JSON.stringify({ speed, sizes }));
15 changes: 15 additions & 0 deletions bench/charts/animation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading