mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
0bf51a410e
commit
cfbac2a586
12 files changed
+371
-214
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user