If you ship anything with user-generated or product imagery, you eventually need to put one image on top of another: a logo in the corner, a “SAMPLE” stamp across a preview, a badge on a product shot, a gradient blended over a hero photo.
This usually means reaching for sharp and stitching together its composite API, or dropping down to node-canvas and doing the math by hand. The latest release of imgkit (v2.3.0) adds a dedicated composite() for exactly this — Rust-backed, async off the main thread, and the same on Node.js and Bun.
Install
bun add imgkit
# or
npm install imgkit
Enter fullscreen mode Exit fullscreen mode
Prebuilt binaries ship for the common platforms, so there’s no build step on install.
The hello-world: a corner watermark
The most common case — a semi-transparent logo anchored to the bottom-right corner, nudged in from the edges:
import { composite, resize } from 'imgkit';
const photo = Buffer.from(await Bun.file('photo.jpg').arrayBuffer());
const logoRaw = Buffer.from(await Bun.file('logo.png').arrayBuffer());
// Scale the logo first, then composite
const logo = await resize(logoRaw, { width: 180 });
const watermarked = await composite(photo, {
layers: [
{ input: logo, gravity: 'southEast', opacity: 0.7, offsetX: -32, offsetY: -32 },
],
output: { format: 'jpeg', jpeg: { quality: 90 } },
});
await Bun.write('watermarked.jpg', watermarked);
Enter fullscreen mode Exit fullscreen mode
That’s the whole API surface for the simple case. Everything else is just more layers and more options.
The mental model
composite() paints an array of layers onto a base image. The rules are small enough to keep in your head:
- Layers paint in array order — the first layer is the bottom-most overlay, the last sits on top.
-
Placement is either gravity or absolute. Use
gravity(center,north,southEast, etc.) to anchor a layer to a region, with optionaloffsetX/offsetYnudges. Or setleft/topfor pixel-precise placement — those override gravity, and off-canvas/negative values are clipped for you. -
opacity(0.0–1.0) fades a layer;resizescales it before compositing;tile: truerepeats it across the whole base. -
blendpicks the blend mode —over(default),multiply,screen,overlay,darken,lighten, oradd, following the standard W3C separable blend formulas.
Recipes
Tiled “SAMPLE” / “DRAFT” stamp across the whole image:
const tiled = await composite(photo, {
layers: [{ input: stamp, tile: true, opacity: 0.15 }],
});
Enter fullscreen mode Exit fullscreen mode
Blend a gradient or texture over a photo:
const blended = await composite(photo, {
layers: [{ input: gradient, blend: 'multiply', opacity: 0.6 }],
});
Enter fullscreen mode Exit fullscreen mode
Stack several layers — a product on a background, a badge top-right, a faded logo bottom-left:
const out = await composite(background, {
layers: [
{ input: product, gravity: 'center' },
{ input: badge, gravity: 'northEast', resize: { width: 80 } },
{ input: logo, gravity: 'southWest', opacity: 0.8 },
],
output: { format: 'webp', webp: { quality: 90 } },
});
Enter fullscreen mode Exit fullscreen mode
A couple of things worth knowing
- The base is decoded to RGBA, so the result preserves transparency by default —
composite()returns PNG unless you setoutput. Choosing a JPEG output flattens any alpha (it’s dropped, not matted), which is usually what you want for a final watermarked photo. - For lots of layers or large canvases, the async
composite()runs off the main thread and can be cancelled with anAbortSignalor atimeoutMsviaAsyncOptions. There’s acompositeSync()if you’d rather stay synchronous. - Fully transparent source pixels and fully opaque
overpaints take fast paths, so the common watermark case stays cheap.
Why I built it this way
imgkit’s whole point is to keep the fast path in Rust (via napi-rs) while exposing an API that feels native to JS — one function, plain options objects, Buffers in and out, identical behavior on Node and Bun. composite() follows that: no canvas, no manual pixel loops, no separate paths for “watermark” vs “blend” vs “tile.” They’re all just layers.
If you want the full option reference and more recipes, the docs are here:
If you try it, I’d love to hear what you’re compositing — and issues/stars on GitHub are always welcome.
답글 남기기