mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-14 02:16:19 +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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user