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);