diff --git a/package-lock.json b/package-lock.json
index eb7d4e0..ac398e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,6 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"ioredis": "^5.10.1",
- "sharp": "^0.34.5",
"svelte": "^5.28.2",
"testcontainers": "^11.14.0",
"vite": "^6.3.2",
@@ -3397,6 +3396,16 @@
"node": ">=18.0.0"
}
},
+ "node_modules/miniflare/node_modules/undici": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
+ "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -4348,9 +4357,9 @@
"license": "MIT"
},
"node_modules/undici": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
- "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index f9dac2e..736cb67 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,6 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"ioredis": "^5.10.1",
- "sharp": "^0.34.5",
"svelte": "^5.28.2",
"testcontainers": "^11.14.0",
"vite": "^6.3.2",
diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js
deleted file mode 100644
index 483c892..0000000
--- a/scripts/image-to-colors.js
+++ /dev/null
@@ -1,122 +0,0 @@
-#!/usr/bin/env node
-/**
- * Convert PNG/JPG/WebP image to rplace palette JSON.
- *
- * Usage:
- * node scripts/image-to-colors.js [-o output.json] [--alpha-threshold N]
- *
- * Output JSON: { width, height, pixels: number[] }
- * - pixels is a flat row-major array of length width*height
- * - each entry is a palette index [0..31], or -1 for transparent (skipped on upload)
- */
-import { writeFile } from 'node:fs/promises';
-import { parseArgs } from 'node:util';
-import sharp from 'sharp';
-import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../src/lib/constants.js';
-import { rgbaToPalette } from '../src/lib/image-to-palette.js';
-import { resizeRgba } from '../src/lib/image-resize.js';
-import { transformRgba } from '../src/lib/image-transform.js';
-import { applyColorCorrection } from '../src/lib/image-color-correction.js';
-
-const { values, positionals } = parseArgs({
- args: process.argv.slice(2),
- options: {
- output: { type: 'string', short: 'o' },
- 'alpha-threshold': { type: 'string', default: '128' },
- dither: { type: 'boolean', default: false },
- 'dither-method': { type: 'string' }, // see DITHER_METHODS; overrides --dither
- width: { type: 'string' },
- height: { type: 'string' },
- method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box
- 'flip-h': { type: 'boolean', default: false },
- 'flip-v': { type: 'boolean', default: false },
- rotate: { type: 'string', default: '0' }, // 0 | 90 | 180 | 270 (CW)
- brightness: { type: 'string', default: '0' },
- contrast: { type: 'string', default: '0' },
- saturation: { type: 'string', default: '0' },
- gamma: { type: 'string', default: '1' },
- 'skip-white': { type: 'boolean', default: false },
- 'white-threshold': { type: 'string', default: '230' },
- 'paint-transparent': { type: 'boolean', default: false },
- },
- allowPositionals: true,
-});
-
-if (positionals.length < 1) {
- console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither|--dither-method ] [--width N] [--height N] [--method nearest|bilinear|box] [--flip-h] [--flip-v] [--rotate 0|90|180|270] [--brightness -100..100] [--contrast -100..100] [--saturation -100..100] [--gamma 0.1..3]');
- process.exit(1);
-}
-
-const input = positionals[0];
-const output = values.output ?? input.replace(/\.[^.]+$/, '.json');
-const alphaThreshold = parseInt(values['alpha-threshold'], 10);
-
-if (!Number.isFinite(alphaThreshold) || alphaThreshold < 0 || alphaThreshold > 255) {
- console.error('--alpha-threshold must be an integer in [0, 255]');
- process.exit(1);
-}
-
-const { data, info } = await sharp(input)
- .ensureAlpha()
- .raw()
- .toBuffer({ resolveWithObject: true });
-
-const { width: rawW, height: rawH } = info;
-
-const rotation = parseInt(values.rotate, 10);
-if (![0, 90, 180, 270].includes(rotation)) {
- console.error('--rotate must be 0, 90, 180, or 270');
- process.exit(1);
-}
-
-const transformed = (values['flip-h'] || values['flip-v'] || rotation !== 0)
- ? transformRgba(data, rawW, rawH, { flipH: values['flip-h'], flipV: values['flip-v'], rotation })
- : { rgba: data, width: rawW, height: rawH };
-const srcW = transformed.width;
-const srcH = transformed.height;
-
-// Optional resize — defaults to post-transform dims. Aspect-preserve when only one of W/H given.
-const reqW = values.width ? parseInt(values.width, 10) : null;
-const reqH = values.height ? parseInt(values.height, 10) : null;
-let outW = reqW ?? (reqH ? Math.round(srcW * reqH / srcH) : srcW);
-let outH = reqH ?? (reqW ? Math.round(srcH * reqW / srcW) : srcH);
-if (outW < 1 || outH < 1) {
- console.error('Resolved width/height must be >= 1');
- process.exit(1);
-}
-if (outW > CANVAS_WIDTH || outH > CANVAS_HEIGHT) {
- console.warn(`Warning: output ${outW}x${outH} exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}. Upload will fail at the boundary.`);
-}
-
-const resized = (outW === srcW && outH === srcH)
- ? transformed.rgba
- : resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method);
-
-const brightness = parseFloat(values.brightness);
-const contrast = parseFloat(values.contrast);
-const saturation = parseFloat(values.saturation);
-const gamma = parseFloat(values.gamma);
-const hasCorrection = brightness !== 0 || contrast !== 0 || saturation !== 0 || gamma !== 1;
-const working = hasCorrection
- ? applyColorCorrection(resized, outW, outH, { brightness, contrast, saturation, gamma })
- : resized;
-
-// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
-const ditherMethod = values['dither-method'] ?? (values.dither ? 'floyd' : 'none');
-const indices = rgbaToPalette(working, outW, outH, {
- alphaThreshold,
- method: ditherMethod,
- skipWhite: values['skip-white'],
- whiteThreshold: parseInt(values['white-threshold'], 10),
- paintTransparent: values['paint-transparent'],
-});
-const pixels = Array.from(indices);
-const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
-
-await writeFile(output, JSON.stringify({ width: outW, height: outH, pixels }));
-const transformNote = (values['flip-h'] || values['flip-v'] || rotation !== 0)
- ? ` [transform: ${[values['flip-h'] && 'flipH', values['flip-v'] && 'flipV', rotation !== 0 && `rot${rotation}`].filter(Boolean).join('+')}]`
- : '';
-const resizeNote = (outW !== srcW || outH !== srcH) ? ` (resized from ${srcW}x${srcH} via ${values.method})` : '';
-const ditherNote = ditherMethod !== 'none' ? ` [dither: ${ditherMethod}]` : '';
-console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${ditherNote}`);
diff --git a/scripts/upload-colors.js b/scripts/upload-colors.js
deleted file mode 100644
index eb45499..0000000
--- a/scripts/upload-colors.js
+++ /dev/null
@@ -1,145 +0,0 @@
-#!/usr/bin/env node
-/**
- * Upload rplace palette JSON to the canvas at (x, y).
- *
- * Usage:
- * node scripts/upload-colors.js [--url=http://localhost:8787] [--batch=256] [--dry]
- *
- * Respects the server's credit bucket: starts assuming MAX_CREDITS, tracks regen
- * locally between batches, and on 429 waits `retryAfter` seconds before retry.
- */
-import { readFile } from 'node:fs/promises';
-import { parseArgs } from 'node:util';
-import {
- CANVAS_WIDTH, CANVAS_HEIGHT,
- MAX_BATCH_SIZE, MAX_CREDITS, CREDIT_REGEN_RATE,
-} from '../src/lib/constants.js';
-
-const { values, positionals } = parseArgs({
- args: process.argv.slice(2),
- options: {
- url: { type: 'string', default: 'http://localhost:8787' },
- batch: { type: 'string' },
- dry: { type: 'boolean', default: false },
- },
- allowPositionals: true,
-});
-
-if (positionals.length < 3) {
- console.error('Usage: node scripts/upload-colors.js [--url=http://localhost:8787] [--batch=256] [--dry]');
- process.exit(1);
-}
-
-const [dataPath, xArg, yArg] = positionals;
-const originX = parseInt(xArg, 10);
-const originY = parseInt(yArg, 10);
-const batchSize = Math.max(1, Math.min(parseInt(values.batch ?? String(MAX_BATCH_SIZE), 10), MAX_BATCH_SIZE));
-const apiUrl = values.url.replace(/\/+$/, '');
-const dry = values.dry;
-
-if (!Number.isInteger(originX) || !Number.isInteger(originY)) {
- console.error('x and y must be integers');
- process.exit(1);
-}
-
-const { width, height, pixels } = JSON.parse(await readFile(dataPath, 'utf8'));
-
-if (
- originX < 0 || originY < 0 ||
- originX + width > CANVAS_WIDTH ||
- originY + height > CANVAS_HEIGHT
-) {
- console.error(`Image ${width}x${height} at (${originX},${originY}) exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}`);
- process.exit(1);
-}
-
-const toSend = [];
-for (let i = 0; i < pixels.length; i++) {
- const color = pixels[i];
- if (color < 0) continue;
- const lx = i % width;
- const ly = (i / width) | 0;
- toSend.push({ x: originX + lx, y: originY + ly, color });
-}
-
-console.log(`Placing ${toSend.length} pixels (image ${width}x${height} at ${originX},${originY}), batch=${batchSize}`);
-
-if (dry) {
- console.log('Dry run, no requests sent.');
- process.exit(0);
-}
-
-await upload(toSend);
-
-async function upload(allPixels) {
- let credits = MAX_CREDITS;
- let lastUpdate = Date.now();
- let placed = 0;
- const started = Date.now();
-
- for (let i = 0; i < allPixels.length; ) {
- const batch = allPixels.slice(i, i + batchSize);
-
- // Refresh local credit estimate with elapsed regen.
- const now = Date.now();
- credits = Math.min(MAX_CREDITS, credits + ((now - lastUpdate) / 1000) * CREDIT_REGEN_RATE);
- lastUpdate = now;
-
- if (credits < batch.length) {
- const waitSec = Math.ceil((batch.length - credits) / CREDIT_REGEN_RATE);
- console.log(` waiting ${waitSec}s for credits (${credits.toFixed(1)}/${batch.length})`);
- await sleep(waitSec * 1000);
- credits = Math.min(MAX_CREDITS, credits + waitSec * CREDIT_REGEN_RATE);
- lastUpdate = Date.now();
- }
-
- let res;
- try {
- res = await fetch(`${apiUrl}/api/place`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pixels: batch }),
- });
- } catch (err) {
- console.error(` network error: ${err.message}, retrying in 3s`);
- await sleep(3000);
- continue;
- }
-
- if (res.status === 429) {
- const body = await res.json().catch(() => ({}));
- const wait = Math.max(1, body.retryAfter ?? 2);
- console.log(` rate limited, waiting ${wait}s`);
- await sleep(wait * 1000);
- credits = 0;
- lastUpdate = Date.now();
- continue;
- }
-
- if (!res.ok) {
- const text = await res.text().catch(() => '');
- throw new Error(`${res.status} ${res.statusText}: ${text}`);
- }
-
- const body = await res.json();
- if (typeof body.credits === 'number') {
- credits = body.credits;
- lastUpdate = Date.now();
- } else {
- credits = Math.max(0, credits - batch.length);
- }
-
- i += batch.length;
- placed += batch.length;
- const pct = ((placed / allPixels.length) * 100).toFixed(1);
- const elapsed = ((Date.now() - started) / 1000).toFixed(0);
- console.log(` [${placed}/${allPixels.length}] ${pct}% credits=${credits} elapsed=${elapsed}s`);
- }
-
- const total = ((Date.now() - started) / 1000).toFixed(0);
- console.log(`Done. Placed ${placed} pixels in ${total}s`);
-}
-
-function sleep(ms) {
- return new Promise((r) => setTimeout(r, ms));
-}
diff --git a/src/client/App.svelte b/src/client/App.svelte
index 78f5da0..f1506c6 100644
--- a/src/client/App.svelte
+++ b/src/client/App.svelte
@@ -6,7 +6,7 @@
import DrawToolbar from './components/DrawToolbar.svelte';
import ImageImporter from './components/ImageImporter.svelte';
- let selectedColor = $state(0); // palette index 0 = black (first grayscale step)
+ let selectedColor = $state(0);
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
let mode = $state('paint');
diff --git a/src/lib/canvas-storage.js b/src/lib/canvas-storage.js
index e9feace..18ceee3 100644
--- a/src/lib/canvas-storage.js
+++ b/src/lib/canvas-storage.js
@@ -1,7 +1,6 @@
import { redisRaw, redisRawBinary } from './redis-client.js';
import { CANVAS_WIDTH, TOTAL_PIXELS, REDIS_CANVAS_KEY } from './constants.js';
-/** Total bytes needed for the canvas — 1 byte per pixel (u8 palette index). */
const CANVAS_BYTES = TOTAL_PIXELS;
/**
diff --git a/src/lib/constants.js b/src/lib/constants.js
index b96dafe..f34fe74 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -3,9 +3,8 @@ export const CANVAS_WIDTH = 4096;
export const CANVAS_HEIGHT = 4096;
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
-/** Color encoding: 1 byte per pixel, 256 palette entries. Byte-aligned means
- * the raw Redis bytes are directly the pixel indices — no bit-level decode. */
-export const BITS_PER_PIXEL = 8;
+/** 1 byte per pixel, 256 palette entries — the raw Redis bytes are directly
+ * the pixel indices, no bit-level decode. */
export const MAX_COLORS = 256;
/** Rate limiting — one request per second per user, batch size independent. */
diff --git a/src/lib/dither-kernels.js b/src/lib/dither-kernels.js
index 830d6ca..5b33119 100644
--- a/src/lib/dither-kernels.js
+++ b/src/lib/dither-kernels.js
@@ -8,7 +8,7 @@
*/
const FS = 1 / 16;
-export const KERNEL_FLOYD = [
+const KERNEL_FLOYD = [
[ 1, 0, 7 * FS],
[-1, 1, 3 * FS],
[ 0, 1, 5 * FS],
@@ -17,7 +17,7 @@ export const KERNEL_FLOYD = [
// Atkinson (original Mac) — only diffuses 6/8 of the error; gives a softer look.
const ATK = 1 / 8;
-export const KERNEL_ATKINSON = [
+const KERNEL_ATKINSON = [
[ 1, 0, ATK],
[ 2, 0, ATK],
[-1, 1, ATK],
@@ -28,7 +28,7 @@ export const KERNEL_ATKINSON = [
// Jarvis-Judice-Ninke — 12-entry kernel over 3 rows, smoother than FS.
const JJN = 1 / 48;
-export const KERNEL_JARVIS = [
+const KERNEL_JARVIS = [
[ 1, 0, 7 * JJN], [ 2, 0, 5 * JJN],
[-2, 1, 3 * JJN], [-1, 1, 5 * JJN], [0, 1, 7 * JJN], [1, 1, 5 * JJN], [2, 1, 3 * JJN],
[-2, 2, 1 * JJN], [-1, 2, 3 * JJN], [0, 2, 5 * JJN], [1, 2, 3 * JJN], [2, 2, 1 * JJN],
@@ -36,14 +36,14 @@ export const KERNEL_JARVIS = [
// Burkes
const BUR = 1 / 32;
-export const KERNEL_BURKES = [
+const KERNEL_BURKES = [
[ 1, 0, 8 * BUR], [ 2, 0, 4 * BUR],
[-2, 1, 2 * BUR], [-1, 1, 4 * BUR], [0, 1, 8 * BUR], [1, 1, 4 * BUR], [2, 1, 2 * BUR],
];
// Sierra (3-line)
const SIE = 1 / 32;
-export const KERNEL_SIERRA = [
+const KERNEL_SIERRA = [
[ 1, 0, 5 * SIE], [ 2, 0, 3 * SIE],
[-2, 1, 2 * SIE], [-1, 1, 4 * SIE], [0, 1, 5 * SIE], [1, 1, 4 * SIE], [2, 1, 2 * SIE],
[-1, 2, 2 * SIE], [0, 2, 3 * SIE], [1, 2, 2 * SIE],
@@ -51,7 +51,7 @@ export const KERNEL_SIERRA = [
// Sierra Lite — cheapest non-trivial
const SLT = 1 / 4;
-export const KERNEL_SIERRA_LITE = [
+const KERNEL_SIERRA_LITE = [
[ 1, 0, 2 * SLT],
[-1, 1, 1 * SLT],
[ 0, 1, 1 * SLT],
diff --git a/src/lib/image-pipeline.js b/src/lib/image-pipeline.js
index 3d592ce..f4aeb78 100644
--- a/src/lib/image-pipeline.js
+++ b/src/lib/image-pipeline.js
@@ -37,7 +37,6 @@ export function createPipeline() {
slots.T = slots.R = slots.C = null;
},
hasSource() { return src !== null; },
- source() { return src; },
/**
* @param {{
diff --git a/src/lib/image-to-palette.js b/src/lib/image-to-palette.js
index cfe1cc4..03d60d0 100644
--- a/src/lib/image-to-palette.js
+++ b/src/lib/image-to-palette.js
@@ -51,15 +51,12 @@ function lutNearest(r, g, b) {
* Convert an RGBA pixel buffer into a flat array of palette indices.
* Pixels with alpha < threshold become -1 (caller should skip on upload).
*
- * Back-compat: `dither: true` is treated as `method: 'floyd'`.
- *
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba - length = width*height*4
* @param {number} width
* @param {number} height
* @param {Object} [options]
* @param {number} [options.alphaThreshold=128]
- * @param {boolean} [options.dither=false] - legacy; prefer `method`
- * @param {('none'|'floyd'|'atkinson'|'jarvis'|'burkes'|'sierra'|'sierra-lite'|'bayer-2'|'bayer-4'|'bayer-8')} [options.method]
+ * @param {('none'|'floyd'|'atkinson'|'jarvis'|'burkes'|'sierra'|'sierra-lite'|'bayer-2'|'bayer-4'|'bayer-8')} [options.method='none']
* @param {boolean} [options.skipWhite=false] - mark near-white source pixels as transparent
* @param {number} [options.whiteThreshold=230] - r,g,b >= this counts as "white"
* @param {boolean} [options.paintTransparent=false] - treat transparent pixels as opaque white before quantizing
@@ -68,12 +65,11 @@ function lutNearest(r, g, b) {
export function rgbaToPalette(rgba, width, height, options = {}) {
const {
alphaThreshold = 128,
- dither = false,
+ method = 'none',
skipWhite = false,
whiteThreshold = 230,
paintTransparent = false,
} = options;
- const method = options.method ?? (dither ? 'floyd' : 'none');
// Substitute transparent→opaque white up-front so the rest of the pipeline
// sees a consistent buffer. We only clone when substitutions actually occur.
diff --git a/test/lib/image-to-palette.test.js b/test/lib/image-to-palette.test.js
index 7616f1b..c1d25b7 100644
--- a/test/lib/image-to-palette.test.js
+++ b/test/lib/image-to-palette.test.js
@@ -32,13 +32,6 @@ describe('rgbaToPalette', () => {
expect(idx[0]).toBe(-1);
});
- it('dither:true is equivalent to method:"floyd"', () => {
- const src = solidRgba(4, 4, 120, 120, 120);
- const a = rgbaToPalette(src, 4, 4, { dither: true });
- const b = rgbaToPalette(src, 4, 4, { method: 'floyd' });
- expect([...a]).toEqual([...b]);
- });
-
it('throws on unknown method', () => {
const src = solidRgba(1, 1, 0, 0, 0);
expect(() => rgbaToPalette(src, 1, 1, { method: 'nope' })).toThrow(/nope/i);