From 4a149c38eb295c1965ec15f6d8238a3e5d8a36ee Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 17 Apr 2026 11:25:16 +0700 Subject: [PATCH] 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. --- src/client/App.svelte | 32 ++ src/client/components/CanvasRenderer.svelte | 5 + src/client/components/ImageImporter.svelte | 318 ++++++++++++++++++++ src/lib/image-uploader.js | 113 +++++++ 4 files changed, 468 insertions(+) create mode 100644 src/client/components/ImageImporter.svelte create mode 100644 src/lib/image-uploader.js diff --git a/src/client/App.svelte b/src/client/App.svelte index 66d220b..160e05e 100644 --- a/src/client/App.svelte +++ b/src/client/App.svelte @@ -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 @@ selectedColor = i} /> + + + canvasRenderer?.getCommittedColor(x, y) ?? -1} + onClose={() => importerOpen = false} + onCredits={(c) => credits = c} + /> + {#if toast}
{toast.text}
{/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; } diff --git a/src/client/components/CanvasRenderer.svelte b/src/client/components/CanvasRenderer.svelte index 28d7eeb..5c83b52 100644 --- a/src/client/components/CanvasRenderer.svelte +++ b/src/client/components/CanvasRenderer.svelte @@ -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; diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte new file mode 100644 index 0000000..64fa654 --- /dev/null +++ b/src/client/components/ImageImporter.svelte @@ -0,0 +1,318 @@ + + +{#if open} + +{/if} + + diff --git a/src/lib/image-uploader.js b/src/lib/image-uploader.js new file mode 100644 index 0000000..eb9cbdf --- /dev/null +++ b/src/lib/image-uploader.js @@ -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; }, + }; +}