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/coreworks 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/corejust 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 sharpA 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 atkinsonThat'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.
Related docs
@ditherkit/core— the full algorithm reference- Image adjustments — pipeline order and why grayscale matters
- Internals — the Sharp
pipeline inside
@ditherkit/nextuses exactly this pattern