From cfbac2a5865ee1ecb09bf626b453a16263dac555 Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sat, 18 Apr 2026 13:47:01 +0700 Subject: [PATCH] feat(canvas): 4096^2 canvas, 256-color palette (u8 byte-aligned), custom picker (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas: - CANVAS_W/H = 4096, total 16,777,216 pixels - BITS_PER_PIXEL = 8 (byte-aligned) — raw Redis bytes are palette indices - Canvas-decoder becomes an identity wrap/copy - Storage BITFIELD uses u8; offset = y*W + x - Redis key versioned to rplace:canvas:v2 so old 32-color/2048^2 data is orphaned (operators can DEL the old key to reclaim memory) Palette: - 256 entries, generated deterministically: - 0..15 = 16-step grayscale ramp (pure black -> pure white) - 16..255 = 240 HSL wheel (4 lightness rings x 60 hues @ 82% saturation) - nearestPaletteIndex(r,g,b) helper for custom-color snapping UI: - ColorPicker: 16-swatch favorites strip (grays + 8 accents) + current-color swatch + expand toggle for the full 16x16 grid + "Custom..." button that opens the native and snaps to nearest palette entry - Default selected color bumped to index 0 (black) Tests + docs: - canvas-decoder tests rewritten for identity u8 decode - canvas-storage tests updated for u8 offsets - image-to-palette tests anchored to PALETTE_BLACK=0 / PALETTE_WHITE=15 and COLORS_RGBA[i] probes (no more hardcoded old 32-color indices) - integration test uses u8 BITFIELD and canvas-aware bounds - README, system-architecture, deployment-guide updated (storage math, migration note for orphaned old key) --- README.md | 21 +-- docs/deployment-guide.md | 3 +- docs/system-architecture.md | 17 +- src/client/App.svelte | 2 +- src/client/components/ColorPicker.svelte | 165 +++++++++++++++--- src/lib/canvas-decoder.js | 38 ++-- src/lib/canvas-storage.js | 9 +- src/lib/constants.js | 93 +++++++--- .../redis-canvas-roundtrip.test.js | 54 +++--- test/lib/canvas-decoder.test.js | 112 +++++------- test/lib/canvas-storage.test.js | 24 +-- test/lib/image-to-palette.test.js | 47 +++-- 12 files changed, 371 insertions(+), 214 deletions(-) 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);