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:
tiennm99 authored and GitHub committed 2026-04-18 13:47:01 +07:00
1 parent 0bf51a410e
commit cfbac2a586
12 files changed
+371 -214

No files matched your search

+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;
}