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; +}