diff --git a/README.md b/README.md
index c056d49..55348dd 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd
## Features
-- **2048x2048 canvas** with 32-color palette (from [rplace.live](https://rplace.live/))
+- **4096×4096 canvas** with a 256-color palette (16-step grayscale + 240-hue HSL wheel)
- **Real-time updates** via WebSocket (Cloudflare Durable Objects)
- **Batch pixel placement** up to 2048 pixels per request
- **Rate limit** — 1 request per second per user (batch size independent)
@@ -26,7 +26,7 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd
```
Browser (Svelte SPA + WebSocket)
- | GET /api/canvas → full canvas binary (2.5MB raw)
+ | GET /api/canvas → full canvas binary (16MB raw, ~5MB gzip)
| POST /api/place → batch pixel placement
| WS /api/ws → Durable Object broadcast room
v
@@ -36,7 +36,7 @@ Cloudflare Worker (Hono)
└── Durable Object (WebSocket broadcast to all clients)
↕
Upstash Redis
- ├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB)
+ ├── STRING "canvas:v2" (1 byte per pixel, 4096×4096 = 16 MB)
└── STRING "cooldown:{userId}" (1s TTL, blocks repeat requests)
```
@@ -93,7 +93,7 @@ src/
│ ├── constants.js # Config, palette, limits (shared)
│ ├── redis-client.js # Upstash Redis factory
│ ├── canvas-storage.js # BITFIELD read/write
-│ ├── canvas-decoder.js # 5-bit → RGBA (client-side)
+│ ├── canvas-decoder.js # Raw bytes → RGBA (client-side; u8 = identity indices)
│ ├── rate-limiter.js # SET NX EX cooldown
│ ├── image-uploader.js # Browser-side batched uploader
│ └── get-user-id.js # IP-based identity
@@ -103,7 +103,7 @@ src/
│ ├── app.css # Global styles
│ └── components/
│ ├── CanvasRenderer.svelte # Canvas + zoom/pan + touch
-│ ├── ColorPicker.svelte # 32-color palette grid
+│ ├── ColorPicker.svelte # Favorites strip + 256-color grid + custom picker
│ ├── CanvasControls.svelte # Zoom buttons + coordinates
│ ├── DrawToolbar.svelte # Paint / submit / undo / redo
│ └── ImageImporter.svelte # Image-to-canvas uploader
@@ -114,7 +114,7 @@ src/
### `GET /api/canvas`
-Returns the full canvas as raw binary (5-bit packed, ~2.5MB).
+Returns the full canvas as raw binary (1 byte per pixel, 16 MB — Cloudflare gzips it on the edge).
### `POST /api/place`
@@ -149,9 +149,10 @@ Key constants in `src/lib/constants.js`:
| Constant | Default | Description |
|---|---|---|
-| `CANVAS_WIDTH` | 2048 | Canvas width in pixels |
-| `CANVAS_HEIGHT` | 2048 | Canvas height in pixels |
-| `MAX_COLORS` | 32 | Number of colors in palette |
+| `CANVAS_WIDTH` | 4096 | Canvas width in pixels |
+| `CANVAS_HEIGHT` | 4096 | Canvas height in pixels |
+| `MAX_COLORS` | 256 | Number of palette entries |
+| `BITS_PER_PIXEL` | 8 | Byte-aligned (storage = W × H bytes) |
| `MAX_BATCH_SIZE` | 2048 | Max pixels per placement request |
| `REQUEST_COOLDOWN_SEC` | 1 | Minimum seconds between requests per user |
@@ -167,7 +168,7 @@ Key constants in `src/lib/constants.js`:
- [redis-place by mehdiamrane](https://github.com/mehdiamrane/redis-place)
- [redis-challenge by alfredosalzillo](https://github.com/alfredosalzillo/redis-challenge)
- [place by dynastic](https://github.com/dynastic/place)
-- [rplace.live](https://rplace.live/) — color palette reference
+- [rplace.live](https://rplace.live/) — original 32-color palette reference (since superseded by our 256-color HSL wheel)
## License
diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md
index c34f947..c13f422 100644
--- a/docs/deployment-guide.md
+++ b/docs/deployment-guide.md
@@ -69,6 +69,7 @@ For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash
## Troubleshooting
- **Canvas loads empty**: Check Upstash credentials in secrets
-- **Pixels don't persist**: Verify BITFIELD support — test with `redis-cli BITFIELD canvas SET u5 #0 1`
+- **Pixels don't persist**: Verify BITFIELD support — test with `redis-cli BITFIELD rplace:canvas:v2 SET u8 #0 42`
+- **Old 32-color canvas still visible after deploy**: the canvas key is versioned (`rplace:canvas:v2`). The old `rplace:canvas` key is orphaned — run `redis-cli DEL rplace:canvas` once to reclaim memory.
- **WebSocket not connecting**: Ensure Durable Object migration ran (check `wrangler.json` migrations)
- **Rate limiting not working**: Verify `SET key value NX EX 1` returns `"OK"` / `null` as expected on your Upstash tier
diff --git a/docs/system-architecture.md b/docs/system-architecture.md
index fb35d62..51ce73e 100644
--- a/docs/system-architecture.md
+++ b/docs/system-architecture.md
@@ -23,9 +23,9 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T
```
1. Client fetches GET /api/canvas
-2. Worker reads Redis key via GETRANGE → raw binary
-3. Client receives ~2.5MB (5-bit packed pixels)
-4. Client decodes 5-bit values → color indices → RGBA ImageData
+2. Worker reads Redis key via GETRANGE → raw binary (16 MB, gzip-compressed by CF edge)
+3. Client receives 16 MB of bytes — each byte is a palette index (u8, byte-aligned)
+4. Client maps indices → RGBA ImageData via COLORS_RGBA lookup
5. Renders onto HTML5 Canvas with OffscreenCanvas
```
@@ -42,13 +42,14 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T
## Storage
-### Redis BITFIELD (Canvas)
+### Redis STRING / BITFIELD (Canvas)
-- Key: `canvas`
-- Encoding: 5 bits per pixel (u5), 32 colors
-- Size: `2048 * 2048 * 5 / 8 = 2,621,440 bytes` (~2.5MB)
+- Key: `rplace:canvas:v2` (bumped from the old `rplace:canvas` so the 32-color/2048² data is ignored on rollout)
+- Encoding: 8 bits per pixel (u8), 256-color palette — byte-aligned, so raw Redis bytes are the pixel indices directly
+- Size: `4096 × 4096 × 1 = 16,777,216 bytes` (16 MB)
- Offset: `y * CANVAS_WIDTH + x`
-- Atomic batch writes: single BITFIELD command with chained .set() calls
+- Atomic batch writes: single BITFIELD command chaining `SET u8 #offset color` per pixel
+- Reads via GETRANGE return the whole buffer; Cloudflare edge handles gzip
### Redis STRING (Cooldown)
diff --git a/src/client/App.svelte b/src/client/App.svelte
index cdc0bbb..78f5da0 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(27); // black
+ let selectedColor = $state(0); // palette index 0 = black (first grayscale step)
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
let mode = $state('paint');
diff --git a/src/client/components/ColorPicker.svelte b/src/client/components/ColorPicker.svelte
index 755f6d5..7102109 100644
--- a/src/client/components/ColorPicker.svelte
+++ b/src/client/components/ColorPicker.svelte
@@ -1,20 +1,91 @@
- {#each COLORS as hex, i}
-
- {/each}
+
+ {#each FAVORITE_INDICES as i (i)}
+
+ {/each}
+
+
+
+
+
+
+
+ {#if expanded}
+
+ {#each COLORS as hex, i (i)}
+
+ {/each}
+
+ {/if}
diff --git a/src/lib/canvas-decoder.js b/src/lib/canvas-decoder.js
index d650fce..11e7e69 100644
--- a/src/lib/canvas-decoder.js
+++ b/src/lib/canvas-decoder.js
@@ -1,31 +1,29 @@
-import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS_RGBA } from './constants.js';
+import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants.js';
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
-const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
+const EXPECTED_BYTES = TOTAL_PIXELS; // 1 byte per pixel (8-bit palette index)
/**
- * Decode 5-bit packed canvas buffer into an array of color indices.
- * Throws if buffer is shorter than the canvas size — silent zero-padding masks corruption.
- * @param {ArrayBuffer} buffer - raw canvas bytes
- * @returns {Uint8Array} color index per pixel
+ * "Decode" a raw canvas buffer into color indices. With 8 bits per pixel the
+ * bytes are already indices — we wrap/copy them into a Uint8Array of the
+ * expected length. We throw on short buffers rather than silently zero-padding,
+ * which previously masked corruption on the ingest path.
+ * @param {ArrayBuffer|ArrayBufferView} buffer - raw canvas bytes
+ * @returns {Uint8Array} one color index per pixel
*/
export function decodeCanvas(buffer) {
- if (buffer.byteLength < EXPECTED_BYTES) {
- throw new Error(`Canvas buffer truncated: got ${buffer.byteLength} bytes, expected ${EXPECTED_BYTES}`);
+ const byteLength = buffer.byteLength;
+ if (byteLength < EXPECTED_BYTES) {
+ throw new Error(`Canvas buffer truncated: got ${byteLength} bytes, expected ${EXPECTED_BYTES}`);
}
- const bytes = new Uint8Array(buffer);
- const indices = new Uint8Array(TOTAL_PIXELS);
-
- let bitPos = 0;
- for (let i = 0; i < TOTAL_PIXELS; i++) {
- const byteIndex = bitPos >> 3;
- const bitOffset = bitPos & 7;
- const value = ((bytes[byteIndex] << 8 | bytes[byteIndex + 1]) >> (11 - bitOffset)) & 0x1f;
- indices[i] = value;
- bitPos += 5;
+ if (buffer instanceof Uint8Array) {
+ return buffer.byteLength === EXPECTED_BYTES ? buffer : buffer.subarray(0, EXPECTED_BYTES);
}
-
- return indices;
+ return new Uint8Array(
+ buffer instanceof ArrayBuffer ? buffer : buffer.buffer,
+ buffer instanceof ArrayBuffer ? 0 : buffer.byteOffset,
+ EXPECTED_BYTES,
+ );
}
/**
diff --git a/src/lib/canvas-storage.js b/src/lib/canvas-storage.js
index 50171c9..e9feace 100644
--- a/src/lib/canvas-storage.js
+++ b/src/lib/canvas-storage.js
@@ -1,8 +1,8 @@
import { redisRaw, redisRawBinary } from './redis-client.js';
-import { CANVAS_WIDTH, TOTAL_PIXELS, BITS_PER_PIXEL, REDIS_CANVAS_KEY } from './constants.js';
+import { CANVAS_WIDTH, TOTAL_PIXELS, REDIS_CANVAS_KEY } from './constants.js';
-/** Total bytes needed for the canvas bitfield */
-const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
+/** Total bytes needed for the canvas — 1 byte per pixel (u8 palette index). */
+const CANVAS_BYTES = TOTAL_PIXELS;
/**
* Get the full canvas as a Uint8Array of raw bytes.
@@ -37,6 +37,7 @@ export async function getFullCanvas(env) {
/**
* Set multiple pixels in a single atomic BITFIELD command.
* Uses raw REST API — SDK bitfield builder is broken in @upstash/redis 1.x.
+ * With u8, BITFIELD offsets are byte-aligned (`#N` = byte N).
* @param {object} env
* @param {Array<{x: number, y: number, color: number}>} pixels
*/
@@ -46,7 +47,7 @@ export async function setPixels(env, pixels) {
const command = ['BITFIELD', REDIS_CANVAS_KEY];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
- command.push('SET', 'u5', `#${offset}`, String(color));
+ command.push('SET', 'u8', `#${offset}`, String(color));
}
await redisRaw(env, command);
}
diff --git a/src/lib/constants.js b/src/lib/constants.js
index c4c3ec2..b96dafe 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -1,35 +1,88 @@
-/** Canvas dimensions (configurable via wrangler.json vars) */
-export const CANVAS_WIDTH = 2048;
-export const CANVAS_HEIGHT = 2048;
+/** Canvas dimensions (4096 × 4096 = 16,777,216 pixels). */
+export const CANVAS_WIDTH = 4096;
+export const CANVAS_HEIGHT = 4096;
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
-/** Color encoding */
-export const BITS_PER_PIXEL = 5;
-export const MAX_COLORS = 32;
+/** 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;
+export const MAX_COLORS = 256;
/** Rate limiting — one request per second per user, batch size independent. */
export const REQUEST_COOLDOWN_SEC = 1;
export const MAX_BATCH_SIZE = 2048;
-/** Redis keys */
+/** Redis keys. Key is versioned (":v2") so the old 32-color / 2048² canvas
+ * key (`rplace:canvas`) is ignored after this rollout — old data stays in
+ * Redis harmlessly until an operator deletes it. */
export const REDIS_KEY_PREFIX = 'rplace:';
-export const REDIS_CANVAS_KEY = `${REDIS_KEY_PREFIX}canvas`;
-
-/** 32-color palette from rplace.live (hex values) */
-export const COLORS = [
- '#6d001a', '#be0039', '#ff4500', '#ffa800', '#ffd635', '#fff8b8',
- '#00a368', '#00cc78', '#7eed56', '#00756f', '#009eaa', '#00ccc0',
- '#2450a4', '#3690ea', '#51e9f4', '#493ac1', '#6a5cff', '#94b3ff',
- '#811e9f', '#b44ac0', '#e4abff', '#de107f', '#ff3881', '#ff99aa',
- '#6d482f', '#9c6926', '#ffb470', '#000000', '#515252', '#898d90',
- '#d4d7d9', '#ffffff',
-];
+export const REDIS_CANVAS_KEY = `${REDIS_KEY_PREFIX}canvas:v2`;
/**
- * RGBA values for each color (pre-computed for canvas rendering).
- * Each entry is [r, g, b, 255].
+ * Build the 256-color palette deterministically:
+ * - Indices 0..15 → 16 grayscale steps (pure black → pure white)
+ * - Indices 16..255 → 240 HSL wheel: 4 lightness rings × 60 hues @ 82% saturation
+ * Layout is fixed so clients, tests, and image-quantizer all agree.
*/
+function buildPalette() {
+ const hexByte = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
+ const asHex = (r, g, b) => `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`;
+ function hslToRgb(h, s, l) {
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const hp = h / 60;
+ const x = c * (1 - Math.abs((hp % 2) - 1));
+ let r1 = 0, g1 = 0, b1 = 0;
+ if (0 <= hp && hp < 1) [r1, g1, b1] = [c, x, 0];
+ else if (hp < 2) [r1, g1, b1] = [x, c, 0];
+ else if (hp < 3) [r1, g1, b1] = [0, c, x];
+ else if (hp < 4) [r1, g1, b1] = [0, x, c];
+ else if (hp < 5) [r1, g1, b1] = [x, 0, c];
+ else [r1, g1, b1] = [c, 0, x];
+ const m = l - c / 2;
+ return [(r1 + m) * 255, (g1 + m) * 255, (b1 + m) * 255];
+ }
+
+ const out = [];
+ for (let i = 0; i < 16; i++) {
+ const v = Math.round((i * 255) / 15);
+ out.push(asHex(v, v, v));
+ }
+ const lightnesses = [0.85, 0.65, 0.45, 0.25];
+ const hueSteps = 60;
+ for (const L of lightnesses) {
+ for (let h = 0; h < hueSteps; h++) {
+ const [r, g, b] = hslToRgb((h * 360) / hueSteps, 0.82, L);
+ out.push(asHex(r, g, b));
+ }
+ }
+ return out;
+}
+
+/** 256 hex strings — built once at module load. */
+export const COLORS = buildPalette();
+
+/** RGBA tuples for each palette entry, pre-computed for hot render loops. */
export const COLORS_RGBA = COLORS.map((hex) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff, 255];
});
+
+/**
+ * Find the nearest palette index to an arbitrary RGB triple. Simple Euclidean
+ * distance in RGB — good enough for the "custom color" snap.
+ * @param {number} r
+ * @param {number} g
+ * @param {number} b
+ * @returns {number} index into COLORS
+ */
+export function nearestPaletteIndex(r, g, b) {
+ let best = 0;
+ let bestDist = Infinity;
+ for (let i = 0; i < COLORS_RGBA.length; i++) {
+ const [pr, pg, pb] = COLORS_RGBA[i];
+ const dr = r - pr, dg = g - pg, db = b - pb;
+ const d = dr * dr + dg * dg + db * db;
+ if (d < bestDist) { bestDist = d; best = i; if (d === 0) return i; }
+ }
+ return best;
+}
diff --git a/test/integration/redis-canvas-roundtrip.test.js b/test/integration/redis-canvas-roundtrip.test.js
index 4524fef..40d246c 100644
--- a/test/integration/redis-canvas-roundtrip.test.js
+++ b/test/integration/redis-canvas-roundtrip.test.js
@@ -7,10 +7,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { GenericContainer } from 'testcontainers';
import Redis from 'ioredis';
import { decodeCanvas } from '../../src/lib/canvas-decoder.js';
-import { CANVAS_WIDTH, BITS_PER_PIXEL, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
+import { CANVAS_WIDTH, CANVAS_HEIGHT, REDIS_CANVAS_KEY } from '../../src/lib/constants.js';
-const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_WIDTH;
-const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
+const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
+const CANVAS_BYTES = TOTAL_PIXELS; // 1 byte per pixel (u8)
let container;
let redis;
@@ -37,7 +37,7 @@ async function writePixels(pixels) {
const args = [];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
- args.push('SET', 'u5', `#${offset}`, String(color));
+ args.push('SET', 'u8', `#${offset}`, String(color));
}
return redis.call('BITFIELD', REDIS_CANVAS_KEY, ...args);
}
@@ -71,16 +71,16 @@ describe('Redis BITFIELD canvas round-trip', () => {
expect(indices[0]).toBe(15);
});
- it('round-trips all 32 color values', async () => {
+ it('round-trips 256 color values across the palette', async () => {
const pixels = [];
- for (let i = 0; i < 32; i++) {
+ for (let i = 0; i < 256; i++) {
pixels.push({ x: i, y: 1, color: i });
}
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
- for (let i = 0; i < 32; i++) {
+ for (let i = 0; i < 256; i++) {
expect(indices[1 * CANVAS_WIDTH + i]).toBe(i);
}
});
@@ -88,10 +88,10 @@ describe('Redis BITFIELD canvas round-trip', () => {
it('handles pixels at various canvas positions', async () => {
const testCases = [
{ x: 0, y: 0, color: 1 },
- { x: 2047, y: 0, color: 31 },
- { x: 0, y: 2047, color: 16 },
- { x: 2047, y: 2047, color: 8 },
- { x: 1024, y: 1024, color: 20 },
+ { x: CANVAS_WIDTH - 1, y: 0, color: 255 },
+ { x: 0, y: CANVAS_HEIGHT - 1, color: 128 },
+ { x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: 42 },
+ { x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2, color: 200 },
];
await writePixels(testCases);
const bytes = await readCanvasBytes();
@@ -108,48 +108,38 @@ describe('Redis BITFIELD canvas round-trip', () => {
let indices = decodeCanvas(bytes.buffer);
expect(indices[500 * CANVAS_WIDTH + 500]).toBe(10);
- // Overwrite
- await writePixels([{ x: 500, y: 500, color: 25 }]);
+ await writePixels([{ x: 500, y: 500, color: 250 }]);
bytes = await readCanvasBytes();
indices = decodeCanvas(bytes.buffer);
- expect(indices[500 * CANVAS_WIDTH + 500]).toBe(25);
+ expect(indices[500 * CANVAS_WIDTH + 500]).toBe(250);
});
it('batch writes are atomic (all pixels in one BITFIELD)', async () => {
- const batchSize = 100;
+ const batchSize = 500;
const pixels = [];
for (let i = 0; i < batchSize; i++) {
- pixels.push({ x: i, y: 2, color: i % 32 });
+ pixels.push({ x: i, y: 2, color: i % 256 });
}
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
for (let i = 0; i < batchSize; i++) {
- expect(indices[2 * CANVAS_WIDTH + i]).toBe(i % 32);
+ expect(indices[2 * CANVAS_WIDTH + i]).toBe(i % 256);
}
});
- it('adjacent pixels do not corrupt each other (5-bit boundary)', async () => {
- // 5-bit values pack across byte boundaries; verify no bleed
+ it('adjacent pixels do not corrupt each other (byte boundary)', async () => {
+ // With u8 each pixel is its own byte; verify no bleed between neighbors.
const pixels = [];
- for (let i = 0; i < 16; i++) {
- pixels.push({ x: i, y: 3, color: 31 }); // all bits set
- }
- // Interleave with zeros
- for (let i = 16; i < 32; i++) {
- pixels.push({ x: i, y: 3, color: 0 });
- }
+ for (let i = 0; i < 16; i++) pixels.push({ x: i, y: 3, color: 255 }); // all bits
+ for (let i = 16; i < 32; i++) pixels.push({ x: i, y: 3, color: 0 }); // zero
await writePixels(pixels);
const bytes = await readCanvasBytes();
const indices = decodeCanvas(bytes.buffer);
- for (let i = 0; i < 16; i++) {
- expect(indices[3 * CANVAS_WIDTH + i]).toBe(31);
- }
- for (let i = 16; i < 32; i++) {
- expect(indices[3 * CANVAS_WIDTH + i]).toBe(0);
- }
+ for (let i = 0; i < 16; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(255);
+ for (let i = 16; i < 32; i++) expect(indices[3 * CANVAS_WIDTH + i]).toBe(0);
});
});
diff --git a/test/lib/canvas-decoder.test.js b/test/lib/canvas-decoder.test.js
index 3cd76d4..23c2cea 100644
--- a/test/lib/canvas-decoder.test.js
+++ b/test/lib/canvas-decoder.test.js
@@ -1,30 +1,12 @@
import { describe, it, expect } from 'vitest';
import { decodeCanvas, indicesToRgba } from '../../src/lib/canvas-decoder.js';
-import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
+import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS, COLORS_RGBA, MAX_COLORS } from '../../src/lib/constants.js';
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
-const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
-
-/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage).
- * Returns a full-canvas-sized buffer (zero-padded tail) so decodeCanvas accepts it. */
-function encodeIndicesPadded(indices) {
- const bytes = new Uint8Array(EXPECTED_BYTES);
- for (let i = 0; i < indices.length; i++) {
- const bitPos = i * 5;
- const byteIndex = bitPos >> 3;
- const bitOffset = bitPos & 7;
- bytes[byteIndex] |= (indices[i] << (11 - bitOffset)) >> 8;
- if (bitOffset > 3) {
- bytes[byteIndex + 1] |= (indices[i] << (11 - bitOffset)) & 0xff;
- } else {
- bytes[byteIndex] |= (indices[i] << (3 - bitOffset));
- }
- }
- return bytes;
-}
+const EXPECTED_BYTES = TOTAL_PIXELS; // 1 byte per pixel
describe('decodeCanvas', () => {
- it('decodes a full zero-filled buffer as all zeros', () => {
+ it('accepts a full zero-filled buffer', () => {
const buffer = new ArrayBuffer(EXPECTED_BYTES);
const indices = decodeCanvas(buffer);
expect(indices.length).toBe(TOTAL_PIXELS);
@@ -36,64 +18,51 @@ describe('decodeCanvas', () => {
expect(() => decodeCanvas(new ArrayBuffer(EXPECTED_BYTES - 1))).toThrow(/truncated/);
});
- it('decodes a single pixel', () => {
- // Color 15 at pixel 0: binary 01111 in first 5 bits → byte 0 = 0111_1000 = 0x78
+ it('returns bytes as-is when lengths match (identity decode)', () => {
const bytes = new Uint8Array(EXPECTED_BYTES);
- bytes[0] = 0x78;
- const indices = decodeCanvas(bytes.buffer);
- expect(indices[0]).toBe(15);
+ bytes[0] = 200; bytes[1] = 42; bytes[EXPECTED_BYTES - 1] = 99;
+ const decoded = decodeCanvas(bytes);
+ expect(decoded[0]).toBe(200);
+ expect(decoded[1]).toBe(42);
+ expect(decoded[EXPECTED_BYTES - 1]).toBe(99);
});
- it('round-trips all 32 color values', () => {
- const input = new Uint8Array(32);
- for (let i = 0; i < 32; i++) input[i] = i;
- const encoded = encodeIndicesPadded(input);
- const decoded = decodeCanvas(encoded.buffer);
- for (let i = 0; i < 32; i++) {
- expect(decoded[i]).toBe(i);
- }
+ it('handles all 256 color values', () => {
+ const bytes = new Uint8Array(EXPECTED_BYTES);
+ for (let i = 0; i < 256; i++) bytes[i] = i;
+ const decoded = decodeCanvas(bytes);
+ for (let i = 0; i < 256; i++) expect(decoded[i]).toBe(i);
});
- it('round-trips repeated color patterns', () => {
- const input = new Uint8Array(100);
- for (let i = 0; i < 100; i++) input[i] = i % 32;
- const encoded = encodeIndicesPadded(input);
- const decoded = decodeCanvas(encoded.buffer);
- for (let i = 0; i < 100; i++) {
- expect(decoded[i]).toBe(i % 32);
- }
- });
-
- it('handles max color value (31) at various offsets', () => {
- const input = new Uint8Array(8).fill(31);
- const encoded = encodeIndicesPadded(input);
- const decoded = decodeCanvas(encoded.buffer);
- for (let i = 0; i < 8; i++) {
- expect(decoded[i]).toBe(31);
- }
+ it('slices when given a larger buffer', () => {
+ const bytes = new Uint8Array(EXPECTED_BYTES + 10);
+ bytes[EXPECTED_BYTES - 1] = 7;
+ const decoded = decodeCanvas(bytes);
+ expect(decoded.length).toBe(EXPECTED_BYTES);
+ expect(decoded[EXPECTED_BYTES - 1]).toBe(7);
});
});
describe('indicesToRgba', () => {
- it('produces correct RGBA for all 32 colors', () => {
- const indices = new Uint8Array(32);
- for (let i = 0; i < 32; i++) indices[i] = i;
+ it('produces correct RGBA across a sampling of palette indices', () => {
+ const samples = [0, 1, 15, 16, 50, 120, 200, 255];
+ const indices = new Uint8Array(samples);
const rgba = indicesToRgba(indices);
- expect(rgba.length).toBe(32 * 4);
- for (let i = 0; i < 32; i++) {
- const [r, g, b, a] = COLORS_RGBA[i];
+ expect(rgba.length).toBe(samples.length * 4);
+ samples.forEach((paletteIdx, i) => {
+ const [r, g, b, a] = COLORS_RGBA[paletteIdx];
expect(rgba[i * 4]).toBe(r);
expect(rgba[i * 4 + 1]).toBe(g);
expect(rgba[i * 4 + 2]).toBe(b);
expect(rgba[i * 4 + 3]).toBe(a);
- }
+ });
});
it('always sets alpha to 255', () => {
- const indices = new Uint8Array([0, 15, 27, 31]);
+ const indices = new Uint8Array([0, 15, 100, 255]);
const rgba = indicesToRgba(indices);
- for (let i = 0; i < 4; i++) {
+ for (let i = 0; i < indices.length; i++) {
expect(rgba[i * 4 + 3]).toBe(255);
}
});
@@ -104,12 +73,14 @@ describe('indicesToRgba', () => {
});
});
-describe('COLORS_RGBA consistency', () => {
- it('has 32 entries matching COLORS hex values', () => {
- expect(COLORS_RGBA.length).toBe(32);
- expect(COLORS.length).toBe(32);
+describe('COLORS / COLORS_RGBA consistency', () => {
+ it('has MAX_COLORS (256) entries', () => {
+ expect(COLORS.length).toBe(MAX_COLORS);
+ expect(COLORS_RGBA.length).toBe(MAX_COLORS);
+ });
- for (let i = 0; i < 32; i++) {
+ it('each hex string maps to its RGBA tuple', () => {
+ for (let i = 0; i < COLORS.length; i++) {
const hex = COLORS[i];
const n = parseInt(hex.slice(1), 16);
expect(COLORS_RGBA[i][0]).toBe((n >> 16) & 0xff);
@@ -118,4 +89,15 @@ describe('COLORS_RGBA consistency', () => {
expect(COLORS_RGBA[i][3]).toBe(255);
}
});
+
+ it('indices 0..15 form a monotonic grayscale ramp (black → white)', () => {
+ for (let i = 0; i < 16; i++) {
+ const [r, g, b] = COLORS_RGBA[i];
+ expect(r).toBe(g);
+ expect(g).toBe(b);
+ if (i > 0) expect(r).toBeGreaterThan(COLORS_RGBA[i - 1][0]);
+ }
+ expect(COLORS_RGBA[0]).toEqual([0, 0, 0, 255]);
+ expect(COLORS_RGBA[15]).toEqual([255, 255, 255, 255]);
+ });
});
diff --git a/test/lib/canvas-storage.test.js b/test/lib/canvas-storage.test.js
index b204820..f37106b 100644
--- a/test/lib/canvas-storage.test.js
+++ b/test/lib/canvas-storage.test.js
@@ -10,7 +10,7 @@ vi.mock('../../src/lib/redis-client.js', () => ({
import { getFullCanvas, setPixels } from '../../src/lib/canvas-storage.js';
import { redisRaw, redisRawBinary } from '../../src/lib/redis-client.js';
-const CANVAS_BYTES = Math.ceil((CANVAS_WIDTH * CANVAS_WIDTH * 5) / 8);
+const CANVAS_BYTES = CANVAS_WIDTH * CANVAS_WIDTH; // u8 = 1 byte per pixel
describe('setPixels', () => {
beforeEach(() => vi.clearAllMocks());
@@ -28,9 +28,9 @@ describe('setPixels', () => {
expect(call[0]).toBe('BITFIELD');
expect(call[1]).toBe(REDIS_CANVAS_KEY);
expect(call[2]).toBe('SET');
- expect(call[3]).toBe('u5');
- // offset = 20 * 2048 + 10 = 40970
- expect(call[4]).toBe('#40970');
+ expect(call[3]).toBe('u8');
+ const expectedOffset = 20 * CANVAS_WIDTH + 10;
+ expect(call[4]).toBe(`#${expectedOffset}`);
expect(call[5]).toBe('5');
});
@@ -38,24 +38,26 @@ describe('setPixels', () => {
redisRaw.mockResolvedValue({ result: [0, 0] });
await setPixels({}, [
{ x: 0, y: 0, color: 1 },
- { x: 1, y: 0, color: 31 },
+ { x: 1, y: 0, color: 255 },
]);
const call = redisRaw.mock.calls[0][1];
- // BITFIELD key SET u5 #0 1 SET u5 #1 31
+ // BITFIELD key SET u8 #0 1 SET u8 #1 255
expect(call).toEqual([
'BITFIELD', REDIS_CANVAS_KEY,
- 'SET', 'u5', '#0', '1',
- 'SET', 'u5', '#1', '31',
+ 'SET', 'u8', '#0', '1',
+ 'SET', 'u8', '#1', '255',
]);
});
it('computes offset correctly for various positions', async () => {
redisRaw.mockResolvedValue({ result: [0] });
- // Pixel at (2047, 2047) — last pixel
- await setPixels({}, [{ x: 2047, y: 2047, color: 0 }]);
- const offset = 2047 * CANVAS_WIDTH + 2047;
+ // Last pixel of the canvas.
+ const lastX = CANVAS_WIDTH - 1;
+ const lastY = CANVAS_WIDTH - 1;
+ await setPixels({}, [{ x: lastX, y: lastY, color: 0 }]);
+ const offset = lastY * CANVAS_WIDTH + lastX;
expect(redisRaw.mock.calls[0][1][4]).toBe(`#${offset}`);
});
});
diff --git a/test/lib/image-to-palette.test.js b/test/lib/image-to-palette.test.js
index e5f7915..7616f1b 100644
--- a/test/lib/image-to-palette.test.js
+++ b/test/lib/image-to-palette.test.js
@@ -1,6 +1,12 @@
import { describe, it, expect } from 'vitest';
import { rgbaToPalette, nearestColorIndex, paletteToRgba, DITHER_METHODS } from '../../src/lib/image-to-palette.js';
import { ERROR_DIFFUSION_KERNELS } from '../../src/lib/dither-kernels.js';
+import { COLORS_RGBA } from '../../src/lib/constants.js';
+
+// Palette indices anchored to the new 256-color palette layout:
+// 0..15 = grayscale ramp (0 = pure black, 15 = pure white)
+const PALETTE_BLACK = 0;
+const PALETTE_WHITE = 15;
function solidRgba(w, h, r, g, b, a = 255) {
const out = new Uint8ClampedArray(w * h * 4);
@@ -12,10 +18,12 @@ function solidRgba(w, h, r, g, b, a = 255) {
describe('rgbaToPalette', () => {
it('nearest (default / method=none) picks exact palette hits', () => {
- // #ff4500 is palette index 2. Feed that exact color.
- const src = solidRgba(2, 2, 0xff, 0x45, 0x00);
+ // Pick an actual palette entry from the new palette and feed it back.
+ const probeIdx = 120;
+ const [r, g, b] = COLORS_RGBA[probeIdx];
+ const src = solidRgba(2, 2, r, g, b);
const idx = rgbaToPalette(src, 2, 2);
- expect([...idx]).toEqual([2, 2, 2, 2]);
+ expect([...idx]).toEqual([probeIdx, probeIdx, probeIdx, probeIdx]);
});
it('transparent pixels become -1', () => {
@@ -42,10 +50,10 @@ describe('rgbaToPalette', () => {
for (const method of DITHER_METHODS) {
const idx = rgbaToPalette(src, w, h, { method });
expect(idx.length).toBe(w * h);
- // Every index must be valid palette (0..31) since all pixels are opaque.
+ // Every index must be a valid opaque palette entry since all source pixels are opaque.
for (let i = 0; i < idx.length; i++) {
expect(idx[i]).toBeGreaterThanOrEqual(0);
- expect(idx[i]).toBeLessThan(32);
+ expect(idx[i]).toBeLessThan(256);
}
}
});
@@ -65,10 +73,10 @@ describe('rgbaToPalette', () => {
it('on a solid-color image, dither produces the same single color everywhere', () => {
// Feeding one exact palette color means zero error → all methods converge.
- const src = solidRgba(4, 4, 0x00, 0x00, 0x00); // palette 27 (#000000)
+ const src = solidRgba(4, 4, 0, 0, 0); // pure black = palette index 0
for (const method of ['floyd', 'atkinson', 'jarvis', 'burkes', 'sierra', 'sierra-lite']) {
const idx = rgbaToPalette(src, 4, 4, { method });
- expect([...idx].every((v) => v === 27)).toBe(true);
+ expect([...idx].every((v) => v === PALETTE_BLACK)).toBe(true);
}
});
@@ -105,14 +113,14 @@ describe('rgbaToPalette skip-white / paint-transparent', () => {
expect(idx[1]).toBe(-1);
});
- it('paintTransparent maps transparent pixels to palette white (31)', () => {
+ it('paintTransparent maps transparent pixels to palette white', () => {
const src = new Uint8ClampedArray([
100, 100, 100, 10, // transparent
255, 255, 255, 255, // white
]);
const idx = rgbaToPalette(src, 2, 1, { paintTransparent: true });
- expect(idx[0]).toBe(31); // ffffff
- expect(idx[1]).toBe(31);
+ expect(idx[0]).toBe(PALETTE_WHITE);
+ expect(idx[1]).toBe(PALETTE_WHITE);
});
it('paintTransparent + skipWhite: transparent pixels end up skipped', () => {
@@ -124,22 +132,23 @@ describe('rgbaToPalette skip-white / paint-transparent', () => {
describe('nearestColorIndex', () => {
it('exact palette match returns that index', () => {
- // Palette 0 is #6d001a → (0x6d, 0x00, 0x1a)
- expect(nearestColorIndex(0x6d, 0x00, 0x1a)).toBe(0);
+ const probe = 77;
+ const [r, g, b] = COLORS_RGBA[probe];
+ expect(nearestColorIndex(r, g, b)).toBe(probe);
});
- it('pure white maps to palette 31 (#ffffff)', () => {
- expect(nearestColorIndex(255, 255, 255)).toBe(31);
+ it('pure white maps to the grayscale-white palette entry', () => {
+ expect(nearestColorIndex(255, 255, 255)).toBe(PALETTE_WHITE);
});
- it('pure black maps to palette 27 (#000000)', () => {
- expect(nearestColorIndex(0, 0, 0)).toBe(27);
+ it('pure black maps to the grayscale-black palette entry', () => {
+ expect(nearestColorIndex(0, 0, 0)).toBe(PALETTE_BLACK);
});
});
describe('paletteToRgba', () => {
it('valid indices map to their palette RGB', () => {
- const rgba = paletteToRgba([27, 31], 2, 1);
- expect([rgba[0], rgba[1], rgba[2]]).toEqual([0, 0, 0]); // black
- expect([rgba[4], rgba[5], rgba[6]]).toEqual([255, 255, 255]); // white
+ const rgba = paletteToRgba([PALETTE_BLACK, PALETTE_WHITE], 2, 1);
+ expect([rgba[0], rgba[1], rgba[2]]).toEqual([0, 0, 0]);
+ expect([rgba[4], rgba[5], rgba[6]]).toEqual([255, 255, 255]);
});
it('-1 renders as a checkerboard cell, alpha 255', () => {
const rgba = paletteToRgba([-1], 1, 1);