Skip to content

Image engines

View as Markdown

StoryShelf compares screenshots with pixelmatch. Around that comparison, something has to decode the two PNGs, encode the overlay of a failing diff, and shrink screenshots into thumbnails for AI insights. That something is the image engine:

EngineWhat it isInstallRuns on
pngjs (default)Pure JavaScriptBuilt inEverywhere Node runs, including serverless and edge-ish hosts
sharpNative (libvips)npm install sharpPlatforms with a prebuilt sharp binary

The engine only changes how PNGs are read and written. The comparison is pixelmatch either way, so verdicts and differing-pixel counts are the same on both engines.

createShelfApp({
// ...
testing: { images: { engine: "sharp" } },
});

or set STORYSHELF_IMAGE_ENGINE=sharp (the option wins over the variable). Install sharp next to StoryShelf; it is an optional peer dependency.

If the name is unknown, or sharp is not installed or has no binary for the platform, StoryShelf refuses to start (readiness fails under images) instead of quietly falling back, so a typo never changes your diffs without anyone noticing. The engine in use is logged on each capture attempt’s render pass finished line.

Where the engine applies:

  • The server (createShelfApp) uses testing.images.engine for diffs, overlays, and AI thumbnails.
  • A forked capture child inherits it from the server (and testing.compare.threads too), so you set it once.
  • Every other instance reads its own testing.images option, or STORYSHELF_IMAGE_ENGINE from its environment, so give all instances behind one database the same value.

With sharp, each diff-pool worker thread limits libvips to one thread, so a busy server is not oversubscribed.

The engine is chosen per deployment, not per project. Baselines are stored PNGs and every build is diffed fresh against them, so you can switch engines at any time without re-approving anything.

Both engines decode the common PNG types to identical pixels (RGB, RGBA, greyscale, palette, interlaced, gamma-tagged), and the test suite checks that diff results match. The one difference is 16-bit PNGs, which round to 8 bits slightly differently (at most one level out of 255, well under the default pixel threshold). Browser screenshots are 8-bit, so you will not meet this in practice. The diff overlay image is pixel-identical; only the PNG file bytes differ.

Median milliseconds on an Apple M1 Max (Node 24). Screenshots are the StoryShelf UI at 1920 px wide, either a single viewport or a stacked full page. Each engine runs one diff at a time (diffs run on a worker pool, so a busy server does many at once).

Viewport (1920×1050)

SteppngjssharpSpeed-up
Decode a PNG1744.6x
Encode a diff overlay110911.9x
Diff: noise only (passes)41172.4x
Diff: small change176364.9x
Diff: everything changed3552381.5x

Full page (1920×7320)

SteppngjssharpSpeed-up
Decode a PNG105244.3x
Encode a diff overlay7646412.0x
Diff: noise only (passes)247803.1x
Diff: small change11532374.9x
Diff: everything changed251016901.5x

Where the gain comes from: decoding and encoding PNGs is what sharp accelerates, and a diff that passes spends most of its time decoding. When nearly every pixel differs, the pixelmatch comparison itself dominates and the gain shrinks to about 1.5x. Expect the biggest improvement on large full-page screenshots and on builds with many small changes.

The numbers depend on your hardware and screenshots. Reproduce them on yours:

Terminal window
cd packages/image
nub run bench # the StoryShelf UI screenshots
nub run bench -- ./my-shots # a folder of NAME-light.png / NAME-dark.png pairs
  • Stay on pngjs if you want zero native dependencies, deploy to a runtime without sharp binaries, or your builds are small. It needs no setup.
  • Switch to sharp if capture or re-diff time is dominated by large screenshots, and you run on Linux, macOS, or Windows where sharp installs cleanly.