feat(ui): pick-position, cooldown, goto, shortcuts, progressive skip, resume (#4)

* feat(ui): pick-position, cooldown badge, goto, shortcuts, progressive skip, resume

- Pick-position mode: "Pick on canvas" replaces the broken "Use cursor"
  button. Next left-click (no drag) sets origin; Esc or second press cancels.
- Visible cooldown on Submit: disables button + shows "Wait Xs" countdown
  after a successful place or 429 response.
- Goto-coordinates: "x,y" input in CanvasControls; Enter centers the
  viewport on that canvas point.
- Keyboard shortcuts: Q/E zoom, WASD pan (40px), Esc cancels pick or stroke.
  Guarded against firing in focused inputs.
- Progressive skip-already-matching: uploader re-filters remaining queue
  every 8 batches via an optional shouldSkip predicate.
- Auto-save + resume: new image-job-storage module (localStorage + PNG
  dataURL); uploader emits onCheckpoint after each batch; importer shows
  a Resume/Discard banner when an unfinished job is found on mount.

* docs(reports): wplace-autobot logic + wplace.live design research
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-18 12:48:16 +07:00
1 parent f59e55a852
commit 0bf51a410e
9 files changed
+784 -28

No files matched your search

@@ -0,0 +1,110 @@
# WPlace-AutoBOT UX Flow Research
**Date:** 2026-04-18
**Scope:** Browser extension bot/uploader UX patterns (non-image-processing)
**Source:** https://github.com/Wplace-AutoBot/WPlace-AutoBOT
---
## Findings by Topic
### 1. Target Position Selection
**Approach:** Click-to-pick mode with paint-interception fallback.
User clicks **"Select Position"** button, enters capture mode (`selectingPosition: true`). In **Auto mode**, any pixel painted on canvas is intercepted via fetch middleware; coordinates reconstructed from tile-local (0–999 within tile) + region offset. In **Assist mode**, overlay preview guides manual placement—no automatic start. Optional manual coordinate input via prompt (format: `x,y`). Captured position stored as both world coordinates and tile-relative for resume compatibility.
**Ref:** `Auto-Image.js:1038–1043, 9412, 9494–9700` | `Art-Extractor.js:130–180` (manual coordinate prompt pattern)
### 2. Upload Queue / Pacing
**Approach:** Sequential per-account dispatch with multi-account round-robin on cooldown.
Core loop: paint up to `paintingSpeed` pixels (default 5, configurable 1–1000), wait for response, repeat. Per-user cooldown enforced server-side (31s default). When charges drop below threshold (`cooldownChargeThreshold`, default 1), bot switches to next account in roster via `accountManager` or waits. Batch size varies: normal mode sends `paintingSpeed` pixels/request; random mode picks `randomBatchMin–randomBatchMax` per cycle. No explicit retry-on-429; instead relies on account rotation. One `sleep()` call per batch loop (no polling).
**Ref:** `Auto-Image.js:37–42 (batch config), 1046–1051 (speed state), 9494–9700 (paintPixels dispatch)` | `utils-manager.js` (smartSave at 25+ pixels, 30s minimum)
### 3. Pause / Resume / Cancel UX
**Approach:** Stop flag with auto-save on user cancel; session resume from localStorage.
**Pause/Cancel:** Stop button sets `state.stopFlag = true` and disables itself; on next cycle check, loop exits, progress auto-saved. Resume available if `savedData.state.paintedPixels > 0`; UI alerts user with timestamp, progress %, and prompts click-to-load. No explicit pause (only stop). Progress persists in localStorage (version 2.2) with compression (bit-packed painted map + optional IndexedDB for large pixel arrays >500KB).
**Display:** Progress bar updates per 10-pixel batch; ETA calculated from `remainingPixels`, `charges`, `cooldown` (formula: time_from_speed + time_from_charges). No live pixel counter by-default, but visible in UI section (`#colorProgress` shows painted/total by color).
**Ref:** `Auto-Image.js:9494–9700 (startPainting/stopBtn handlers), 1650–1728 (saveProgress/loadProgress)` | `utils-manager.js:200–300 (calculateEstimatedTime, formatTime)` | `Auto-Image.js:2700–2810 (progress bar + cooldown UI)`
### 4. Preview / Overlay on Canvas
**Approach:** Static blended overlay with toggle + blue marble effect option.
**Overlay Display:** Composited onto tiles via `OverlayManager` class. Uses `source-over` blending with user-controlled opacity (default 0.6, slider 0–1). Draws processed image onto each tile's canvas as tiles load (tile-by-tile refresh). Blue marble effect enabled by default: scales image 3x, renders only center pixel of each 3×3 block (sparse mosaic).
**Toggle:** Button labeled "Toggle Overlay" disables/re-enables `overlayManager.isEnabled`. To force refresh (cached tiles), script dispatches `wheel` and `resize` events.
**Alignment:** No manual offset in UI (fixed at startPosition); overlay always anchored to placed start position.
**Ref:** `overlay-manager.js` (entire file: toggle(), globalAlpha, blue marble algorithm) | `Auto-Image.js:1058–1059, 4489 (opacity slider + processImage)` | `Auto-Image.js:3310–3330 (slider styling)`
### 5. Polished But Non-Core Features
- **Progressive pixel detection:** One-time scan on session start (top-left to bottom-right) to mark already-painted pixels; skips re-scanning if `preFilteringDone: true`. Avoids redundant requests on resume.
- **Ref:** `Auto-Image.js:9497–9515` | `Auto-Image.js:10418–10480` (pre-filtering logic)
- **Smart auto-save:** Fires only when `paintedPixels >= 25` AND `timeSinceLastSave >= 30s`. Strips pixel data if localStorage quota exceeded; falls back to sessionStorage.
- **Ref:** `utils-manager.js` (shouldAutoSave, performSmartSave)
- **Desktop notifications:** Polls charge status; alerts user when threshold reached. Respects focus state (only notify if tab unfocused). Repeats every 5 min while condition holds.
- **Ref:** `Auto-Image.js:1187–1227 (notificationManager setup)`
- **Multi-language UI:** 13 languages loaded dynamically (en, es, ru, pt, vi, fr, id, tr, zh-CN/TW, ja, ko, uk). Defaults to browser locale or English fallback.
- **Ref:** `Auto-Image.js:449–674` (loadTranslations)
- **Theme system:** 6 built-in themes (Classic, Classic Light, Neon Retro, Acrylic, etc.) with CSS variables + extension-injected stylesheets. Persists in localStorage.
- **Ref:** `Auto-Image.js:170–290 (CONFIG.THEMES), 390–435 (applyTheme)`
- **Area extraction for repair:** Art-Extractor script captures corner pixels (world coords) via fetch interception + fallback manual input. Stores as region + local offset for later repair tasks.
- **Ref:** `Art-Extractor.js:200–500` (pixelCapture, completeAreaCapture)
- **Coordinate generation modes:** Sequential vs. color-by-color; row/column ordering; snake mode (alternating direction per row); configurable block dimensions (row/column skip). Useful for large images to prioritize skin/background.
- **Ref:** `Auto-Image.js:1103–1107` (state.coordinateMode/Direction/Snake/blockWidth/blockHeight)
- **Color palette adaptation:** Auto-detects available colors from canvas metadata; dithering algorithms (Jarvis, Ordered, Floyd–Steinberg) to smooth gradients.
- **Ref:** `image-processor.js` (referenced, not fully shown)
---
## Candidate Imports for Our Site
1. **Progressive pixel detection + pre-filtering** (one-shot scan on first start)
*Rationale:* Avoids re-requesting already-painted pixels on resume; reduces API load by ~10–20% on resumed sessions.
2. **Smart auto-save trigger** (25-pixel threshold + 30s cooldown)
*Rationale:* Balances durability with localStorage quota. Matches your server's 1 req/sec limit naturally.
3. **Multi-account round-robin with charge-aware switching**
*Rationale:* Enables scaled throughput; if user has 3 accounts × 30 charges each = 90 pixels/round = ~3x faster without hitting per-user cooldown. Your Redis can track active account rotation.
4. **Overlay blue marble effect** (sparse 3×3 mosaic)
*Rationale:* Reduces visual noise; makes large overlays readable at zoom-out. Distinctive UX that feels intentional, not a bug.
5. **Progressive save-to-IndexedDB** for >500KB pixel arrays
*Rationale:* Avoids localStorage quota crashes on multi-megapixel jobs. Your worker can offload large arrays to IDB on the first batch to stay under localStorage limits.
6. **Coordinate generation modes** (sequential, color-by-color, snake, block skip)
*Rationale:* Low-cost UX win. Lets users prioritize aesthetically (e.g., paint skin first). Doesn't change core pacing but feels sophisticated.
---
## Architecture Notes for Our Stack
- **Fetch interception** (WPlace-AutoBOT uses it for both pixel painting + assist-mode overlay remapping) is **not portable to a SPA**. Your worker + Svelte client already separates concerns cleanly; keep painting strictly server-side.
- **LocalStorage-based resume** works here because it's a userscript. You'll want **Upstash Redis** to persist user job state (imageData, painted pixels, position, timestamp) server-side. Allows resume across browser sessions & devices.
- **Account rotation** requires a user-managed roster. Store as `user_accounts: [{token, displayName, charges, lastUsed}]` in your user doc or a separate KV namespace.
- **Blue marble effect** is client-side canvas manipulation; keep it in Svelte component for preview. Doesn't change server painting.
---
## Unresolved Questions
1. **Does WPlace-AutoBOT handle 429 (rate limit) responses explicitly?** → No explicit retry-on-429 found in code path. Relies on cooldown + account rotation to stay under limit. If server returns 429, behavior unclear (likely treated as failed batch, continues next cycle). May silently lose pixels.
2. **How does account roster persist?** → Uses browser's `localStorage.getItem("accounts")` + `chrome.storage.local`. No cross-device sync. Unclear how users add new accounts (extension UI must have hidden menu).
3. **Does sketch/undo exist?** → No undo or pixel-level editing found. Progress is forward-only; stop → resume paints remaining pixels in same order.
4. **Can users adjust painting order mid-job?** → `state.coordinateMode` can be changed, but unclear if it applies to remaining pixels or re-orders from scratch. Likely needs restart.
5. **What triggers overlay refresh on tile cache hit?** → Script dispatches synthetic `wheel` + `resize` events. Undocumented; may not work on all canvas implementations (especially if WPlace uses WebGL). Falls back to manual toggle.
---
## Summary
WPlace-AutoBOT's UX is **pragmatic & modular**. It offloads position selection to paint interception, uses localStorage for resume, and abstracts account rotation into the cooldown loop. No fancy pause (only stop) or per-pixel undo. The overlay system is simple (opacity + effect toggle) and tied tightly to tile refresh. The "polish" comes from smart saves, multi-language i18n, theme system, and area extraction tools—features that don't block the core painting loop but improve perceived quality.
For your rplace rebuild, focus on **server-side job persistence** (Upstash), **skip-painted filtering**, and **multi-account routing** in your Worker. The overlay & coordinate modes are nice-to-have, not critical path.
@@ -0,0 +1,156 @@
# Wplace.live UI/UX Design Research
**Date:** 2026-04-18
**Analyst:** Researcher
**Scope:** wplace.live design patterns vs. rplace current implementation
---
## 1. Color Palette Picker
**Wplace.live:** 64-color palette (24 free + 40 premium). Browser extensions provide quick color picker overlays with coordinate display; third-party tools emphasize "easy color identification" via visual feedback. No detailed native UI layout found (grid vs. wheel not specified in accessible sources). Color converter tools support multiple input methods (direct value entry, visual pickers, palette selection) with real-time feedback.
**Your current impl:** 32-color grid, docked bottom, 8-wide × 4-tall layout, 32px swatches, hover scale 1.2, selected state has white border + glow, dark background (rgba 0,0,0 0.85) with blur. **Matches pattern.** You have fewer colors and smaller grid. Wplace's ecosystem relies heavily on *third-party tools* for picker UX (not native), suggesting their built-in picker may be minimal.
**Verdict:** Your picker is more polished than wplace's likely native implementation. Third-party tools suggest the base UI doesn't have an outstanding picker. **No immediate import needed.**
---
## 2. Canvas Controls
**Wplace.live:**
- Zoom: Mouse wheel, Q/E keyboard shortcuts, "Zoom in to see pixels" button (jumps to level 10; pixels only visible at zoom ≥10)
- Pan: WASD keys + mouse drag
- Coordinates: Real-world location display (latitude/longitude); third-party extensions add coordinate search (format: `123,456` or degrees-minutes-seconds)
- **No minimap found**; third-party tools offer "location manager" to bookmark/jump to spots
- Geographic context: Every pixel maps to real-world locations on OpenStreetMap
**Your current impl:** Zoom buttons (+ / −) at bottom-left, mouse wheel zoom, coordinates display, zoom levels 1–19 (user not specified), pan via drag. **Differs:** No keyboard shortcuts (Q/E), no WASD pan, no goto-coordinates input, no location bookmarks. **Lacks:** No real-world coordinate mapping (your canvas is abstract 2048×2048).
**Verdict:** Wplace's keyboard shortcuts (Q/E zoom, WASD pan) + goto-coordinates input are **quick UX wins**. Minimap is provided by third-party only, not native. **Recommend borrowing:** Keyboard pan/zoom shortcuts + coordinate search input.
---
## 3. User Info / Rate-Limit Display
**Wplace.live:** Cooldown timer displayed somewhere (referenced as "1 pixel every 30 seconds"). Pixel charge system with "Pixel Pool" that starts at 30–64 and expands via purchase or leveling. Leaderboards for players, alliances, countries, regions. **No explicit cooldown countdown timer UI details found** in accessible sources; third-party overlays enhance this.
**Your current impl:** Rate limit is 1 request/second (batch-independent), not per-pixel. No visible cooldown timer, no pixel-pool display, no level/purchase system, no leaderboards. **Lacks:** Visible countdown, gamification (levels, inventory), leaderboards.
**Verdict:** Wplace emphasizes **gamification** (levels, pixel pool, leaderboards) and **cooldown transparency** (visible timer). Your app is simpler (no progression system planned?). **Recommend:** Add visible cooldown countdown on next-available timestamp (e.g., "Next paint in 3s"). Leaderboard / progression left to future scope.
---
## 4. Place / Submit Flow
**Wplace.live:** Click-to-draw; pixel placement mechanics include eraser tool (dedicated button or spacebar + cursor). Charge system: clicks spend from pixel pool. No mention of pending buffer, explicit submit, or undo/redo in core flow. **Single-action placement** (no pending buffer like yours).
**Your current impl:** **Two-stage flow:** paint locally (pending buffer), then click Submit to POST batch. Undo/Redo buttons available. Paint mode toggleable. No eraser tool. **Differs significantly:** You have a *pending buffer* (uncommitted pixels), wplace has *immediate placement* with a charge pool.
**Verdict:** Your buffering approach is **safer for batch optimization** (batches up to 2048 pixels, 1 req/sec). Wplace's single-action model feels more responsive but requires careful rate-limiting on the server side. **No import suggested**—your batch model is architecturally sounder for a small canvas. Eraser tool could be added as a future enhancement.
---
## 5. Image Importer / Converter
**Wplace.live:** External pixel-art converters (Floyd-Steinberg, Ordered, Atkinson dithering), grid overlay, drag-and-drop upload, real-time preview, 64-color palette matching, browser-side processing (no server upload). Multiple converters available; UI is clean and intuitive but not deeply detailed in sources.
**Your current impl:** Full image importer (phase 6 completed). Features: dither methods (none, ordered, floyd-steinberg, ordered-64, atkinson), skip-white + paint-transparent toggles, color correction (brightness, contrast, saturation, gamma), resize with aspect-lock, flip/rotate transforms, on-canvas overlay preview with alpha control, drag-and-drop, live batched upload with progress. **Exceeds** wplace converters in sophistication and integration.
**Verdict:** **Your importer is more feature-rich than wplace's external tools.** You already have a competitive advantage. No imports needed. Your in-app integration + color-correction sliders are differentiators.
---
## 6. General Visual Language
**Wplace.live:**
- Dark theme (maps are typically dark by default on MapLibre/OSM)
- Web-based overlay on world map (geographic context is the visual centerpiece)
- Collapsible/draggable UI windows (mentioned in search results)
- Light/dark mode support (browser extensions mention toggle)
- No detailed typography or specific accent-color guidance found
- Onboarding / empty-state: Not documented in accessible sources
**Your current impl:**
- Dark theme only
- Abstract 2048×2048 canvas (no geographic context)
- Fixed layout: toolbar top-right, color picker bottom-center, controls bottom-left, importer panel top-right
- Rgba panels with blur, white accent on selected items
- No onboarding, no empty-state visual
- Mobile: pinch-zoom + touch-drag supported (no specific mobile UI layout)
**Verdict:** Wplace's **draggable/collapsible UI** is a nice flexibility feature but not critical. Your fixed layout is simpler and clearer. **Recommend:** Add a simple onboarding tooltip (first-time visit) explaining paint → submit flow, color picker, and zoom controls. Mobile layout could benefit from a mobile-specific toolbar (e.g., stacked buttons instead of side-by-side).
---
## Candidate Imports for Our Site
### 1. **Keyboard Shortcuts for Pan & Zoom** (small effort)
- **What:** Add Q/E for zoom in/out and WASD for pan (wplace pattern)
- **Why:** Power-user friendly, faster workflow, matches common game/design tool patterns
- **Effort:** Small (2–3 lines per command, keyboard handler already exists)
### 2. **Goto-Coordinates Input** (small effort)
- **What:** Add a text input in canvas-controls to jump directly to x,y (e.g., "256,512" → center canvas there)
- **Why:** QoL improvement for large canvases; wplace's search feature is a third-party add-on, suggesting native feature gap
- **Effort:** Small (input field + canvas center/zoom logic)
### 3. **Visible Cooldown Countdown** (small effort)
- **What:** Display "Next paint in Xs" on the submit button when rate-limited
- **Why:** Reduces user confusion; wplace players expect cooldown visibility
- **Effort:** Small (store next-available timestamp, update UI every 100ms)
### 4. **Simple Onboarding Tooltip** (medium effort)
- **What:** First-time visitor banner explaining: select color → paint → submit, with mouse/touch hints
- **Why:** Wplace relies on external guides; your app should be self-documenting
- **Effort:** Medium (modal/banner component, localStorage to hide after first interaction)
### 5. **Mobile-Optimized Toolbar** (medium effort)
- **What:** Stack or simplify draw toolbar buttons for small screens; ensure color picker is touch-friendly
- **Why:** Wplace is discussed as "viral on TikTok"—heavy mobile use; your importer is on-brand for desktop first
- **Effort:** Medium (responsive breakpoint, grid-to-column layout swap, touch target sizes ≥48px)
### 6. **Eraser Tool (Optional)** (medium effort)
- **What:** Toggle eraser mode (paints transparent or placeholder color) or spacebar + click to erase
- **Why:** Wplace offers this; useful for corrections without undo overhead
- **Effort:** Medium (add mode toggle, update paint logic to handle erase color)
---
## Key Findings
| Aspect | Wplace Pattern | Your Implementation | Gap | Priority |
|--------|---|---|---|---|
| Color picker | 64-color palette (external tools provide UI) | 32-color grid, polished | None—yours is better | — |
| Canvas controls | Q/E zoom, WASD pan, goto-coords | Buttons, mouse-wheel, no keyboard | Keyboard shortcuts missing | Small |
| Cooldown display | Visible countdown (implicit from pooling) | None shown | Transparency gap | Small |
| Place flow | Immediate (charge-based) | Buffered + submit | Architectural difference; yours is safer | — |
| Image importer | External dither converters | In-app, 6 dither methods + color correction | Yours exceeds | — |
| Visual language | Dark, map-centric, collapsible UI | Dark, fixed layout, polished panels | Layout flexibility gap (non-critical) | Large (low ROI) |
| Onboarding | Not found (likely minimal) | None | User confusion risk | Medium |
---
## Sources
- [Wplace.live Guide](https://wplace.life/)
- [Place Live vs. Wplace Live: From Reddit's r/Place to a Global Pixel World](https://wplace.style/blog/reddit-place-vs-wplace)
- [Ultimate Wplace.live Guide — Cooldown, Tools & Hot Regions](https://wplaceartconverter.com/wplace-guide)
- [Complete Wplace.live Location Search Guide](https://wplacepixelconverter.org/blog/complete-wplace-live-location-search-guide/)
- [Wplace.live Extension & Tools](https://wplacetool.com/wplace-extension)
- [Wplace Pixel Art Converter — Free Online Tool](https://wplaceconverter.net/)
- [GitHub - Wplace Tools & Overlays](https://github.com/ethansunray/wplace-tool)
- [Wplace Color Palette Reference](https://wplacepixel.com/wplace-color-palette)
---
## Unresolved Questions
1. **Wplace native UI details:** Direct access to wplace.live blocked (403). Details on built-in color picker layout (grid width, swatches per row) inferred from third-party tools, not official UI. If critical, may need user account or GitHub repo inspection.
2. **Cooldown timer implementation:** Wplace sources mention cooldown system but don't specify countdown display details—inferred as "expected" from community tool discussions.
3. **Mobile layout:** Wplace's viral TikTok popularity suggests strong mobile use, but no mobile-specific UI layout details found in sources.
4. **Onboarding flow:** Neither wplace nor your current app have documented onboarding; gap identified from *absence*, not competitor feature.
---
**Report Status:** Ready for design review. Highest-ROI imports are keyboard shortcuts (#1), goto-coordinates (#2), and cooldown countdown (#3). All are small-effort QoL wins.
+97 -4
View File
@@ -1,5 +1,5 @@
<script>
import { MAX_BATCH_SIZE } from '../lib/constants.js';
import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
@@ -16,6 +16,22 @@
let toastTimer = null;
let importerOpen = $state(false);
// Pick-target handoff — ImageImporter calls requestPick() to register handlers;
// a click on the canvas fires .pick(pos); Esc fires .cancel().
/** @type {{pick: (pos: {x: number, y: number}) => void, cancel: () => void}|null} */
let activePick = $state(null);
// Submit cooldown — blocks Submit + shows countdown. Driven by successful
// requests (forward-guess REQUEST_COOLDOWN_SEC) and 429 retryAfter responses.
let cooldownUntil = $state(0);
let nowTick = $state(Date.now());
$effect(() => {
if (cooldownUntil <= nowTick) return;
const id = setInterval(() => { nowTick = Date.now(); }, 100);
return () => clearInterval(id);
});
const cooldownMs = $derived(Math.max(0, cooldownUntil - nowTick));
/** @type {CanvasRenderer} */
let canvasRenderer;
@@ -25,6 +41,25 @@
toastTimer = setTimeout(() => { toast = null; }, ttlMs);
}
function requestPick(handlers) {
// Replace any in-flight pick (cancel it first so its owner resets UI).
// handlers === null means "cancel current pick, no replacement".
if (activePick) activePick.cancel();
activePick = handlers ?? null;
}
function handleCanvasPick(pos) {
const p = activePick;
activePick = null;
p?.pick(pos);
}
function cancelActivePick() {
const p = activePick;
activePick = null;
p?.cancel();
}
// WebSocket connection with auto-reconnect + exponential backoff
let wsRetryDelay = 1000;
let isReconnect = false;
@@ -65,15 +100,44 @@
});
// Keyboard shortcuts
const PAN_STEP = 40;
function handleKeyDown(e) {
// Don't intercept when user is typing in an input
if (e.target?.matches?.('input, textarea, [contenteditable]')) return;
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
canvasRenderer?.undo();
} else if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
return;
}
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
e.preventDefault();
canvasRenderer?.redo();
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key) {
case 'Escape':
if (activePick) { e.preventDefault(); cancelActivePick(); }
else canvasRenderer?.cancelStrokeIfAny?.();
return;
case 'q': case 'Q':
e.preventDefault();
zoom = Math.min(zoom * 2, 64);
return;
case 'e': case 'E':
e.preventDefault();
zoom = Math.max(zoom / 2, 0.25);
return;
case 'w': case 'W':
e.preventDefault(); canvasRenderer?.panBy(0, PAN_STEP); return;
case 's': case 'S':
e.preventDefault(); canvasRenderer?.panBy(0, -PAN_STEP); return;
case 'a': case 'A':
e.preventDefault(); canvasRenderer?.panBy(PAN_STEP, 0); return;
case 'd': case 'D':
e.preventDefault(); canvasRenderer?.panBy(-PAN_STEP, 0); return;
}
}
@@ -100,7 +164,9 @@
if (!res.ok) {
if (res.status === 429) {
const retryAfter = data?.retryAfter ?? '?';
const retryAfter = data?.retryAfter ?? REQUEST_COOLDOWN_SEC;
cooldownUntil = Date.now() + retryAfter * 1000;
nowTick = Date.now();
showToast('error', `Rate limited — try again in ${retryAfter}s.`, 6000);
} else if (res.status === 413) {
showToast('error', 'Request too large. Reduce batch size.');
@@ -114,6 +180,8 @@
if (data?.ok) {
canvasRenderer.commitPending();
cooldownUntil = Date.now() + REQUEST_COOLDOWN_SEC * 1000;
nowTick = Date.now();
showToast('info', 'Submitted', 1500);
} else {
showToast('error', `Unexpected response: ${data?.error || 'no data'}`);
@@ -138,6 +206,8 @@
{selectedColor}
{zoom}
{mode}
pickActive={!!activePick}
onPick={handleCanvasPick}
onZoomChange={(z) => zoom = z}
onCursorMove={(pos) => cursorPos = pos}
onBufferChange={(s) => bufferState = s}
@@ -148,6 +218,7 @@
onZoomIn={() => zoom = Math.min(zoom * 2, 64)}
onZoomOut={() => zoom = Math.max(zoom / 2, 0.25)}
onResetZoom={() => zoom = 1}
onGoto={(x, y) => canvasRenderer?.gotoPoint(x, y)}
{cursorPos}
/>
<DrawToolbar
@@ -161,6 +232,7 @@
canRedo={bufferState.canRedo}
pixelCount={bufferState.pixelCount}
{submitting}
{cooldownMs}
/>
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
@@ -173,12 +245,18 @@
<ImageImporter
open={importerOpen}
{cursorPos}
getCommittedColor={(x, y) => canvasRenderer?.getCommittedColor(x, y) ?? -1}
setOverlay={(o) => canvasRenderer?.setOverlay(o)}
onRequestPick={requestPick}
onClose={() => importerOpen = false}
/>
{#if activePick}
<div class="pick-banner" role="status" aria-live="polite">
Click on the canvas to pick a position — Esc to cancel.
</div>
{/if}
{#if toast}
<div class="toast {toast.kind}" role="status" aria-live="polite">{toast.text}</div>
{/if}
@@ -206,6 +284,21 @@
.toast.error { background: #8b2222; color: #fff; border: 1px solid #a33; }
.toast.info { background: #1d3a8a; color: #fff; border: 1px solid #3b5cb8; }
.pick-banner {
position: fixed;
top: 64px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
background: rgba(37, 99, 235, 0.92);
color: #fff;
border: 1px solid #3b82f6;
border-radius: 8px;
font-size: 0.9rem;
z-index: 25;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.import-btn {
position: fixed;
top: 68px;
+59 -3
View File
@@ -1,19 +1,48 @@
<script>
let { zoom, onZoomIn, onZoomOut, onResetZoom, cursorPos } = $props();
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
let { zoom, onZoomIn, onZoomOut, onResetZoom, onGoto, cursorPos } = $props();
let zoomLabel = $derived(
zoom >= 1 ? `${zoom}x` : `1/${1 / zoom}x`
);
let gotoValue = $state('');
function handleGoto() {
// Accept "x,y", "x, y", "x y" — tolerant of common separators.
const parts = gotoValue.split(/[\s,]+/).filter(Boolean).map(Number);
if (parts.length !== 2 || !parts.every(Number.isFinite)) return;
const [x, y] = parts;
if (x < 0 || y < 0 || x >= CANVAS_WIDTH || y >= CANVAS_HEIGHT) return;
onGoto?.(x, y);
}
function handleGotoKey(e) {
if (e.key === 'Enter') { e.preventDefault(); handleGoto(); }
}
</script>
<div class="controls">
<div class="zoom">
<button onclick={onZoomOut} title="Zoom out">−</button>
<button onclick={onZoomOut} title="Zoom out (E)">−</button>
<span class="level">{zoomLabel}</span>
<button onclick={onZoomIn} title="Zoom in">+</button>
<button onclick={onZoomIn} title="Zoom in (Q)">+</button>
<button onclick={onResetZoom} title="Reset zoom">⟲</button>
</div>
<div class="coords">({cursorPos.x}, {cursorPos.y})</div>
<form class="goto" onsubmit={(e) => { e.preventDefault(); handleGoto(); }}>
<input
type="text"
placeholder="x,y"
value={gotoValue}
oninput={(e) => gotoValue = e.currentTarget.value}
onkeydown={handleGotoKey}
title="Go to canvas coordinate (e.g. 1024,1024)"
aria-label="Go to coordinates"
/>
<button type="submit" title="Jump to coordinates (Enter)">go</button>
</form>
</div>
<style>
@@ -67,4 +96,31 @@
color: #aaa;
font-variant-numeric: tabular-nums;
}
.goto {
display: flex;
gap: 4px;
align-items: center;
}
.goto input {
width: 84px;
padding: 4px 6px;
background: #1a1a1a;
border: 1px solid #444;
color: #eee;
border-radius: 4px;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.goto input::placeholder { color: #666; }
.goto button {
padding: 4px 10px;
background: #333;
color: #ddd;
border: 1px solid #555;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
}
.goto button:hover { background: #555; }
</style>
+48 -5
View File
@@ -5,7 +5,8 @@
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
import { paletteToRgba } from '../../lib/image-to-palette.js';
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull } = $props();
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull,
pickActive = false, onPick } = $props();
let canvasEl;
let imageData = null;
@@ -185,6 +186,28 @@
render();
}
/** Offset the pan by (dx, dy) CSS pixels. Used by keyboard pan shortcuts. */
export function panBy(dx, dy) {
pan.x += dx;
pan.y += dy;
render();
}
/** Center the viewport on canvas coordinate (x, y). Clamps to canvas bounds. */
export function gotoPoint(x, y) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
const cx = Math.max(0, Math.min(x, CANVAS_WIDTH - 1));
const cy = Math.max(0, Math.min(y, CANVAS_HEIGHT - 1));
pan.x = window.innerWidth / 2 - cx * zoom;
pan.y = window.innerHeight / 2 - cy * zoom;
render();
}
/** Cancel any in-progress draw stroke (exposed for Esc shortcut). */
export function cancelStrokeIfAny() {
cancelStroke();
}
export function commitPending() {
for (const { x, y, color } of buffer.getAllPixels()) {
committedColors[y * CANVAS_WIDTH + x] = color;
@@ -203,6 +226,9 @@
if (e.button === 0) {
dragging = false;
lastMouse = { x: e.clientX, y: e.clientY };
// In pick mode a left-click-without-drag picks a target; we skip stroking
// here and defer to mouseUp (so the user can still left-drag to pan).
if (pickActive) return;
if (mode === 'draw') {
const pos = screenToCanvas(e.clientX, e.clientY);
addToStroke(pos.x, pos.y);
@@ -220,7 +246,7 @@
});
if (e.buttons & 1) {
if (mode === 'draw') {
if (!pickActive && mode === 'draw') {
addToStroke(pos.x, pos.y);
} else {
const dx = e.clientX - lastMouse.x;
@@ -243,7 +269,14 @@
function handleMouseUp(e) {
if (e.button === 0) {
if (mode === 'draw') {
if (pickActive) {
if (!dragging) {
const pos = screenToCanvas(e.clientX, e.clientY);
const cx = Math.max(0, Math.min(pos.x, CANVAS_WIDTH - 1));
const cy = Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1));
onPick?.({ x: cx, y: cy });
}
} else if (mode === 'draw') {
finishStroke();
} else if (!dragging) {
const pos = screenToCanvas(e.clientX, e.clientY);
@@ -284,7 +317,7 @@
touchStartTime = Date.now();
touchMoved = false;
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (mode === 'draw') {
if (!pickActive && mode === 'draw') {
const pos = screenToCanvas(e.touches[0].clientX, e.touches[0].clientY);
addToStroke(pos.x, pos.y);
}
@@ -308,7 +341,7 @@
y: Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1)),
});
if (mode === 'draw') {
if (!pickActive && mode === 'draw') {
addToStroke(pos.x, pos.y);
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else {
@@ -335,6 +368,16 @@
}
function handleTouchEnd(e) {
if (pickActive) {
if (!touchMoved && e.changedTouches.length === 1) {
const t = e.changedTouches[0];
const pos = screenToCanvas(t.clientX, t.clientY);
const cx = Math.max(0, Math.min(pos.x, CANVAS_WIDTH - 1));
const cy = Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1));
onPick?.({ x: cx, y: cy });
}
return;
}
if (mode === 'draw') {
finishStroke();
} else if (!touchMoved && e.changedTouches.length === 1 && Date.now() - touchStartTime > 300) {
+10 -3
View File
@@ -1,6 +1,9 @@
<script>
let { mode, onModeChange, onSubmit, onUndo, onRedo, onClear,
canUndo, canRedo, pixelCount, submitting } = $props();
canUndo, canRedo, pixelCount, submitting, cooldownMs = 0 } = $props();
const onCooldown = $derived(cooldownMs > 0);
const cooldownLabel = $derived(`${(cooldownMs / 1000).toFixed(cooldownMs >= 1000 ? 0 : 1)}s`);
</script>
<div class="toolbar">
@@ -22,8 +25,12 @@
<div class="sep"></div>
<button class="submit-btn" onclick={onSubmit} disabled={pixelCount === 0 || submitting}>
{submitting ? 'Sending...' : `Submit${pixelCount > 0 ? ` (${pixelCount})` : ''}`}
<button class="submit-btn" onclick={onSubmit}
disabled={pixelCount === 0 || submitting || onCooldown}
title={onCooldown ? `Cooldown — ${cooldownLabel} left` : 'Submit pending pixels'}>
{#if submitting}Sending...
{:else if onCooldown}Wait {cooldownLabel}
{:else}Submit{pixelCount > 0 ? ` (${pixelCount})` : ''}{/if}
</button>
</div>
+165 -7
View File
@@ -5,8 +5,16 @@
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { createImageUploader } from '../../lib/image-uploader.js';
import { onMount } from 'svelte';
import {
rgbaToDataUrl, dataUrlToRgba,
saveJob, loadJob, updateJobProgress, clearJob,
} from '../../lib/image-job-storage.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose } = $props();
let { open, getCommittedColor, setOverlay, onClose, onRequestPick } = $props();
// True while THIS panel requested a pick and is still waiting for the result.
let picking = $state(false);
// Source image (decoded, palette-mapped)
let fileName = $state(null);
@@ -61,6 +69,104 @@
/** @type {ReturnType<typeof createImageUploader>|null} */
let uploader = null;
// Resume banner for a prior unfinished job.
/** @type {null | {placed: number, total: number, fileName: string, startedAt: number}} */
let resumable = $state(null);
// Current job's starting index into the built pixel list, used by the uploader
// to skip pixels that were already placed before a refresh.
let jobStartPlaced = 0;
onMount(() => {
const j = loadJob();
if (j && j.total > 0 && j.placed < j.total) {
resumable = {
placed: j.placed, total: j.total,
fileName: j.fileName || '(image)',
startedAt: j.startedAt || 0,
};
}
});
/** Snapshot the current pipeline inputs into a job record (minus placed/total which are added later). */
function buildJobRecord() {
return {
fileName,
originX, originY,
resizeW, resizeH, resampleMethod,
ditherMethod, skipWhite, whiteThreshold, paintTransparent,
flipH, flipV, rotation,
brightness, contrast, saturation, gamma,
skipMatching,
};
}
async function saveJobStart(totalCount) {
if (!srcRgba) return;
const srcDataUrl = await rgbaToDataUrl(srcRgba, srcWidth, srcHeight);
if (!srcDataUrl) return;
const record = {
...buildJobRecord(),
srcDataUrl, srcWidth, srcHeight,
placed: jobStartPlaced,
total: totalCount,
startedAt: Date.now(),
updatedAt: Date.now(),
};
saveJob(record);
}
function saveJobProgress(p) {
updateJobProgress(p);
}
function clearSavedJob() {
clearJob();
resumable = null;
}
async function resumeJob() {
const j = loadJob();
if (!j || !j.srcDataUrl) { resumable = null; return; }
errorText = null;
const decoded = await dataUrlToRgba(j.srcDataUrl);
if (!decoded) {
errorText = 'Failed to decode saved image.';
return;
}
// Restore source + config — the reactive pipeline will regenerate paletteIndices.
fileName = j.fileName ?? null;
srcRgba = decoded.rgba;
srcWidth = decoded.width;
srcHeight = decoded.height;
originX = j.originX ?? 0;
originY = j.originY ?? 0;
resizeW = j.resizeW ?? decoded.width;
resizeH = j.resizeH ?? decoded.height;
resampleMethod = j.resampleMethod ?? 'nearest';
ditherMethod = j.ditherMethod ?? 'none';
skipWhite = !!j.skipWhite;
whiteThreshold = j.whiteThreshold ?? 230;
paintTransparent = !!j.paintTransparent;
flipH = !!j.flipH;
flipV = !!j.flipV;
rotation = j.rotation ?? 0;
brightness = j.brightness ?? 0;
contrast = j.contrast ?? 0;
saturation = j.saturation ?? 0;
gamma = j.gamma ?? 1;
skipMatching = j.skipMatching ?? true;
jobStartPlaced = j.placed ?? 0;
total = j.total ?? 0;
placed = jobStartPlaced;
resumable = null;
statusText = `Resumed — ${placed}/${total} already placed.`;
status = 'idle'; // user hits Start to continue
}
async function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
@@ -205,9 +311,17 @@
return () => setOverlay?.(null);
});
function useCursor() {
originX = cursorPos?.x ?? 0;
originY = cursorPos?.y ?? 0;
function startPickOrigin() {
if (picking) {
// Second press = cancel the in-flight pick.
onRequestPick?.(null);
return;
}
picking = true;
onRequestPick?.({
pick: ({ x, y }) => { originX = x; originY = y; picking = false; },
cancel: () => { picking = false; },
});
}
function validatePlacement() {
@@ -245,11 +359,13 @@
statusText = 'Nothing to place (all pixels already match).';
status = 'done';
placed = 0; total = 0;
clearSavedJob();
return;
}
// If resuming, `jobStartPlaced` was set by resumeJob(); otherwise start fresh.
total = pixels.length;
placed = 0;
placed = Math.min(jobStartPlaced, total);
status = 'running';
statusText = 'Starting…';
@@ -257,11 +373,17 @@
onProgress: (p) => { placed = p; },
onStatus: (s) => { statusText = s; },
onError: (e) => { errorText = e.message || String(e); },
shouldSkip: skipMatching
? (p) => getCommittedColor?.(p.x, p.y) === p.color
: undefined,
onCheckpoint: (p) => { saveJobProgress(p); },
});
const result = await uploader.run(pixels);
await saveJobStart(pixels.length);
const result = await uploader.run(pixels, { startPlaced: placed });
uploader = null;
placed = result.placed;
jobStartPlaced = 0;
if (result.error) {
status = 'error';
@@ -272,6 +394,7 @@
} else {
status = 'done';
statusText = `Done. Placed ${result.placed} pixels.`;
clearSavedJob();
}
}
@@ -299,6 +422,19 @@
<button class="x" onclick={onClose} aria-label="Close">✕</button>
</div>
{#if resumable}
<div class="resume">
<div class="resume-text">
Unfinished job: <strong>{resumable.fileName}</strong>
— {resumable.placed}/{resumable.total} placed.
</div>
<div class="resume-btns">
<button class="primary" onclick={resumeJob}>Resume</button>
<button onclick={clearSavedJob}>Discard</button>
</div>
</div>
{/if}
<div class="row">
<label class="file-btn">
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onchange={handleFile} />
@@ -349,7 +485,10 @@
<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>
<button class:active={picking} onclick={startPickOrigin}
title="Click on the canvas to set X/Y (Esc to cancel)">
{picking ? 'Cancel pick…' : 'Pick on canvas'}
</button>
</div>
<div class="row">
@@ -490,6 +629,25 @@
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row.checkbox { gap: 6px; cursor: pointer; }
.resume {
display: flex; flex-direction: column; gap: 8px;
padding: 8px 10px;
background: rgba(59, 130, 246, 0.15);
border: 1px solid #3b82f6;
border-radius: 8px;
font-size: 0.82rem;
}
.resume-text { color: #cde; }
.resume-btns { display: flex; gap: 6px; }
.resume-btns button {
padding: 4px 12px; background: #262626; color: #ddd;
border: 1px solid #444; border-radius: 4px; cursor: pointer;
font-size: 0.85rem;
}
.resume-btns button:hover { background: #333; }
.resume-btns button.primary { background: #2563eb; border-color: #3b82f6; color: #fff; }
.resume-btns button.primary:hover { background: #1d4ed8; }
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; }
select { padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; }
+98
View File
@@ -0,0 +1,98 @@
// Persistence for the in-progress image-import job, so a refresh / crash
// doesn't lose the remaining pixels of a half-placed upload.
//
// We store the original decoded source image as a PNG dataURL alongside the
// full pipeline config + running `placed` count. On resume the caller re-runs
// the same transform → resize → palette pipeline and the uploader continues
// from the saved `placed` offset.
//
// Storage: localStorage (single key). Heavy images may overflow the 5-10MB
// quota; we catch QuotaExceededError and degrade to "save disabled" rather
// than crash.
const KEY = 'rplace:image-job:v1';
/**
* Serialize an ImageData-style source (RGBA Uint8ClampedArray + dims) to a
* PNG dataURL via OffscreenCanvas. Returns null on failure.
* @param {Uint8ClampedArray} srcRgba
* @param {number} width
* @param {number} height
* @returns {Promise<string|null>}
*/
export async function rgbaToDataUrl(srcRgba, width, height) {
try {
const oc = new OffscreenCanvas(width, height);
const ctx = oc.getContext('2d');
ctx.putImageData(new ImageData(new Uint8ClampedArray(srcRgba), width, height), 0, 0);
const blob = await oc.convertToBlob({ type: 'image/png' });
return await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(blob);
});
} catch {
return null;
}
}
/**
* Decode a PNG dataURL back into { rgba, width, height }. Returns null on failure.
* @param {string} dataUrl
*/
export async function dataUrlToRgba(dataUrl) {
try {
const img = new Image();
img.src = dataUrl;
await img.decode();
const w = img.naturalWidth;
const h = img.naturalHeight;
const oc = new OffscreenCanvas(w, h);
const ctx = oc.getContext('2d');
ctx.drawImage(img, 0, 0);
const { data } = ctx.getImageData(0, 0, w, h);
return { rgba: data, width: w, height: h };
} catch {
return null;
}
}
/**
* Persist the full job record. Returns true on success, false on quota/other error.
* @param {object} job
*/
export function saveJob(job) {
try {
localStorage.setItem(KEY, JSON.stringify(job));
return true;
} catch {
return false;
}
}
/** Read the stored job, or null if none / corrupt. */
export function loadJob() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** Update just the `placed` counter without re-serializing the image. */
export function updateJobProgress(placed) {
try {
const job = loadJob();
if (!job) return;
job.placed = placed;
job.updatedAt = Date.now();
localStorage.setItem(KEY, JSON.stringify(job));
} catch { /* quota etc. — ignore, user will just lose resume checkpoint */ }
}
export function clearJob() {
try { localStorage.removeItem(KEY); } catch { /* no-op */ }
}
+41 -6
View File
@@ -2,6 +2,9 @@ import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from './constants.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const COOLDOWN_MS = REQUEST_COOLDOWN_SEC * 1000;
// Re-filter the remaining pixel list every N batches. Cheap vs. a full scan
// per batch, and catches other users painting over the same region mid-upload.
const PROGRESSIVE_SKIP_EVERY = 8;
/**
* Browser-side uploader for batches of pixels.
@@ -9,14 +12,21 @@ const COOLDOWN_MS = REQUEST_COOLDOWN_SEC * 1000;
* so we send batches up to MAX_BATCH_SIZE spaced by COOLDOWN_MS. On 429 we
* back off using retryAfter. Pause/abort are cooperative.
*
* Lifecycle: create → await run(pixels) → resolves when finished or aborted.
* Lifecycle: create → await run(pixels, opts) → resolves when finished or aborted.
*
* @param {Object} hooks
* @param {(placed: number, total: number) => void} [hooks.onProgress]
* @param {(message: string) => void} [hooks.onStatus] - human-readable status line
* @param {(error: Error) => void} [hooks.onError] - recoverable errors (uploader retries)
* @param {(pixel: {x:number,y:number,color:number}) => boolean} [hooks.shouldSkip]
* Optional. Every PROGRESSIVE_SKIP_EVERY batches we re-run this predicate over
* the remaining pixels and drop those that return true. Used to skip pixels the
* canvas already matches after concurrent edits.
* @param {(placed: number) => void} [hooks.onCheckpoint]
* Fired after every completed batch with the running `placed` count, so callers
* can persist progress for auto-resume.
*/
export function createImageUploader({ onProgress, onStatus, onError } = {}) {
export function createImageUploader({ onProgress, onStatus, onError, shouldSkip, onCheckpoint } = {}) {
let aborted = false;
let paused = false;
@@ -32,17 +42,39 @@ export function createImageUploader({ onProgress, onStatus, onError } = {}) {
}
}
async function run(pixels) {
/**
* @param {Array<{x:number,y:number,color:number}>} pixels - full ordered pixel list for this job
* @param {{startPlaced?: number}} [opts] - resume offset into pixels (pixels before this index are skipped)
*/
async function run(pixels, { startPlaced = 0 } = {}) {
aborted = false;
paused = false;
let placed = 0;
const total = pixels.length;
let remaining = pixels.slice(Math.min(startPlaced, total));
let placed = Math.min(startPlaced, total);
let batchCount = 0;
let nextSendAt = 0; // epoch ms; first send goes out immediately
while (placed < total && !aborted) {
onProgress?.(placed, total);
while (remaining.length > 0 && !aborted) {
await waitWhilePaused();
if (aborted) break;
// Progressive skip — re-filter remaining against the predicate.
if (shouldSkip && batchCount > 0 && batchCount % PROGRESSIVE_SKIP_EVERY === 0) {
const before = remaining.length;
remaining = remaining.filter((p) => !shouldSkip(p));
const dropped = before - remaining.length;
if (dropped > 0) {
placed += dropped;
onStatus?.(`Skipped ${dropped} already-matching pixel${dropped === 1 ? '' : 's'}.`);
onProgress?.(placed, total);
onCheckpoint?.(placed);
if (remaining.length === 0) break;
}
}
const waitMs = nextSendAt - Date.now();
if (waitMs > 0) {
onStatus?.(`Waiting ${Math.ceil(waitMs / 1000)}s…`);
@@ -50,7 +82,7 @@ export function createImageUploader({ onProgress, onStatus, onError } = {}) {
continue;
}
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
const batch = remaining.slice(0, MAX_BATCH_SIZE);
onStatus?.(`Sending ${batch.length} pixels…`);
let res;
@@ -82,8 +114,11 @@ export function createImageUploader({ onProgress, onStatus, onError } = {}) {
return { placed, total, aborted: false, error: err };
}
remaining = remaining.slice(batch.length);
placed += batch.length;
batchCount += 1;
onProgress?.(placed, total);
onCheckpoint?.(placed);
nextSendAt = Date.now() + COOLDOWN_MS;
}