← back to Wallco Ai
ghost-review: magic wand — click motif to isolate, then recolor/erase/copy/paste/apply
7031abfccf7d717986bf13fd7857599ab36330c0 · 2026-05-23 23:49:11 -0700 · Steve Abrams
VIEWER — toggle wand mode with the green Wand button on stage (or W key):
- Click any pixel to flood-fill all connected pixels of similar color
- Tolerance slider re-runs the fill (0=strict, 120=loose)
- Selection rendered as a 50% green overlay on a separate canvas
- Per-image working canvas holds non-destructive edits
Actions on the selection:
- Recolor: color picker → replaces all selected pixels
- Erase: fills selected pixels with avg color of NON-selected pixels (bg)
- Copy: captures bounding box as a masked clipboard buffer
- Paste: click anywhere to drop the clipboard at that point (Esc to cancel)
- Clear: drops selection without applying any edit
- Apply: composites all edits into a new PNG, posts to /save-edited
SERVER — new POST /api/design/:id/save-edited:
- Accepts data:image/png;base64,... from the browser canvas
- Inserts a new spoon_all_designs row with generator='magic-wand-browser-edit'
- parent_design_id = source · unpublishes the original
- Returns { new_id, src_id, bytes }
UX:
- Wand mode disables the rect-drag mousedown so clicks isolate, not draw
- Working canvas persists across recolor/erase/paste operations until
Apply (or next image)
- Auto-saves a tp_other label on Apply so the action is recorded
- Auto-advances to next unlabeled after Apply
Files touched
M public/admin/ghost-review.htmlM server.js
Diff
commit 7031abfccf7d717986bf13fd7857599ab36330c0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 23:49:11 2026 -0700
ghost-review: magic wand — click motif to isolate, then recolor/erase/copy/paste/apply
VIEWER — toggle wand mode with the green Wand button on stage (or W key):
- Click any pixel to flood-fill all connected pixels of similar color
- Tolerance slider re-runs the fill (0=strict, 120=loose)
- Selection rendered as a 50% green overlay on a separate canvas
- Per-image working canvas holds non-destructive edits
Actions on the selection:
- Recolor: color picker → replaces all selected pixels
- Erase: fills selected pixels with avg color of NON-selected pixels (bg)
- Copy: captures bounding box as a masked clipboard buffer
- Paste: click anywhere to drop the clipboard at that point (Esc to cancel)
- Clear: drops selection without applying any edit
- Apply: composites all edits into a new PNG, posts to /save-edited
SERVER — new POST /api/design/:id/save-edited:
- Accepts data:image/png;base64,... from the browser canvas
- Inserts a new spoon_all_designs row with generator='magic-wand-browser-edit'
- parent_design_id = source · unpublishes the original
- Returns { new_id, src_id, bytes }
UX:
- Wand mode disables the rect-drag mousedown so clicks isolate, not draw
- Working canvas persists across recolor/erase/paste operations until
Apply (or next image)
- Auto-saves a tp_other label on Apply so the action is recorded
- Auto-advances to next unlabeled after Apply
---
public/admin/ghost-review.html | 333 ++++++++++++++++++++++++++++++++++++++++-
server.js | 70 +++++++++
2 files changed, 398 insertions(+), 5 deletions(-)
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 03914ba..8d77cdf 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -158,16 +158,26 @@
background:rgba(0,0,0,.7); color:#fff; font:600 11px ui-monospace,Menlo,monospace;
padding:5px 10px; border-radius:4px; pointer-events:none; z-index:3;
}
- .stage .seam-test-btn {
- position:absolute; bottom:14px; right:14px; z-index:4;
+ .stage .seam-test-btn,
+ .stage .wand-btn {
+ position:absolute; bottom:14px; z-index:4;
background:rgba(0,0,0,.75); color:#fff; border:1px solid #555;
padding:9px 14px; border-radius:6px; cursor:pointer;
font:600 12px var(--sans); letter-spacing:.06em;
display:flex; align-items:center; gap:8px;
}
- .stage .seam-test-btn:hover { background:#000; }
+ .stage .seam-test-btn { right:14px; }
+ .stage .wand-btn { right:185px; }
+ .stage .seam-test-btn:hover, .stage .wand-btn:hover { background:#000; }
.stage .seam-test-btn.active { background:var(--orange); border-color:var(--orange); color:#fff; }
- .stage .seam-test-btn .icon { font-size:14px; }
+ .stage .wand-btn.active { background:#7ad17a; border-color:#5fa85f; color:#0a2a0a; }
+ .stage .seam-test-btn .icon, .stage .wand-btn .icon { font-size:14px; }
+ .stage.wand-mode { cursor:crosshair; }
+ .stage canvas.wand-overlay {
+ position:absolute; pointer-events:none;
+ user-select:none; z-index:2;
+ image-rendering:pixelated;
+ }
.stage canvas.seam-canvas {
max-width:100%; max-height:96vh; display:block;
user-select:none; pointer-events:none;
@@ -451,6 +461,9 @@
<button class="seam-test-btn" id="seam-test-btn" title="quadrant-swap to bring outer edges to the center (X)">
<span class="icon">⊞</span> Seam Test <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">X</kbd>
</button>
+ <button class="wand-btn" id="wand-btn" title="click any motif to isolate it via flood-fill (W)">
+ <span class="icon">🪄</span> Wand <kbd style="background:#222;color:#fff;border:1px solid #555;padding:0 5px;border-radius:3px;font:600 10px ui-monospace,Menlo,monospace;margin-left:4px">W</kbd>
+ </button>
</div>
<div class="panel">
<button class="closex" id="close-btn" title="close (esc)">×</button>
@@ -509,6 +522,36 @@
</div>
<div id="fix-status" style="font:11.5px var(--sans); color:var(--faint); display:none; padding:8px 10px; background:#eef4ff; border-left:3px solid #3b78d8; border-radius:0 4px 4px 0;"></div>
+ <h3 style="color:var(--ink); font-size:12px;">🪄 Magic Wand <span style="margin-left:6px;font:600 10px var(--sans); color:var(--faint);">(<kbd>W</kbd> to enter wand mode · click any motif)</span></h3>
+ <div id="wand-panel" style="display:none; padding:10px 12px; border:1px solid #5fa85f; border-radius:6px; background:#f0f9f0; display:flex; flex-direction:column; gap:8px;">
+ <div style="display:flex; gap:8px; align-items:center; font:11px ui-monospace,Menlo,monospace;">
+ <span id="wand-swatch" style="display:inline-block; width:18px; height:18px; border-radius:3px; border:1px solid #888; vertical-align:middle"></span>
+ <span id="wand-stats">click a pixel</span>
+ </div>
+ <div style="display:flex; gap:6px; align-items:center;">
+ <span style="font:10px var(--sans); color:var(--faint); width:50px">tolerance</span>
+ <input type="range" id="wand-tol" min="0" max="120" step="2" value="28" style="flex:1">
+ <span id="wand-tol-val" style="font:600 11px ui-monospace,Menlo,monospace; color:var(--faint); width:30px; text-align:right">28</span>
+ </div>
+ <div style="display:flex; gap:6px;">
+ <label style="font:11px var(--sans); align-self:center;">recolor →</label>
+ <input type="color" id="wand-color" value="#222222" style="flex:1; height:30px; border:1px solid var(--line); border-radius:4px;">
+ <button id="wand-recolor" style="padding:6px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Apply</button>
+ </div>
+ <div style="display:flex; gap:6px; flex-wrap:wrap;">
+ <button id="wand-erase" style="flex:1; padding:7px 8px; border:1px solid var(--red); color:var(--red); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">🗑 Erase</button>
+ <button id="wand-copy" style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">⎘ Copy</button>
+ <button id="wand-paste" style="flex:1; padding:7px 8px; border:1px solid #3b78d8; color:#3b78d8; border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);" disabled>📋 Paste</button>
+ <button id="wand-clear" style="flex:1; padding:7px 8px; border:1px solid var(--line); color:var(--faint); border-radius:4px; background:#fff; cursor:pointer; font:600 11px var(--sans);">Clear</button>
+ </div>
+ <button id="wand-apply" style="padding:9px 10px; border:1px solid #5fa85f; color:#fff; background:#5fa85f; border-radius:4px; cursor:pointer; font:700 12px var(--sans);">
+ 💾 Apply All Edits → save as new design
+ </button>
+ <div style="font:10.5px var(--sans); color:var(--faint); line-height:1.4">
+ edits are non-destructive until <b>Apply</b>. you can recolor, erase, copy/paste multiple regions before committing.
+ </div>
+ </div>
+
<h3>Verdict (saves to training labels)</h3>
<div class="actions">
<button class="b-fp" id="btn-fp">✓ Clean (false-positive) <kbd>F</kbd></button>
@@ -795,6 +838,7 @@
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
runAutoSuggest();
+ wandResetForNewImage();
};
mainImg.src = item.image_url;
if (mainImg.complete) {
@@ -802,6 +846,7 @@
updateRectCount();
if (SEAM_VIEW) buildSeamCanvas();
runAutoSuggest();
+ wandResetForNewImage();
}
$('notes').value = (lbl && lbl.notes) || '';
$('notes-row').style.display = DRAW_MODE === 'other' ? '' : 'none';
@@ -837,6 +882,7 @@
// ── Drawing ────────────────────────────────────────────────────────────
let dragging = false, dragStart = null, dragGhost = null;
stage.addEventListener('mousedown', (e) => {
+ if (WAND_ON) return; // wand uses click handler, not drag-rect
if (e.target.tagName === 'BUTTON') return;
if (e.target.classList.contains('x')) return;
if (CURRENT === null) return;
@@ -1181,6 +1227,279 @@
// Re-render suggestions on window resize so they stay anchored
window.addEventListener('resize', () => { drawSuggestions(); });
+ // ── Magic Wand (select / recolor / erase / copy / paste / apply) ──────
+ let WAND_ON = false;
+ let WAND_TOL = 28;
+ let SELECTION_MASK = null; // Uint8Array of working canvas size
+ let WORKING_CANVAS = null; // off-screen edited copy of the source
+ let CLIPBOARD = null; // {data: ImageData, w, h}
+ let LAST_CLICK = null;
+ let PASTE_ARMED = false;
+
+ function wandResetForNewImage() {
+ WORKING_CANVAS = null; SELECTION_MASK = null; LAST_CLICK = null;
+ CLIPBOARD = null; PASTE_ARMED = false;
+ $('wand-paste').disabled = true;
+ $('wand-stats').textContent = 'click a pixel';
+ $('wand-swatch').style.background = '';
+ imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
+ if (WAND_ON) ensureWorkingCanvas();
+ }
+ function ensureWorkingCanvas() {
+ if (WORKING_CANVAS) return WORKING_CANVAS;
+ const w = mainImg.naturalWidth, h = mainImg.naturalHeight;
+ if (!w) return null;
+ WORKING_CANVAS = document.createElement('canvas');
+ WORKING_CANVAS.width = w; WORKING_CANVAS.height = h;
+ WORKING_CANVAS.getContext('2d').drawImage(mainImg, 0, 0, w, h);
+ return WORKING_CANVAS;
+ }
+ function ensureWandOverlay() {
+ let ov = imgWrap.querySelector('.wand-overlay');
+ if (!ov) {
+ ov = document.createElement('canvas');
+ ov.className = 'wand-overlay';
+ imgWrap.appendChild(ov);
+ }
+ positionWandOverlay(ov);
+ return ov;
+ }
+ function positionWandOverlay(ov) {
+ if (!WORKING_CANVAS) return;
+ const r = mainImg.getBoundingClientRect();
+ const wr = imgWrap.getBoundingClientRect();
+ ov.width = WORKING_CANVAS.width;
+ ov.height = WORKING_CANVAS.height;
+ ov.style.width = r.width + 'px';
+ ov.style.height = r.height + 'px';
+ ov.style.left = (r.left - wr.left) + 'px';
+ ov.style.top = (r.top - wr.top) + 'px';
+ }
+ function toggleWand() {
+ if (CURRENT === null) return;
+ WAND_ON = !WAND_ON;
+ $('wand-btn').classList.toggle('active', WAND_ON);
+ stage.classList.toggle('wand-mode', WAND_ON);
+ $('wand-panel').style.display = WAND_ON ? 'flex' : 'none';
+ if (WAND_ON) {
+ ensureWorkingCanvas();
+ ensureWandOverlay();
+ toast('🪄 wand mode — click any motif color to select it');
+ } else {
+ imgWrap.querySelectorAll('.wand-overlay').forEach(el => el.remove());
+ }
+ }
+ $('wand-btn').onclick = toggleWand;
+
+ function runFloodFill() {
+ if (!LAST_CLICK || !WORKING_CANVAS) return;
+ const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+ const ctx = WORKING_CANVAS.getContext('2d');
+ const img = ctx.getImageData(0, 0, w, h);
+ const data = img.data;
+ const i0 = (LAST_CLICK.py * w + LAST_CLICK.px) * 4;
+ const tR = data[i0], tG = data[i0+1], tB = data[i0+2];
+ $('wand-swatch').style.background = `rgb(${tR},${tG},${tB})`;
+ $('wand-color').value = '#' + [tR,tG,tB].map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2,'0')).join('');
+ SELECTION_MASK = new Uint8Array(w * h);
+ const stack = [[LAST_CLICK.px, LAST_CLICK.py]];
+ const tol2 = WAND_TOL * WAND_TOL * 3;
+ while (stack.length) {
+ const [x, y] = stack.pop();
+ if (x < 0 || y < 0 || x >= w || y >= h) continue;
+ const idx = y * w + x;
+ if (SELECTION_MASK[idx]) continue;
+ const di = idx * 4;
+ const dr = data[di] - tR, dg = data[di+1] - tG, db = data[di+2] - tB;
+ if (dr*dr + dg*dg + db*db > tol2) continue;
+ SELECTION_MASK[idx] = 1;
+ stack.push([x+1, y]); stack.push([x-1, y]);
+ stack.push([x, y+1]); stack.push([x, y-1]);
+ }
+ drawSelectionOverlay();
+ const count = SELECTION_MASK.reduce((s,v) => s + v, 0);
+ $('wand-stats').textContent = `${count.toLocaleString()} px (${((count / (w*h)) * 100).toFixed(1)}% of image) · rgb(${tR},${tG},${tB})`;
+ }
+ function drawSelectionOverlay() {
+ const ov = ensureWandOverlay();
+ if (!ov || !WORKING_CANVAS) return;
+ positionWandOverlay(ov);
+ const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+ const ctx = ov.getContext('2d');
+ ctx.clearRect(0, 0, w, h);
+ if (!SELECTION_MASK) return;
+ const out = ctx.createImageData(w, h);
+ for (let i = 0; i < SELECTION_MASK.length; i++) {
+ if (SELECTION_MASK[i]) {
+ const o = i * 4;
+ out.data[o] = 90; out.data[o+1] = 220; out.data[o+2] = 90; out.data[o+3] = 130;
+ }
+ }
+ ctx.putImageData(out, 0, 0);
+ }
+ function onWandClick(e) {
+ const r = mainImg.getBoundingClientRect();
+ if (e.clientX < r.left || e.clientX > r.right ||
+ e.clientY < r.top || e.clientY > r.bottom) return;
+ if (!WORKING_CANVAS) ensureWorkingCanvas();
+ const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+ const px = Math.floor((e.clientX - r.left) / r.width * w);
+ const py = Math.floor((e.clientY - r.top) / r.height * h);
+ LAST_CLICK = { px, py };
+ runFloodFill();
+ }
+
+ function wandRecolor() {
+ if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+ const hex = $('wand-color').value;
+ const R = parseInt(hex.slice(1,3),16), G = parseInt(hex.slice(3,5),16), B = parseInt(hex.slice(5,7),16);
+ const ctx = WORKING_CANVAS.getContext('2d');
+ const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
+ let n = 0;
+ for (let i = 0; i < SELECTION_MASK.length; i++) {
+ if (SELECTION_MASK[i]) {
+ img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B;
+ n++;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+ toast(`✓ recolored ${n.toLocaleString()} px`);
+ }
+ function wandErase() {
+ if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+ const ctx = WORKING_CANVAS.getContext('2d');
+ const img = ctx.getImageData(0, 0, WORKING_CANVAS.width, WORKING_CANVAS.height);
+ // Avg color of NON-selected pixels = bg
+ let aR = 0, aG = 0, aB = 0, nb = 0;
+ for (let i = 0; i < SELECTION_MASK.length; i++) {
+ if (!SELECTION_MASK[i]) { aR += img.data[i*4]; aG += img.data[i*4+1]; aB += img.data[i*4+2]; nb++; }
+ }
+ const R = Math.round(aR/nb), G = Math.round(aG/nb), B = Math.round(aB/nb);
+ let n = 0;
+ for (let i = 0; i < SELECTION_MASK.length; i++) {
+ if (SELECTION_MASK[i]) { img.data[i*4] = R; img.data[i*4+1] = G; img.data[i*4+2] = B; n++; }
+ }
+ ctx.putImageData(img, 0, 0);
+ mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+ toast(`✓ erased ${n.toLocaleString()} px to bg rgb(${R},${G},${B})`);
+ }
+ function wandCopy() {
+ if (!SELECTION_MASK || !WORKING_CANVAS) { toast('select first'); return; }
+ const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+ let minX = w, minY = h, maxX = 0, maxY = 0;
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
+ if (SELECTION_MASK[y*w + x]) {
+ if (x<minX) minX=x; if (x>maxX) maxX=x;
+ if (y<minY) minY=y; if (y>maxY) maxY=y;
+ }
+ }
+ const rw = maxX - minX + 1, rh = maxY - minY + 1;
+ if (rw < 1 || rh < 1) { toast('empty selection'); return; }
+ const ctx = WORKING_CANVAS.getContext('2d');
+ const region = ctx.getImageData(minX, minY, rw, rh);
+ // Apply mask as alpha
+ for (let y = 0; y < rh; y++) for (let x = 0; x < rw; x++) {
+ if (!SELECTION_MASK[(y+minY) * w + (x+minX)]) region.data[(y*rw + x)*4 + 3] = 0;
+ }
+ CLIPBOARD = { data: region, w: rw, h: rh };
+ $('wand-paste').disabled = false;
+ toast(`⎘ copied ${rw}×${rh} region`);
+ }
+ function wandPasteArm() {
+ if (!CLIPBOARD) { toast('nothing copied'); return; }
+ PASTE_ARMED = true;
+ toast('click to paste · Esc to cancel');
+ }
+ function doPaste(clickX, clickY) {
+ if (!CLIPBOARD || !WORKING_CANVAS) return;
+ const r = mainImg.getBoundingClientRect();
+ const w = WORKING_CANVAS.width, h = WORKING_CANVAS.height;
+ const cx = Math.floor((clickX - r.left) / r.width * w);
+ const cy = Math.floor((clickY - r.top) / r.height * h);
+ const ctx = WORKING_CANVAS.getContext('2d');
+ const tmp = document.createElement('canvas');
+ tmp.width = CLIPBOARD.w; tmp.height = CLIPBOARD.h;
+ tmp.getContext('2d').putImageData(CLIPBOARD.data, 0, 0);
+ ctx.drawImage(tmp, cx - CLIPBOARD.w/2, cy - CLIPBOARD.h/2);
+ PASTE_ARMED = false;
+ mainImg.src = WORKING_CANVAS.toDataURL('image/png');
+ toast(`📋 pasted at (${cx},${cy})`);
+ }
+ function wandClear() {
+ SELECTION_MASK = null; LAST_CLICK = null;
+ drawSelectionOverlay();
+ $('wand-stats').textContent = 'click a pixel';
+ $('wand-swatch').style.background = '';
+ }
+ async function wandApply() {
+ if (CURRENT === null || !WORKING_CANVAS) { toast('no edits to save'); return; }
+ if (!confirm('Save all magic-wand edits as a new design? Original will be unpublished.')) return;
+ const item = VIEW[CURRENT];
+ const stat = $('fix-status');
+ stat.style.display = ''; stat.style.borderLeftColor = '#5fa85f'; stat.style.background = '#f0f9f0';
+ stat.textContent = 'Uploading edited PNG…';
+ try {
+ const dataUrl = WORKING_CANVAS.toDataURL('image/png');
+ const r = await fetch(`/api/design/${item.id}/save-edited`, {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ data_url: dataUrl, edits_summary: 'magic-wand: recolor/erase/paste' }),
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+ stat.innerHTML = `✓ Saved as <a href="/design/${j.new_id}" target="_blank" style="color:var(--green); font-weight:600">#${j.new_id}</a> · original #${item.id} unpublished`;
+ toast(`✓ saved → #${j.new_id}`);
+ // Save a tp_other label
+ try {
+ await fetch('/api/ghost-review/label', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ id: item.id, verdict: 'tp_other', crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `magic-wand edit → #${j.new_id}` }),
+ });
+ } catch {}
+ STAT.other = (STAT.other || 0) + 1;
+ LABELS.set(item.id, { verdict: 'tp_other', crops: [], wand_to: j.new_id });
+ updateStats();
+ WORKING_CANVAS = null;
+ wandClear();
+ setTimeout(() => {
+ recomputeView(); render();
+ if (FILTER === 'unlabeled') {
+ if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
+ if (VIEW.length === 0) { close(); return; }
+ open(CURRENT);
+ } else {
+ if (CURRENT < VIEW.length - 1) navigate(1);
+ }
+ }, 2200);
+ } catch (e) {
+ stat.style.borderLeftColor = 'var(--red)'; stat.style.background = '#fce8e6';
+ stat.innerHTML = '✗ Save failed: ' + e.message;
+ }
+ }
+
+ $('wand-tol').oninput = (e) => {
+ WAND_TOL = parseInt(e.target.value, 10);
+ $('wand-tol-val').textContent = WAND_TOL;
+ if (LAST_CLICK) runFloodFill();
+ };
+ $('wand-recolor').onclick = wandRecolor;
+ $('wand-erase').onclick = wandErase;
+ $('wand-copy').onclick = wandCopy;
+ $('wand-paste').onclick = wandPasteArm;
+ $('wand-clear').onclick = wandClear;
+ $('wand-apply').onclick = wandApply;
+
+ // Stage click handler for wand (separate from rect-drag mousedown)
+ stage.addEventListener('click', (e) => {
+ if (!WAND_ON) return;
+ if (e.target.tagName === 'BUTTON' || e.target.classList.contains('x')) return;
+ if (e.target.classList.contains('rect') || e.target.classList.contains('sgst')) return;
+ if (PASTE_ARMED) doPaste(e.clientX, e.clientY);
+ else onWandClick(e);
+ });
+ // Reposition overlay on resize
+ window.addEventListener('resize', () => { if (WAND_ON) drawSelectionOverlay(); });
+
// ── Save verdict ───────────────────────────────────────────────────────
function toast(msg) {
const t = $('toast');
@@ -1419,7 +1738,10 @@
if (!modal.classList.contains('open')) return;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
const k = e.key.toLowerCase();
- if (k === 'escape') close();
+ if (k === 'escape') {
+ if (PASTE_ARMED) { PASTE_ARMED = false; toast('paste cancelled'); }
+ else close();
+ }
else if (k === 'arrowleft') navigate(-1);
else if (k === 'arrowright') navigate(1);
else if (k === 'f') save('fp');
@@ -1435,6 +1757,7 @@
else if (k === 'r') { if (!$('btn-fix').classList.contains('gated')) doFix(); }
else if (k === 'd') { if (!$('btn-remove').classList.contains('gated')) doFix('remove-fill'); }
else if (k === 'e') { doRegen(); }
+ else if (k === 'w') { toggleWand(); }
});
// ── Boot ───────────────────────────────────────────────────────────────
diff --git a/server.js b/server.js
index 5846766..4fdf25f 100644
--- a/server.js
+++ b/server.js
@@ -7017,6 +7017,76 @@ RETURNING id`);
}
});
+// POST /api/design/:id/save-edited — accept a base64-PNG of locally-edited
+// pixels (magic-wand recolor/erase/paste) and save it as a NEW design.
+// No Gemini call — just stores the bytes the browser produced.
+// Body: { data_url: 'data:image/png;base64,...', edits_summary?: '...' }
+app.post('/api/design/:id/save-edited', express.json({ limit: '32mb' }), async (req, res) => {
+ if (!adminRatingGate(req, res)) return;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ const dataUrl = String(req.body?.data_url || '');
+ const summary = String(req.body?.edits_summary || 'browser-edited').slice(0, 300);
+ if (!dataUrl.startsWith('data:image/png;base64,')) {
+ return res.status(400).json({ error: 'data_url must be data:image/png;base64,...' });
+ }
+ const b64 = dataUrl.slice('data:image/png;base64,'.length);
+ const buf = Buffer.from(b64, 'base64');
+ if (buf.length < 1024) return res.status(400).json({ error: 'tiny png ' + buf.length + 'b' });
+
+ try {
+ let d = null;
+ try {
+ const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, seed, category,
+ width_in, height_in, panels, motifs, tags, generator, dominant_hex, palette, local_path,
+ product_line, parent_design_id, dig_number
+ FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+ } catch { /* non-fatal */ }
+ if (!d) {
+ const cached = DESIGNS.find(x => x.id === id);
+ if (cached) d = { ...cached };
+ }
+ if (!d) return res.status(404).json({ error: 'design not found' });
+
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `wandedit_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ fs.writeFileSync(outPath, buf);
+
+ const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+ const ins = psqlExecLocal(`
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+ image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
+ parent_design_id)
+VALUES
+ (${esc(d.kind || 'tile_repeat')}, 'wallco.ai',
+ ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+ 'magic-wand-browser-edit',
+ ${esc(`Magic-wand edit of #${id}: ${summary}`)},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(outPath)},
+ ${esc(d.dominant_hex)},
+ ${esc(JSON.stringify(d.palette || []))}::jsonb,
+ ${d.motifs ? `ARRAY[${(d.motifs || []).map(m => esc(m)).join(',')}]::text[]` : 'NULL'},
+ ${d.tags ? `ARRAY[${(d.tags || []).map(t => esc(t)).join(',')}]::text[]` : 'NULL'},
+ ${esc(d.category)},
+ TRUE,
+ ${id})
+RETURNING id`);
+ const newId = parseInt(ins, 10);
+ if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+ psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+ return res.json({ ok: true, new_id: newId, src_id: id, bytes: buf.length });
+ } catch (e) {
+ console.error('[save-edited]', e);
+ return res.status(500).json({ error: e.message });
+ }
+});
+
app.post('/api/design/:id/ghosting', express.json({ limit: '16kb' }), (req, res) => {
if (!adminRatingGate(req, res)) return;
const id = parseInt(req.params.id, 10);
← 9666be2 scripts: oneshot trellis-upgrade regen for drunk-zoo-36 #266
·
back to Wallco Ai
·
add ghost-rescore-flagged.js — quantify detector-change resc a3c2b00 →