feat(canvas): 4096^2 canvas, 256-color palette (u8 byte-aligned), custom picker (#5)

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 <input type="color"> 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)
This commit is contained in:
2026-04-18 13:47:01 +07:00
committed by GitHub
parent 0bf51a410e
commit cfbac2a586
12 changed files with 371 additions and 214 deletions
+11 -10
View File
@@ -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
+2 -1
View File
@@ -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
+9 -8
View File
@@ -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)
+1 -1
View File
@@ -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');
+142 -23
View File
@@ -1,20 +1,91 @@
<script>
import { COLORS } from '../../lib/constants.js';
import { COLORS, nearestPaletteIndex } from '../../lib/constants.js';
let { selectedColor, onSelect } = $props();
// Favorites strip — first 16 palette entries (the grayscale ramp) plus 8
// saturated accents from the wheel for quick access.
const FAVORITE_INDICES = [
// 8 grays (from the 16-gray ramp at 0..15, spaced every 2 steps).
0, 2, 5, 8, 11, 13, 14, 15,
// 8 vivid accents (wheel indices 16..255, picked to span the hue circle).
// 16 + (hue * 60) at lightness 65% (second ring), 7.5° hue steps.
// We just pull 8 evenly spaced entries from the vivid ring.
76, 84, 91, 99, 106, 114, 121, 128,
];
let expanded = $state(false);
let customInput = $state();
let customHex = $state('#ffffff');
function selectAndClose(i) {
onSelect(i);
}
function openCustom() {
customInput?.click();
}
function onCustomChange(e) {
const hex = e.currentTarget.value;
customHex = hex;
const n = parseInt(hex.slice(1), 16);
const idx = nearestPaletteIndex((n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff);
onSelect(idx);
expanded = true; // so user can see which palette swatch got picked
}
</script>
<div class="color-picker">
{#each COLORS as hex, i}
<button
class="swatch"
class:selected={i === selectedColor}
style="background-color: {hex}"
onclick={() => onSelect(i)}
title="Color {i}: {hex}"
aria-label="Select color {hex}"
></button>
{/each}
<div class="strip">
{#each FAVORITE_INDICES as i (i)}
<button
class="swatch"
class:selected={i === selectedColor}
style="background-color: {COLORS[i]}"
onclick={() => selectAndClose(i)}
title="Color {i}: {COLORS[i]}"
aria-label="Select color {COLORS[i]}"
></button>
{/each}
<div class="divider"></div>
<button class="current" style="background-color: {COLORS[selectedColor]}"
title="Current: {COLORS[selectedColor]} (index {selectedColor})" aria-label="Current color"></button>
<button class="toggle" onclick={() => expanded = !expanded}
title={expanded ? 'Hide full palette' : 'Show full palette'}
aria-expanded={expanded}>
{expanded ? '▾' : '▸'}
</button>
<button class="custom" onclick={openCustom} title="Pick any RGB snapped to nearest palette color">
Custom
</button>
<input
bind:this={customInput}
type="color"
value={customHex}
onchange={onCustomChange}
aria-hidden="true"
tabindex="-1"
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"
/>
</div>
{#if expanded}
<div class="grid" role="listbox" aria-label="Full 256-color palette">
{#each COLORS as hex, i (i)}
<button
class="cell"
class:selected={i === selectedColor}
style="background-color: {hex}"
onclick={() => selectAndClose(i)}
title="Color {i}: {hex}"
aria-label="Select color {hex}"
aria-selected={i === selectedColor}
role="option"
></button>
{/each}
</div>
{/if}
</div>
<style>
@@ -23,33 +94,81 @@
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
border-radius: 10px;
backdrop-filter: blur(8px);
z-index: 10;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.strip { display: flex; align-items: center; gap: 4px; }
.swatch {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s;
padding: 0;
}
.swatch:hover {
transform: scale(1.2);
z-index: 1;
}
.swatch:hover { transform: scale(1.2); z-index: 1; }
.swatch.selected {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.divider { width: 1px; height: 20px; background: #444; margin: 0 4px; }
.current {
width: 32px; height: 32px;
border: 2px solid #888;
border-radius: 6px;
cursor: default;
padding: 0;
}
.toggle, .custom {
padding: 4px 10px;
background: #262626;
color: #ddd;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.toggle { min-width: 30px; }
.toggle:hover, .custom:hover { background: #333; }
.grid {
display: grid;
grid-template-columns: repeat(16, 1fr);
gap: 2px;
max-width: calc(16 * 22px + 15 * 2px);
}
.cell {
width: 22px;
height: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 3px;
cursor: pointer;
padding: 0;
transition: transform 0.08s;
}
.cell:hover {
transform: scale(1.3);
z-index: 2;
border-color: rgba(255, 255, 255, 0.6);
}
.cell.selected {
border-color: #fff;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6);
transform: scale(1.15);
z-index: 1;
}
</style>
+18 -20
View File
@@ -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,
);
}
/**
+5 -4
View File
@@ -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);
}
+73 -20
View File
@@ -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;
}
+22 -32
View File
@@ -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);
});
});
+47 -65
View File
@@ -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]);
});
});
+13 -11
View File
@@ -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}`);
});
});
+28 -19
View File
@@ -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);