-
Notifications
You must be signed in to change notification settings - Fork 0
Previews and Performance
This is the page that matters if you are building anything interactive. It is the reason the library exists, and it contains one trap that will silently cost you 1.6×.
A 12MP photo is 4000×3000. Decoding it fully means inverse-transforming 187,500 blocks and allocating 48 MB. Then rotating it resamples all 12 million pixels. Measured, on a 12MP JPEG:
| Time | |
|---|---|
decode() at full resolution |
224ms |
| crop + rotate at full resolution | ~150ms |
Do that on every frame of a slider drag and you have a slideshow. That measurement — a ~260ms full-resolution rotate causing visible stutter — is what started this project.
Do not make the rotate faster. Stop doing it at full resolution sixty times a second.
// 1. On open: decode ONCE, small. Hold it.
const preview = await decode(bytes, { hintMaxLongEdge: 1600 });
// 2. On every interaction: transform THAT. No re-decode, no encode.
const frame = rotate(crop(preview, rect), angle);
// push `frame` at a canvas
// 3. On save: full resolution, once. The user hit save; 600ms is fine.
const saved = await SImg.pipeline(spec).run(bytes);Measured, same photo:
| Time | ||
|---|---|---|
decode(bytes, { hintMaxLongEdge: 1600 }) |
37ms | 1000×750 |
| crop + rotate on the preview | ~10ms | inside a 16ms frame |
| the same frame at full resolution | 153ms | what it replaces |
Run it yourself: npm run example:preview.
Step 2 never encodes. Worth saying twice: the preview loop is decode-once,
transform-per-frame. A preview that re-encodes a JPEG every frame would be slower than the
problem it is fixing. toBuffer() is not in the loop.
JPEG stores 8×8 blocks of frequency coefficients. To decode at half size you inverse-transform only the top-left 4×4 of each block — at a quarter, the 2×2; at an eighth, just the DC coefficient, which is the block's mean.
The transform gets cheaper, not skipped. That is the difference between this and decode-then-resize, which pays the full cost and then adds a resize on top.
There is an honest ceiling: the entropy decode does not get faster. JPEG's Huffman stream has to be walked in full whatever size you want out of it, and at 1/8 scale it is most of what is left. That is why 224ms → 37ms rather than → 4ms.
hintMaxLongEdgeis a hint. Read the real size off the result. Never compute it.
DCT scaling only does powers of two. So a 4000px photo hinted at 1600 comes back at 1000, not 1600 — because 1/2 would give 2000, which is over the cap, so it takes 1/4.
const preview = await decode(bytes, { hintMaxLongEdge: 1600 });
preview.width; // 1000. Not 1600.Now map a crop rectangle the user drew on the preview back to source coordinates:
// WRONG — off by 1.6×, silently, on every JPEG
const scale = 4000 / 1600; // 2.5
// RIGHT
const scale = probe(bytes).width / preview.width; // 4Both produce a plausible crop. One produces the wrong region. Nothing throws, no test you did not write for it fails, and the bug only shows up as "the saved image is not quite what I selected".
probe() exists for exactly this: real source dimensions in microseconds, no decode.
The contract is at or under the hint, never over — for every format, including the fallback path. So a hint of 200 on a 4000px JPEG gives you 200 (the DCT gets to 500, then a resize finishes the job).
The win varies enormously, and pretending otherwise would be dishonest:
| Format | Native downsample | |
|---|---|---|
| JPEG | yes, big win | DCT scaling. Nearly free, and the format that matters most |
| PNG | no | DEFLATE is a byte stream; you cannot inflate a quarter of it |
| GIF | no | LZW, same problem |
| WebP | no | not exposed through the WASM build used here |
| BMP | partial | uncompressed, so rows can be skipped |
| TIFF | partial | same, when uncompressed |
For the rest, decode(bytes, { hintMaxLongEdge }) decodes fully, resizes, and releases the
full-resolution buffer. The decode itself is not faster. You still get a small image, and
you still save on every downstream transform, which is where most of the cost is anyway.
One honest limitation: the fallback still allocates the full 48 MB before downsampling. On a memory-constrained machine, opening a huge PNG preview costs the same as opening it fully.
The trick that keeps the preview and the save honest is that they run the same object:
const spec = SImg.pipeline()
.crop({ x: drawn.x * scale, y: drawn.y * scale, width: drawn.width * scale, height: drawn.height * scale })
.rotate(20)
.toFormat('jpeg', { quality: 88 })
.toJSON();
await SImg.pipeline(spec).run(bytes); // full resolutionThere is no second code path to drift out of sync with the first.
| Kernel | Use it for |
|---|---|
nearest |
pixel art, or when you want speed and do not care |
bilinear |
the default, and the right choice for a preview |
lanczos3 |
a final save, where sharpness matters and 6 taps per axis is affordable |
Bilinear on the preview path, lanczos3 on save, is a reasonable default pair.
npm run bench:preview # the whole preview path, on a generated 12MP JPEGTimings are from an M-series laptop and are directional, not a contract. The contract is an instrumented counter in the test suite proving the reduced transform actually runs — a stopwatch test would be flaky, and that fact is not.
Pure TypeScript image editing for Node and Bun. Core: 23.2 KB min+gzip, replacing a 7 MB ImageMagick bundle.
Using it
Working on it