feat(client): add image importer panel with dither toggle

New floating "Import Image" panel decodes PNG/JPG/WebP in the browser, shows
a palette-matched preview, and uploads pixels at a user-chosen origin with
pause/resume/cancel and live progress. Options:

- Skip-matching: compare against CanvasRenderer.getCommittedColor so pixels
  already correct don't waste credits.
- Dither (Floyd-Steinberg): toggle between nearest-color and error-diffusion
  quantization; reactively re-quantizes the cached source on change.

Uploader (src/lib/image-uploader.js) tracks credits locally with regen and
backs off on 429 responses so it can run alongside manual drawing.
This commit is contained in:
tiennm99 committed 2026-04-17 11:25:16 +07:00
1 parent 1565776f02
commit 4a149c38eb
4 files changed
+468

No files matched your search

+32
View File
@@ -5,6 +5,7 @@
import CanvasControls from './components/CanvasControls.svelte';
import DrawToolbar from './components/DrawToolbar.svelte';
import UserInfo from './components/UserInfo.svelte';
import ImageImporter from './components/ImageImporter.svelte';
let selectedColor = $state(27); // black
let credits = $state(MAX_CREDITS);
@@ -15,6 +16,7 @@
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
let toast = $state(null); // { kind: 'error'|'info', text: string }
let toastTimer = null;
let importerOpen = $state(false);
/** @type {CanvasRenderer} */
let canvasRenderer;
@@ -177,6 +179,19 @@
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
<UserInfo {credits} />
<button class="import-btn" onclick={() => importerOpen = !importerOpen}
title="Upload an image and place it on the canvas">
{importerOpen ? 'Close Import' : 'Import Image'}
</button>
<ImageImporter
open={importerOpen}
{cursorPos}
getCommittedColor={(x, y) => canvasRenderer?.getCommittedColor(x, y) ?? -1}
onClose={() => importerOpen = false}
onCredits={(c) => credits = c}
/>
{#if toast}
<div class="toast {toast.kind}" role="status" aria-live="polite">{toast.text}</div>
{/if}
@@ -203,4 +218,21 @@
}
.toast.error { background: #8b2222; color: #fff; border: 1px solid #a33; }
.toast.info { background: #1d3a8a; color: #fff; border: 1px solid #3b5cb8; }
.import-btn {
position: fixed;
top: 12px;
right: 12px;
padding: 8px 14px;
background: rgba(37, 99, 235, 0.9);
border: 1px solid #3b82f6;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
border-radius: 8px;
cursor: pointer;
z-index: 25;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.import-btn:hover { background: #1d4ed8; }
</style>
@@ -153,6 +153,11 @@
export function getPendingPixels() { return buffer.getAllPixels(); }
export function getCommittedColor(x, y) {
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return -1;
return committedColors[y * CANVAS_WIDTH + x];
}
export function commitPending() {
for (const { x, y, color } of buffer.getAllPixels()) {
committedColors[y * CANVAS_WIDTH + x] = color;
+318
View File
@@ -0,0 +1,318 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
import { rgbaToPalette, paletteToRgba } from '../../lib/image-to-palette.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, onClose, onCredits } = $props();
// Source image (decoded, palette-mapped)
let fileName = $state(null);
let srcWidth = $state(0);
let srcHeight = $state(0);
/** @type {Uint8ClampedArray|null} */
let srcRgba = $state(null);
/** @type {Int16Array|null} */
let paletteIndices = $state(null);
let opaqueCount = $state(0);
// Preview canvas
let previewEl = $state();
// Target position + options
let originX = $state(0);
let originY = $state(0);
let skipMatching = $state(true);
let dither = $state(false);
// Run state
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
let placed = $state(0);
let total = $state(0);
let statusText = $state('');
let errorText = $state(null);
/** @type {ReturnType<typeof createImageUploader>|null} */
let uploader = null;
async function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
fileName = file.name;
errorText = null;
try {
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
URL.revokeObjectURL(img.src);
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w === 0 || h === 0) throw new Error('Empty image');
const oc = new OffscreenCanvas(w, h);
const octx = oc.getContext('2d');
octx.drawImage(img, 0, 0);
const { data } = octx.getImageData(0, 0, w, h);
srcRgba = data;
srcWidth = w;
srcHeight = h;
// paletteIndices recomputed reactively via $effect below.
} catch (err) {
errorText = `Failed to load image: ${err.message || err}`;
srcRgba = null;
paletteIndices = null;
srcWidth = srcHeight = 0;
}
}
// Re-quantize when source or dither mode changes.
$effect(() => {
if (!srcRgba) { paletteIndices = null; opaqueCount = 0; return; }
const idx = rgbaToPalette(srcRgba, srcWidth, srcHeight, { dither });
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
opaqueCount = count;
renderPreview();
});
function renderPreview() {
if (!previewEl || !paletteIndices) return;
const rgba = paletteToRgba(paletteIndices, srcWidth, srcHeight);
previewEl.width = srcWidth;
previewEl.height = srcHeight;
const ctx = previewEl.getContext('2d');
ctx.putImageData(new ImageData(rgba, srcWidth, srcHeight), 0, 0);
}
$effect(() => { if (open && paletteIndices) renderPreview(); });
function useCursor() {
originX = cursorPos?.x ?? 0;
originY = cursorPos?.y ?? 0;
}
function validatePlacement() {
if (!paletteIndices) return 'No image loaded.';
if (!Number.isInteger(originX) || !Number.isInteger(originY)) return 'X and Y must be integers.';
if (originX < 0 || originY < 0) return 'X and Y must be ≥ 0.';
if (originX + srcWidth > CANVAS_WIDTH || originY + srcHeight > CANVAS_HEIGHT) {
return `Image would overflow canvas (${CANVAS_WIDTH}x${CANVAS_HEIGHT}).`;
}
return null;
}
function buildPixels() {
const pixels = [];
for (let i = 0; i < paletteIndices.length; i++) {
const color = paletteIndices[i];
if (color < 0) continue;
const lx = i % srcWidth;
const ly = (i / srcWidth) | 0;
const x = originX + lx;
const y = originY + ly;
if (skipMatching && getCommittedColor?.(x, y) === color) continue;
pixels.push({ x, y, color });
}
return pixels;
}
async function start() {
const err = validatePlacement();
if (err) { errorText = err; return; }
errorText = null;
const pixels = buildPixels();
if (pixels.length === 0) {
statusText = 'Nothing to place (all pixels already match).';
status = 'done';
placed = 0; total = 0;
return;
}
total = pixels.length;
placed = 0;
status = 'running';
statusText = 'Starting…';
uploader = createImageUploader({
onProgress: (p) => { placed = p; },
onCredits: (c) => onCredits?.(c),
onStatus: (s) => { statusText = s; },
onError: (e) => { errorText = e.message || String(e); },
});
const result = await uploader.run(pixels);
uploader = null;
placed = result.placed;
if (result.error) {
status = 'error';
statusText = `Stopped: ${result.error.message}`;
} else if (result.aborted) {
status = 'idle';
statusText = `Cancelled after ${result.placed}/${result.total}.`;
} else {
status = 'done';
statusText = `Done. Placed ${result.placed} pixels.`;
}
}
function pause() { uploader?.pause(); status = 'paused'; statusText = 'Paused.'; }
function resume() { uploader?.resume(); status = 'running'; statusText = 'Resuming…'; }
function cancel() { uploader?.abort(); }
function reset() {
if (status === 'running' || status === 'paused') return;
placed = 0; total = 0; status = 'idle'; statusText = ''; errorText = null;
}
const pct = $derived(total > 0 ? (placed / total) * 100 : 0);
const etaSec = $derived(
status === 'running' && total > placed
? Math.ceil((total - placed)) // ~1 pixel/sec steady state
: null,
);
</script>
{#if open}
<div class="panel" role="dialog" aria-label="Image importer">
<div class="head">
<strong>Import Image</strong>
<button class="x" onclick={onClose} aria-label="Close">✕</button>
</div>
<div class="row">
<label class="file-btn">
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onchange={handleFile} />
{fileName ? 'Change image…' : 'Choose image…'}
</label>
{#if fileName}<span class="file-name" title={fileName}>{fileName}</span>{/if}
</div>
{#if paletteIndices}
<div class="preview-row">
<div class="preview-wrap">
<canvas bind:this={previewEl} class="preview"></canvas>
</div>
<div class="meta">
<div>{srcWidth} × {srcHeight}</div>
<div>{opaqueCount} / {srcWidth * srcHeight} opaque</div>
</div>
</div>
<div class="row">
<label>X <input type="number" min="0" max={CANVAS_WIDTH - 1} bind:value={originX} /></label>
<label>Y <input type="number" min="0" max={CANVAS_HEIGHT - 1} bind:value={originY} /></label>
<button onclick={useCursor} title="Set X/Y to current cursor position">Use cursor</button>
</div>
<label class="row checkbox">
<input type="checkbox" bind:checked={skipMatching} />
Skip pixels that already match
</label>
<label class="row checkbox" title="Floyd-Steinberg error diffusion. Better for photos & gradients; noisier for flat art.">
<input type="checkbox" bind:checked={dither} />
Dither (Floyd-Steinberg)
</label>
{#if total > 0}
<div class="progress">
<div class="bar"><div class="fill" style="width: {pct}%"></div></div>
<div class="prog-text">
{placed} / {total} ({pct.toFixed(1)}%){etaSec != null ? ` — ~${etaSec}s left` : ''}
</div>
</div>
{/if}
{#if statusText}<div class="status">{statusText}</div>{/if}
{#if errorText}<div class="error">{errorText}</div>{/if}
<div class="controls">
{#if status === 'idle' || status === 'done' || status === 'error'}
<button class="primary" onclick={start} disabled={!paletteIndices}>Start</button>
{#if status !== 'idle'}<button onclick={reset}>Reset</button>{/if}
{:else if status === 'running'}
<button onclick={pause}>Pause</button>
<button class="danger" onclick={cancel}>Cancel</button>
{:else if status === 'paused'}
<button class="primary" onclick={resume}>Resume</button>
<button class="danger" onclick={cancel}>Cancel</button>
{/if}
</div>
{/if}
</div>
{/if}
<style>
.panel {
position: fixed;
top: 64px;
right: 12px;
width: 320px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 80px);
overflow-y: auto;
background: rgba(10, 10, 10, 0.95);
border: 1px solid #3a3a3a;
border-radius: 12px;
padding: 12px;
color: #ddd;
font-size: 0.9rem;
z-index: 20;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
gap: 10px;
}
.head { display: flex; justify-content: space-between; align-items: center; }
.x {
background: transparent; border: 0; color: #aaa; font-size: 1.2rem;
cursor: pointer; padding: 4px 8px;
}
.x:hover { color: #fff; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row.checkbox { gap: 6px; cursor: pointer; }
label { display: flex; align-items: center; gap: 6px; }
input[type="number"] { width: 70px; padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; }
.file-btn {
display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px;
cursor: pointer; color: #fff; font-weight: 500;
}
.file-btn:hover { background: #1d4ed8; }
.file-btn input { display: none; }
.file-name { color: #aaa; font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; max-width: 180px; white-space: nowrap; }
.preview-row { display: flex; gap: 10px; align-items: flex-start; }
.preview-wrap {
width: 120px; height: 120px; background: #0a0a0a; border: 1px solid #333;
border-radius: 6px; display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.preview { image-rendering: pixelated; max-width: 100%; max-height: 100%; }
.meta { font-size: 0.8rem; color: #aaa; display: flex; flex-direction: column; gap: 4px; }
.progress { display: flex; flex-direction: column; gap: 4px; }
.bar { height: 8px; background: #222; border-radius: 4px; overflow: hidden; }
.fill { height: 100%; background: #2563eb; transition: width 0.2s; }
.prog-text { font-size: 0.8rem; color: #bbb; }
.status { font-size: 0.85rem; color: #9cf; }
.error { font-size: 0.85rem; color: #f88; background: rgba(139, 34, 34, 0.3); padding: 6px 8px; border-radius: 4px; }
.controls { display: flex; gap: 6px; flex-wrap: wrap; }
.controls button {
padding: 6px 14px; background: #333; color: #ddd;
border: 1px solid #555; border-radius: 6px; cursor: pointer;
}
.controls button:hover { background: #444; }
.controls button.primary { background: #2563eb; border-color: #3b82f6; color: #fff; }
.controls button.primary:hover { background: #1d4ed8; }
.controls button.danger { background: #8b2222; border-color: #a33; color: #fff; }
.controls button.danger:hover { background: #a33; }
.controls button:disabled { opacity: 0.4; cursor: default; }
</style>
+113
View File
@@ -0,0 +1,113 @@
import { MAX_BATCH_SIZE, MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Browser-side throttled uploader for batches of pixels.
* Tracks credits locally (regen at CREDIT_REGEN_RATE/sec, capped at MAX_CREDITS),
* backs off on 429, and yields control between batches so the UI stays responsive.
*
* Lifecycle: create → await run(pixels) → resolves when finished or aborted.
* Call pause()/resume()/abort() from UI to steer.
*
* @param {Object} hooks
* @param {(placed: number, total: number) => void} [hooks.onProgress]
* @param {(credits: number) => void} [hooks.onCredits]
* @param {(message: string) => void} [hooks.onStatus] - human-readable status line
* @param {(error: Error) => void} [hooks.onError] - recoverable errors (uploader retries)
*/
export function createImageUploader({ onProgress, onCredits, onStatus, onError } = {}) {
let aborted = false;
let paused = false;
async function waitWhilePaused() {
while (paused && !aborted) await sleep(150);
}
async function run(pixels) {
aborted = false;
paused = false;
let credits = MAX_CREDITS; // optimistic; server corrects on first response
let lastUpdate = Date.now();
let placed = 0;
const total = pixels.length;
while (placed < total && !aborted) {
await waitWhilePaused();
if (aborted) break;
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
// Refresh local credit estimate with elapsed regen.
const now = Date.now();
credits = Math.min(MAX_CREDITS, credits + ((now - lastUpdate) / 1000) * CREDIT_REGEN_RATE);
lastUpdate = now;
if (credits < batch.length) {
const waitSec = Math.ceil((batch.length - credits) / CREDIT_REGEN_RATE);
onStatus?.(`Waiting ${waitSec}s for credits…`);
// Split sleep so pause/abort feel responsive.
const end = Date.now() + waitSec * 1000;
while (Date.now() < end && !aborted && !paused) {
await sleep(Math.min(250, end - Date.now()));
}
continue; // re-check regen & pause on next loop
}
onStatus?.(`Sending ${batch.length} pixels…`);
let res;
try {
res = await fetch('/api/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pixels: batch }),
});
} catch (err) {
onError?.(err);
onStatus?.(`Network error, retrying in 3s…`);
await sleep(3000);
continue;
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const wait = Math.max(1, body.retryAfter ?? 2);
if (typeof body.remaining === 'number') onCredits?.(body.remaining);
credits = 0;
lastUpdate = Date.now();
onStatus?.(`Rate limited, waiting ${wait}s…`);
await sleep(wait * 1000);
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => '');
const err = new Error(`HTTP ${res.status}: ${text}`);
onError?.(err);
// Non-recoverable: stop here so user sees the error.
return { placed, total, aborted: false, error: err };
}
const body = await res.json().catch(() => ({}));
if (typeof body.credits === 'number') {
credits = body.credits;
onCredits?.(body.credits);
lastUpdate = Date.now();
}
placed += batch.length;
onProgress?.(placed, total);
}
return { placed, total, aborted };
}
return {
run,
pause() { paused = true; },
resume() { paused = false; },
abort() { aborted = true; paused = false; },
get isPaused() { return paused; },
get isAborted() { return aborted; },
};
}