Skip to content
Lord-SamuelPublic

About

pixcore is a pure JavaScript image processing library for Nodejs, designed to be lightweight and fast.

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Latest commit

 

History

59 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

pixcore

npm version license node

A fast, chainable image processing and canvas library for Node.js — decode, transform, draw, and encode, with first-class support for WhatsApp-style stickers (including animated ones) and color emoji text rendering.

No native compilation step. No prebuilt binaries to fetch per-platform. Just JavaScript and WebAssembly.

import pix from 'pixcore'

const sticker = await (await pix.read(imageBuffer))
  .resize(512, 512, { fit: 'contain' })
  .sharpen(1.2)
  .webp({ quality: 90 })
  .toBuffer({
    exif: { packname: 'My Pack', author: 'Me', categories: ['😀', '🎉'] }
  })

Why pixcore

  • Zero native dependencies. Decoding/encoding runs on pure JS (pngjs, jpeg-js) and WebAssembly (@jsquash/webp) — no node-gyp, no platform-specific prebuilt binaries. Install and go, including on serverless/edge runtimes where that matters most.
  • A real canvas API. Shapes, gradients, strokes, and text — including color emoji — with no dependency on node-canvas/Cairo.
  • Sticker-aware, animated included. Purpose-built WebP EXIF metadata writing for WhatsApp sticker packs (pack ID, name, publisher, emoji tags, and more), plus full animated WebP decode/transform/encode — not bolted on as an afterthought.
  • Chainable, promise-based API in the shape you'd expect if you've used sharp.

pixcore is not trying to out-benchmark sharp at raw pixel throughput — sharp wraps libvips, native C++ with SIMD, and that's a structural advantage pure JS/WASM can't close. Where pixcore earns its place is zero-native-dependency installs, and a feature set (canvas + emoji + sticker metadata + animated WebP) sharp doesn't have at all.

Installation

npm install pixcore

Requires Node.js 16+. Ships as an ES module (import, not require).

Quick start

import pix from 'pixcore'
import fs from 'fs/promises'

const buffer = await fs.readFile('./photo.jpg')

const thumbnail = await (await pix.read(buffer))
  .resize(300, 300, { fit: 'cover' })
  .grayscale()
  .png()
  .toBuffer()

await fs.writeFile('./thumbnail.png', thumbnail)

Reading auto-detects PNG, JPEG, WebP, or animated WebP from the buffer's contents — no need to specify the source format.

API

pix.read(buffer)

Decodes a PNG/JPEG/WebP (static or animated) buffer and returns a PixCore instance for chaining. Async.

Transform methods

All of these mutate and return this, so calls chain naturally. None of them encode anything — call .toBuffer() (or a format-setter + .toBuffer()) when you're done transforming.

If the source is an animated image, every transform below applies across all frames at once (not just a "preview" frame) — metadata(), frames(), and toBuffer() all stay consistent with whatever you've done. See Animated WebP for the details and the one exception (trim's cropping behavior).

Method Description
resize(width, height, { fit, background }) fit: 'fill' (default, stretches), 'cover' (crops to fill exactly), or 'contain' (letterboxes to exactly width×height, padded with background, default transparent). Omit either dimension to preserve aspect ratio.
extract({ left, top, width, height }) Crop to a region.
extend({ top, bottom, left, right, background }) Pad the canvas outward.
trim({ threshold, background }) Auto-crop uniform/transparent borders. On an animated source, crops every frame to one shared bounding box (the union of each frame's content) so frames never end up mismatched sizes.
composite(layers) Stack images on top. layers: [{ input, left, top }], where input is a Buffer, a decoded image object, or a canvas (.toImage()). Each layer's input must itself be a still image — an animated buffer/object throws rather than silently compositing only its first frame.
rotate(degrees) Rotates by any multiple of 90°.
flip() / flop() Vertical / horizontal mirror.
grayscale() / greyscale() Desaturate.
negate() Invert colors.
normalize() / normalise() Stretch contrast to the full 0–255 range.
tint([r, g, b]) Multiply toward a color.
blur(radius) Box blur.
sharpen(amount) Unsharp-style convolution sharpen.
ensureAlpha() / removeAlpha() Add/flatten the alpha channel.

Reading info

  • metadata() — { format, width, height, channels, hasAlpha, size, space, animated }, plus pages (frame count) and loop when animated is true.
  • isAnimated() — shorthand boolean, same as metadata().animated.
  • stats() — per-channel min/max/mean (async)

Encoding

img.png()                     // set target format
img.jpeg({ quality: 85 })
img.webp({ quality: 90 })

await img.toBuffer()          // encode using whatever format was set (defaults to jpeg)
await img.toBuffer({ format: 'webp', quality: 80 })   // or specify inline

Encoding an animated source as 'webp' writes out the full animation. Encoding it as 'jpeg' or 'png' — formats with no animation support — flattens it to just the first frame; the rest of the animation is discarded.

Sticker metadata (WebP only)

Pass an exif object when encoding to WebP to embed WhatsApp sticker pack metadata directly into the file:

await img.webp({ quality: 90 }).toBuffer({
  exif: {
    packId: 'my-pack-2024',      // use the SAME id across every sticker in one pack
    packname: 'My Sticker Pack',
    author: 'Me',
    categories: ['😀', '🎉'],     // emoji tags for this sticker
    isAvatarSticker: false,
    publisherEmail: 'me@example.com',
    publisherWebsite: 'https://example.com',
    androidAppStoreLink: 'https://play.google.com/store/apps/details?id=...',
    iosAppStoreLink: 'https://apps.apple.com/app/id...',
    privacyPolicyWebsite: 'https://example.com/privacy',
    licenseAgreementWebsite: 'https://example.com/license'
  }
})

Only packname, author, and categories are commonly needed — everything else is optional. Handles both WebP container variants correctly (synthesizes the required VP8X chunk for simple-format sources, reuses it when already present), and works the same whether the source is a still image or an animated one — the animation and its EXIF metadata are both preserved.

If you're sending the result through Baileys or a similar WhatsApp library, note that whether a sticker plays as animated is decided by what you declare when sending it, not by pixcore or by WhatsApp inspecting the file — pass isAnimated: true alongside the sticker buffer in your send call.

Animated WebP

Reading an animated WebP decodes every frame up front:

const img = await pix.read(animatedBuffer)

img.isAnimated()      // true
img.metadata()        // { ..., animated: true, pages: 24, loop: 0 }

const frames = img.frames()   // one PixCore per frame
frames[0].duration            // frame duration in ms
frames[0].loop                 // the source's loop count

frames() gives you independent PixCore instances — transform them individually if you need per-frame control:

const resized = img.frames().map(f => f.resize(200, 200))
const out = await pix.writeAnimated(resized, { loop: img.metadata().loop, quality: 75 })

Or skip frames() entirely and call transforms straight on the animated instance — they run across every frame automatically:

const out = await (await pix.read(animatedBuffer))
  .resize(200, 200)
  .grayscale()
  .webp({ quality: 75 })
  .toBuffer()

pix.writeAnimated(frames, { loop, quality, background }) builds a fresh animated WebP from a list of frames — either PixCore instances (as returned by .frames(), using their attached .duration) or plain { data, width, height, duration } objects. Every frame must share the same width/height; resize them to match first.

Canvas

Draw shapes and text from scratch, or use a canvas as a layer to composite onto a decoded image.

import pix from 'pixcore'

const canvas = pix.createCanvas(400, 400, { background: '#fff9e6' })

canvas
  .rect({ x: 20, y: 20, width: 360, height: 360, radius: 24, fill: '#ffffff', stroke: '#2b2b2b', strokeWidth: 6 })
  .circle({ x: 150, y: 130, radius: 60, fill: { type: 'radial', from: { x: 150, y: 130 }, to: 60,
      stops: [{ color: '#ffe066', offset: 0 }, { color: '#ff8c42', offset: 1 }] } })
  .star({ x: 300, y: 130, points: 5, outerRadius: 45, innerRadius: 20, fill: '#06d6a0' })

await canvas.text('Happy Birthday!', {
  x: 200, y: 320, size: 40, color: '#2b2b2b', align: 'center',
  font: './fonts/Bold.ttf'
})

const img = pix.PixCore.fromCanvas(canvas)
const buffer = await img.png().toBuffer()

(pix.fromCanvas(canvas) works identically, if you'd rather not reach through PixCore.)

Shapes

All shape methods return this for chaining, and accept fill/stroke/strokeWidth. fill accepts a CSS-style color string ('#ff0000', 'rgba(255,0,0,0.5)', 'hsl(200,80%,50%)', named colors) or a gradient descriptor (see below).

Method Options
rect({ x, y, width, height, radius }) radius for rounded corners
circle({ x, y, radius })
ellipse({ x, y, rx, ry })
line({ x1, y1, x2, y2, color, width })
arc({ x, y, radius, startAngle, endAngle }) Filled as a pie wedge (radians)
polygon({ points }) points: [{x,y}, ...]
triangle({ x1, y1, x2, y2, x3, y3 })
star({ x, y, points, outerRadius, innerRadius })
path({ commands }) Raw path commands: `{type:'M'

Gradients

Use a gradient as any shape's fill, clipped exactly to that shape:

fill: {
  type: 'linear',                 // or 'radial'
  from: { x: 0, y: 0 },
  to: { x: 200, y: 0 },            // a point for linear, or a radius (number) for radial
  stops: [{ color: '#ff0055', offset: 0 }, { color: '#5500ff', offset: 1 }]
}

canvas.gradient({ type, from, to, stops }) paints a gradient across the entire canvas instead, overwriting whatever was drawn before it — call it first, as a background.

Text

await canvas.text('Hello world!', {
  x: 20, y: 60, size: 32, color: '#1e1e2e',
  font: './fonts/Regular.ttf',   // required — any .ttf/.otf
  align: 'left',                  // 'left' | 'center' | 'right'
  strokeColor: '#000000',
  strokeWidth: 2
})

canvas.textBg(str, opts) draws the same text with an automatic background pill behind it (bg, bgPadding, borderRadius options).

Emoji

Regular text fonts don't contain emoji glyphs, and real color-emoji fonts use formats a plain vector-outline renderer can't read at all — so pixcore handles color emoji through two dedicated paths:

  • Bundled by default — a subsetted color emoji font ships with the package, so emoji work with zero configuration:
    await canvas.text('Nice work 🎉', { x: 20, y: 60, size: 32, font: './fonts/Regular.ttf' })
  • colorFont — for COLR/CPAL-format color fonts (e.g. the current Noto Color Emoji build from Google Fonts), rendered as real layered vector paths via opentype.js's native color-glyph support:
    await canvas.text('Nice work 🎉', {
      x: 20, y: 60, size: 32, font: './fonts/Regular.ttf',
      colorFont: './fonts/NotoColorEmoji-Regular.ttf'
    })
  • emojiFont — override the bundled CBDT/CBLC bitmap font with your own (defaults to the bundled one; set to null to disable emoji rendering entirely).

colorFont is tried first per character, falling back to emojiFont, then to leaving the character unrendered with a console.warn explaining why.

Known limitation: flag emoji, skin-tone modifiers, and ZWJ sequences (e.g. family/profession emoji) require combining multiple codepoints into one glyph via font ligature rules, which isn't implemented — each codepoint in a sequence resolves independently. Standalone emoji (the vast majority) are unaffected.

Using a canvas as a layer

const canvas = pix.createCanvas(200, 80, { background: 'transparent' })
await canvas.text('WATERMARK', { x: 10, y: 50, size: 28, color: 'rgba(255,255,255,0.6)', font: './fonts/Bold.ttf' })

const img = await pix.read(photoBuffer)
await img.composite([{ input: canvas.toImage(), left: 20, top: 20 }])

Or wrap the canvas as its own PixCore instance first (via pix.fromCanvas(canvas)) if you want to run transforms — resize, filters, etc. — on it before compositing or encoding it directly.

Supported formats

Decode Encode
PNG ✓ ✓
JPEG ✓ ✓
WebP (static) ✓ ✓ (+ sticker EXIF)
WebP (animated) ✓ ✓ (+ sticker EXIF)

License

MIT

About

pixcore is a pure JavaScript image processing library for Nodejs, designed to be lightweight and fast.

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages