-
Notifications
You must be signed in to change notification settings - Fork 0
Getting Started
Bytes in, bytes out. Uint8Array both ways.
import { SImg } from 's-img';
const out = await SImg.fromBuffer(bytes)
.resize({ width: 800 })
.toFormat('jpeg', { quality: 80 })
.toBuffer();Nothing happens until toBuffer(). The chain builds a description; toBuffer() runs it.
Even a PNG resize, which needs no async work at all. WebP's WASM has to load sometime, and
an API that is sometimes async is worse than one that always is — it means every caller
writes the await anyway but only finds out on the one code path that forgot.
crop → rotate → flip → resize → format, always, regardless of the order you call the methods in. These two are identical:
SImg.fromBuffer(b).resize({ width: 400 }).crop({ x: 0, y: 0, width: 800, height: 600 });
SImg.fromBuffer(b).crop({ x: 0, y: 0, width: 800, height: 600 }).resize({ width: 400 });Both crop first, then resize. That is a deliberate design decision, not an accident: a chain that means different things depending on call order is a chain you have to read twice, and the crop rectangle a user drew is in source coordinates, so cropping after a resize would mean something they never asked for.
If you genuinely need resize-then-crop, that is two chains.
A chain edits one image:
await SImg.fromBuffer(bytes).rotate(90).toFormat('png').toBuffer();A pipeline is a recipe, with no image attached:
const preset = SImg.pipeline().maxLongEdge(600).stripMetadata().toFormat('jpeg', { quality: 75 });
for (const file of files) {
await toFile(await preset.run(await readFile(file)), `out/${file}`);
}Build it once, run it on anything, as many times as you like.
This is the useful part. toJSON() gives you an object of plain values — no functions, no
class instances, no buffers — so it survives a settings file and a reload.
localStorage.setItem('preset', JSON.stringify(preset.toJSON()));
// later, or on another machine, or after an app restart
const restored = SImg.pipeline(JSON.parse(localStorage.getItem('preset')!));
await restored.run(bytes);SImg.pipeline(spec) validates what you hand it. A spec out of a settings file is
untrusted input — someone hand-edited it, or it was written by an older version — so a bad
field throws InvalidOptionError naming the field, right there, rather than
failing weirdly three steps later inside a transform:
SImg.pipeline(JSON.parse('{"version":1,"resize":{"maxLongEdge":"banana"}}'));
// InvalidOptionError: pipeline.resize.maxLongEdge must be a number, got "banana"Rotating by 8° exposes corners. Converting a PNG to JPEG drops an alpha channel JPEG cannot store. Both need to know what colour goes underneath, and there is one rule:
await SImg.fromBuffer(png)
.rotate(8)
.background([255, 255, 255, 255]) // white, [r, g, b, a]
.toFormat('jpeg')
.toBuffer();background is decided at encode time, where the format is actually known. Without it, the
default is white. Set it on the pipeline and it covers both the exposed corners and the
alpha compositing — one setting, one rule to learn.
If you want the corners filled immediately, even on a format that supports alpha, pass
background to rotate itself instead.
probe() reads the header. No pixel buffer, microseconds:
import { probe } from 's-img';
probe(bytes); // { width: 4000, height: 3000 }Use it whenever you need dimensions and not pixels — sizing a canvas, a file list, deciding whether something is worth decoding at all.
One asymmetry worth knowing: probe() reports the dimensions as stored, while decode()
applies EXIF orientation. A photo tagged "rotate 90" probes as 4000×3000 and decodes to
3000×4000. Ratios are unaffected, because both edges scale by the same factor.
If you are building anything interactive, read Previews and Performance — it is the reason this library exists, and it has one trap in it that will silently cost you 1.6×.
The short version:
// once, on open
const preview = await decode(bytes, { hintMaxLongEdge: 1600 });
// per frame: transform `preview`. No re-decode. No encode.
// on save: SImg.pipeline(spec).run(bytes) — full resolution, once.WebP arrives as a WASM module, so it can genuinely be unavailable in a way PNG never is. Ask rather than assume:
import { supportedFormats, preload } from 's-img';
supportedFormats();
// { read: [...], write: [...], pending: ['webp'], unavailable: [] }
await preload('webp'); // optional: load it now instead of on first touchpending means "not loaded yet, expected to work". unavailable means it tried and failed,
and it will not try again.
The core never touches the filesystem — that is what keeps it portable. fromFile/toFile
are a thin shim over the same chain, and the only part of the library that imports node:fs:
import { fromFile, toFile } from 's-img';
const chain = await fromFile('photo.jpg');
await toFile(await chain.maxLongEdge(800).toFormat('webp').toBuffer(), 'photo.webp');A missing file throws Node's ENOENT unwrapped, not an SImgError. That is deliberate: a
missing file is not an image problem, and Node's message is better than anything written over
the top of it.
- API Reference — every export.
- Formats — per-format options and quirks.
- Errors — what can go wrong.
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