Skip to content

Getting Started

Abdulkader Safi edited this page Jul 16, 2026 · 2 revisions

Getting Started

The shape of it

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.

Everything is async

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.

The order is canonical

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.

The chain versus the pipeline

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.

A pipeline is plain JSON

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"

Transparency and backgrounds

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.

Reading the size

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.

Previews

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.

What this build can do

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 touch

pending means "not loaded yet, expected to work". unavailable means it tried and failed, and it will not try again.

Files

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.

Next

Clone this wiki locally