diff --git a/package-lock.json b/package-lock.json
index 8239736..eb7d4e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"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/package.json b/package.json
index 736cb67..f9dac2e 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"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
new file mode 100644
index 0000000..455973e
--- /dev/null
+++ b/scripts/image-to-colors.js
@@ -0,0 +1,59 @@
+#!/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';
+
+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 },
+ },
+ allowPositionals: true,
+});
+
+if (positionals.length < 1) {
+ console.error('Usage: node scripts/image-to-colors.js [-o output.json] [--alpha-threshold 128] [--dither]');
+ 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, height } = info;
+
+if (width > CANVAS_WIDTH || height > CANVAS_HEIGHT) {
+ console.warn(`Warning: image ${width}x${height} exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}. Upload will fail at the boundary.`);
+}
+
+// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
+const indices = rgbaToPalette(data, width, height, { alphaThreshold, dither: values.dither });
+const pixels = Array.from(indices);
+const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
+
+await writeFile(output, JSON.stringify({ width, height, pixels }));
+console.log(`Wrote ${output}: ${width}x${height}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);
diff --git a/scripts/upload-colors.js b/scripts/upload-colors.js
new file mode 100644
index 0000000..eb45499
--- /dev/null
+++ b/scripts/upload-colors.js
@@ -0,0 +1,145 @@
+#!/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/lib/image-to-palette.js b/src/lib/image-to-palette.js
new file mode 100644
index 0000000..e5dd7d0
--- /dev/null
+++ b/src/lib/image-to-palette.js
@@ -0,0 +1,128 @@
+import { COLORS_RGBA } from './constants.js';
+
+/**
+ * Return the palette index closest to (r, g, b) in squared-RGB distance.
+ * @param {number} r
+ * @param {number} g
+ * @param {number} b
+ * @returns {number} index in [0, COLORS_RGBA.length)
+ */
+export function nearestColorIndex(r, g, b) {
+ let best = 0;
+ let bestDist = Infinity;
+ for (let i = 0; i < COLORS_RGBA.length; i++) {
+ const [cr, cg, cb] = COLORS_RGBA[i];
+ const dr = r - cr, dg = g - cg, db = b - cb;
+ const dist = dr * dr + dg * dg + db * db;
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/**
+ * Convert an RGBA pixel buffer into a flat array of palette indices.
+ * Pixels with alpha < threshold become -1 (caller should skip on upload).
+ * @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] - Floyd-Steinberg error diffusion
+ * @returns {Int16Array} length = width*height
+ */
+export function rgbaToPalette(rgba, width, height, options = {}) {
+ const { alphaThreshold = 128, dither = false } = options;
+ return dither
+ ? ditherFloydSteinberg(rgba, width, height, alphaThreshold)
+ : quantizeNearest(rgba, width, height, alphaThreshold);
+}
+
+function quantizeNearest(rgba, width, height, alphaThreshold) {
+ const count = width * height;
+ const out = new Int16Array(count);
+ for (let i = 0; i < count; i++) {
+ const off = i * 4;
+ if (rgba[off + 3] < alphaThreshold) { out[i] = -1; continue; }
+ out[i] = nearestColorIndex(rgba[off], rgba[off + 1], rgba[off + 2]);
+ }
+ return out;
+}
+
+function ditherFloydSteinberg(rgba, width, height, alphaThreshold) {
+ const count = width * height;
+ const out = new Int16Array(count);
+ // Working RGB buffer in float so we can accumulate error without clamping early.
+ const buf = new Float32Array(count * 3);
+ for (let i = 0; i < count; i++) {
+ buf[i * 3] = rgba[i * 4];
+ buf[i * 3 + 1] = rgba[i * 4 + 1];
+ buf[i * 3 + 2] = rgba[i * 4 + 2];
+ }
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const i = y * width + x;
+ if (rgba[i * 4 + 3] < alphaThreshold) { out[i] = -1; continue; }
+
+ const r = buf[i * 3];
+ const g = buf[i * 3 + 1];
+ const b = buf[i * 3 + 2];
+ const idx = nearestColorIndex(
+ r < 0 ? 0 : r > 255 ? 255 : r,
+ g < 0 ? 0 : g > 255 ? 255 : g,
+ b < 0 ? 0 : b > 255 ? 255 : b,
+ );
+ out[i] = idx;
+ const [pr, pg, pb] = COLORS_RGBA[idx];
+ const er = r - pr, eg = g - pg, eb = b - pb;
+
+ diffuse(buf, rgba, width, height, x + 1, y, er, eg, eb, 7 / 16, alphaThreshold);
+ diffuse(buf, rgba, width, height, x - 1, y + 1, er, eg, eb, 3 / 16, alphaThreshold);
+ diffuse(buf, rgba, width, height, x, y + 1, er, eg, eb, 5 / 16, alphaThreshold);
+ diffuse(buf, rgba, width, height, x + 1, y + 1, er, eg, eb, 1 / 16, alphaThreshold);
+ }
+ }
+ return out;
+}
+
+function diffuse(buf, rgba, width, height, nx, ny, er, eg, eb, w, alphaThreshold) {
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) return;
+ const ni = ny * width + nx;
+ if (rgba[ni * 4 + 3] < alphaThreshold) return; // skip transparent neighbors
+ buf[ni * 3] += er * w;
+ buf[ni * 3 + 1] += eg * w;
+ buf[ni * 3 + 2] += eb * w;
+}
+
+/**
+ * Render palette indices back to an RGBA buffer for preview.
+ * Transparent indices (-1) render as a checkerboard.
+ * @param {Int16Array|number[]} indices
+ * @param {number} width
+ * @param {number} height
+ * @returns {Uint8ClampedArray} RGBA buffer of length width*height*4
+ */
+export function paletteToRgba(indices, width, height) {
+ const out = new Uint8ClampedArray(width * height * 4);
+ for (let i = 0; i < indices.length; i++) {
+ const off = i * 4;
+ const idx = indices[i];
+ if (idx < 0) {
+ const x = i % width;
+ const y = (i / width) | 0;
+ const checker = ((x >> 2) ^ (y >> 2)) & 1 ? 180 : 120;
+ out[off] = out[off + 1] = out[off + 2] = checker;
+ out[off + 3] = 255;
+ continue;
+ }
+ const [r, g, b, a] = COLORS_RGBA[idx];
+ out[off] = r;
+ out[off + 1] = g;
+ out[off + 2] = b;
+ out[off + 3] = a;
+ }
+ return out;
+}