ditherkit
Integrations

Node CLI / build scripts

@ditherkit/core is pure functions. Pair with sharp for file I/O and you have a working CLI in 30 lines.

Status:Supported. @ditherkit/core works in Node with zero configuration. Pair it with Sharp for file I/O. An example CLI is backlogged (apps/example-node-cli).

If you're writing a build script, a CLI tool, or a one-off batch job that processes a folder of images, @ditherkit/core is the right entry point. No framework, no server, no browser — just functions you call from Node.

What works

  • All seven algorithms — Floyd-Steinberg, Atkinson, Jarvis-Judice-Ninke, Stucki, Burkes, Bayer, Threshold
  • All image adjustments — grayscale, brightness, contrast
  • Any Node runtime — Node 18+, Bun, Deno (with node: prefix imports)
  • Sharp for I/O — read any format, resize, re-encode to any format
  • Streaming — Sharp streams work with @ditherkit/core just fine if you want to process large batches

What doesn't work

  • Edge runtimes — Sharp has native modules and doesn't run on Cloudflare Workers, Deno Deploy, or similar. Use a pure-JS image decoder (PNG: pngjs, JPEG: jpeg-js) if you need edge support; performance will be lower than Sharp.

Quick start

pnpm add @ditherkit/core sharp

A minimal CLI:

// dither.mjs
import sharp from 'sharp'
import {
  // All 7 @ditherkit/core algorithms
  floydSteinbergDither,
  atkinsonDither,
  jarvisJudiceNinkeDither,
  stuckiDither,
  burkesDither,
  bayerDither,
  thresholdDither,
  grayscale,
  type Color,
} from '@ditherkit/core'
import { parseArgs } from 'node:util'

const { values, positionals } = parseArgs({
  options: {
    algorithm: { type: 'string', default: 'floyd-steinberg' },
    palette: { type: 'string', default: '000000,ffffff' },
    'max-width': { type: 'string', default: '800' },
  },
  allowPositionals: true,
})

const [input, output] = positionals
if (!input || !output) {
  console.error('usage: node dither.mjs <input> <output> [options]')
  process.exit(1)
}

const palette: Color[] = values.palette!.split(',').map((hex) => ({
  r: parseInt(hex.slice(0, 2), 16),
  g: parseInt(hex.slice(2, 4), 16),
  b: parseInt(hex.slice(4, 6), 16),
}))

// 1. Read + resize + get raw RGB pixels
const { data, info } = await sharp(input)
  .resize(parseInt(values['max-width']!))
  .raw()
  .toBuffer({ resolveWithObject: true })

// 2. RGB → RGBA
const pixels = new Uint8ClampedArray(info.width * info.height * 4)
for (let i = 0, j = 0; i < data.length; i += 3, j += 4) {
  pixels[j] = data[i]
  pixels[j + 1] = data[i + 1]
  pixels[j + 2] = data[i + 2]
  pixels[j + 3] = 255
}

// 3. Grayscale (only for 2-colour palettes) + dither
if (palette.length <= 2) {
  grayscale(pixels)
}
switch (values.algorithm) {
  case 'floyd-steinberg':
    floydSteinbergDither(pixels, info.width, info.height, palette)
    break
  case 'atkinson':
    atkinsonDither(pixels, info.width, info.height, palette)
    break
  case 'jarvis-judice-ninke':
    jarvisJudiceNinkeDither(pixels, info.width, info.height, palette)
    break
  case 'stucki':
    stuckiDither(pixels, info.width, info.height, palette)
    break
  case 'burkes':
    burkesDither(pixels, info.width, info.height, palette)
    break
  case 'bayer':
    bayerDither(pixels, info.width, info.height, palette)
    break
  case 'threshold':
    thresholdDither(pixels, 128, palette)
    break
  default:
    console.error(`unknown algorithm: ${values.algorithm}`)
    process.exit(1)
}

// 4. RGBA → RGB
const rgbOut = new Uint8Array((pixels.length / 4) * 3)
for (let i = 0, j = 0; i < pixels.length; i += 4, j += 3) {
  rgbOut[j] = pixels[i]
  rgbOut[j + 1] = pixels[i + 1]
  rgbOut[j + 2] = pixels[i + 2]
}

// 5. Encode and write
await sharp(rgbOut, {
  raw: { width: info.width, height: info.height, channels: 3 },
})
  .webp({ quality: 90 })
  .toFile(output)

console.log(`wrote ${output}`)

Run it:

node dither.mjs portrait.jpg portrait.webp --algorithm atkinson

That's the whole thing. About 60 lines of code, no dependencies beyond @ditherkit/core and sharp.

Batch processing

For a folder of images, wrap the core pipeline in a loop:

import { readdir } from 'node:fs/promises'
import { join, parse } from 'node:path'

const sourceDir = './source-images'
const outputDir = './dithered'

for (const file of await readdir(sourceDir)) {
  const { name } = parse(file)
  // ... same pipeline as above, reading from join(sourceDir, file)
  // and writing to join(outputDir, `${name}.webp`)
}

For faster processing, parallelise with Promise.all (though keep the concurrency bounded — Sharp uses native threads that don't love being over-subscribed):

import pLimit from 'p-limit'

const limit = pLimit(4)
const files = await readdir(sourceDir)
await Promise.all(
  files.map((file) => limit(() => processOne(file)))
)

Use cases

  • Pre-rendering static site images — build-time dithering for blog headers, marketing imagery, etc. The output is just static files; no runtime overhead.
  • Processing user-uploaded content in a queue job — pull from an upload queue, dither, store in S3.
  • Generating test fixtures — produce dithered reference images for visual regression tests.
  • Wrapping in a Docker image for CI — deterministic image processing as part of your CI pipeline.

Example app

A polished example CLI is in progress — argparse, nicer error messages, batch mode. Until it lands, the snippet above is a complete working starter you can adapt.

On this page