chore: prune dead code, unused exports, and orphan scripts

- scripts/: remove upload-colors.js (broken — imported removed constants)
  and image-to-colors.js (orphan — flow moved fully client-side to
  ImageImporter). Drop sharp from devDependencies.
- constants.js: drop unused BITS_PER_PIXEL export.
- dither-kernels.js: make KERNEL_* consts internal; only the
  ERROR_DIFFUSION_KERNELS map is consumed externally.
- image-pipeline.js: drop unused pipeline.source() accessor.
- image-to-palette.js: drop `options.dither` legacy boolean shim
  (no external caller uses it) and its test.
- canvas-storage.js, App.svelte: drop comments that restated names.
This commit is contained in:
tiennm99 committed 2026-04-18 15:01:49 +07:00
1 parent 8c6b9aa191
commit 6b790f3892
11 files changed
+24 -297

No files matched your search

+13 -4
View File
@@ -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": {
-1
View File
@@ -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",
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env node
/**
* Convert PNG/JPG/WebP image to rplace palette JSON.
*
* Usage:
* node scripts/image-to-colors.js <input> [-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 <input> [-o output.json] [--alpha-threshold 128] [--dither|--dither-method <name>] [--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}`);
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env node
/**
* Upload rplace palette JSON to the canvas at (x, y).
*
* Usage:
* node scripts/upload-colors.js <data.json> <x> <y> [--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 <data.json> <x> <y> [--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));
}
+1 -1
View File
@@ -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');
-1
View File
@@ -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;
/**
+2 -3
View File
@@ -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. */
+6 -6
View File
@@ -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],
-1
View File
@@ -37,7 +37,6 @@ export function createPipeline() {
slots.T = slots.R = slots.C = null;
},
hasSource() { return src !== null; },
source() { return src; },
/**
* @param {{
+2 -6
View File
@@ -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.
-7
View File
@@ -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);