← back to Wallco Ai
feat(ghost-review): bulk-select with marquee drag + Delete & Learn
b6c85f3044026c3415933b4986d4dbcd7cfbfabc · 2026-05-24 00:40:57 -0700 · Steve Abrams
UI changes (public/admin/ghost-review.html):
- New 'BULK SELECT' toggle in the controls bar
- In bulk mode: tile click toggles selection (shift-click selects range)
- Drag marquee on empty grid area selects every overlapping tile; alt-drag deselects
- Floating bulk-action bar with 'Select all on page', 'Clear', 'Delete & learn (N)'
- Selected tiles get a blue outline + checkmark badge
Server endpoint (server.js):
- POST /api/ghost-review/bulk-delete {ids, learn}
1. Looks up prompt + motifs + local_path from PG for each id
2. Appends each to data/bad-aesthetic-patterns.jsonl so future
generation prompts can avoid the bad aesthetic (Steve's 'learn that those are bad' ask)
3. Moves PNGs to data/generated_ghost_quarantine/
4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE
(PG update runs idempotently even if files already moved — fixes the
soft-delete script's over-aggressive 'skip if in purge log' bug)
5. Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply
Files touched
M public/admin/ghost-review.htmlM server.js
Diff
commit b6c85f3044026c3415933b4986d4dbcd7cfbfabc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 00:40:57 2026 -0700
feat(ghost-review): bulk-select with marquee drag + Delete & Learn
UI changes (public/admin/ghost-review.html):
- New 'BULK SELECT' toggle in the controls bar
- In bulk mode: tile click toggles selection (shift-click selects range)
- Drag marquee on empty grid area selects every overlapping tile; alt-drag deselects
- Floating bulk-action bar with 'Select all on page', 'Clear', 'Delete & learn (N)'
- Selected tiles get a blue outline + checkmark badge
Server endpoint (server.js):
- POST /api/ghost-review/bulk-delete {ids, learn}
1. Looks up prompt + motifs + local_path from PG for each id
2. Appends each to data/bad-aesthetic-patterns.jsonl so future
generation prompts can avoid the bad aesthetic (Steve's 'learn that those are bad' ask)
3. Moves PNGs to data/generated_ghost_quarantine/
4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE
(PG update runs idempotently even if files already moved — fixes the
soft-delete script's over-aggressive 'skip if in purge log' bug)
5. Reversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply
---
public/admin/ghost-review.html | 257 +++++++++++++++++++++++-
server.js | 432 +++++++++++++++++++++++++++++++++++++++--
2 files changed, 673 insertions(+), 16 deletions(-)
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index e07cacd..bd46a57 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -77,6 +77,37 @@
.tile.lbl-tp_seam { outline:3px solid var(--orange); outline-offset:-3px; }
.tile.lbl-tp_other { outline:3px solid var(--gold); outline-offset:-3px; }
.tile.lbl-skip { opacity:.4; outline:2px dashed var(--faint); outline-offset:-2px; }
+ .tile.sel { outline:4px solid #3b78d8; outline-offset:-4px; box-shadow:0 0 0 2px rgba(59,120,216,.35) inset; }
+ .tile.sel::after {
+ content:"✓"; position:absolute; top:6px; right:6px;
+ background:#3b78d8; color:#fff; width:22px; height:22px; border-radius:50%;
+ display:flex; align-items:center; justify-content:center;
+ font:700 14px var(--sans); box-shadow:0 1px 3px rgba(0,0,0,.3);
+ }
+ body.bulk-mode .tile { cursor:cell; }
+ body.bulk-mode .grid { user-select:none; }
+ .marquee {
+ position:absolute; pointer-events:none;
+ background:rgba(59,120,216,.10); border:1.5px dashed #3b78d8;
+ border-radius:2px; z-index:30; display:none;
+ }
+ .bulk-bar {
+ position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
+ background:#1f1808; color:#fff; padding:12px 22px; border-radius:30px;
+ display:none; align-items:center; gap:14px; z-index:60;
+ box-shadow:0 6px 24px rgba(0,0,0,.35); font:600 13px var(--sans);
+ }
+ .bulk-bar.show { display:flex; }
+ .bulk-bar .count { background:#3b78d8; padding:3px 10px; border-radius:14px; font:700 12px ui-monospace,Menlo,monospace; }
+ .bulk-bar button {
+ background:transparent; color:#fff; border:1px solid rgba(255,255,255,.35);
+ padding:7px 14px; border-radius:18px; font:600 12px var(--sans);
+ cursor:pointer; letter-spacing:.05em;
+ }
+ .bulk-bar button:hover { background:rgba(255,255,255,.10); }
+ .bulk-bar button.danger { background:#b3261e; border-color:#b3261e; }
+ .bulk-bar button.danger:hover { background:#d33; }
+ #bulk-toggle.active { background:#3b78d8; color:#fff; border-color:#3b78d8; }
.tile .verdict-pill {
position:absolute; left:0; right:0; bottom:0;
color:#fff; font:700 10px var(--sans); letter-spacing:.1em;
@@ -196,16 +227,19 @@
background:rgba(120,180,255,.10);
transition:background .12s, border-color .12s;
}
- .stage .sgst:hover {
- background:rgba(120,180,255,.30);
- border-color:#7cb8ff;
+ .stage .sgst.s-seam {
+ border-color:rgba(255,174,58,.95);
+ background:rgba(255,174,58,.16);
}
+ .stage .sgst:hover { background:rgba(120,180,255,.30); border-color:#7cb8ff; }
+ .stage .sgst.s-seam:hover { background:rgba(255,174,58,.36); border-color:#ffae3a; }
.stage .sgst .sgst-label {
position:absolute; top:-18px; left:0;
background:#3b78d8; color:#fff; font:600 9px var(--sans);
letter-spacing:.06em; padding:1px 4px; border-radius:2px;
pointer-events:none; white-space:nowrap;
}
+ .stage .sgst.s-seam .sgst-label { background:#ffae3a; color:#3a2a00; }
/* Right panel */
.panel {
@@ -425,6 +459,7 @@
<button data-filter="seam">Seam</button>
<button data-filter="all">All</button>
</div>
+ <button id="bulk-toggle" style="padding:5px 12px;border:1px solid var(--line);border-radius:14px;background:#fff;font:600 11px var(--sans);cursor:pointer;letter-spacing:.06em;">🔲 BULK SELECT</button>
<div class="legend">
<kbd>F</kbd> clean
<kbd>G</kbd> ghost
@@ -445,13 +480,23 @@
<div class="analysis-grid" id="ana-grid"></div>
</section>
- <div id="grid" class="grid"></div>
+ <div id="grid-wrap" style="position:relative;">
+ <div id="grid" class="grid"></div>
+ <div id="marquee" class="marquee"></div>
+ </div>
<div id="empty" class="empty" style="display:none">
<h2>Nothing to review</h2>
<p>All flagged items in this filter have been labeled. Switch filter above.</p>
</div>
</main>
+<div id="bulk-bar" class="bulk-bar" role="region" aria-label="Bulk selection actions">
+ <span><span class="count" id="bulk-count">0</span> selected</span>
+ <button id="bulk-all-page" title="select every tile currently rendered">Select all on page</button>
+ <button id="bulk-clear">Clear</button>
+ <button id="bulk-delete" class="danger" title="soft-delete every selected design + save its prompt/motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic">🗑 Delete & learn</button>
+</div>
+
<div class="modal" id="modal" aria-hidden="true">
<div class="modal-inner">
<div class="stage" id="stage">
@@ -510,6 +555,9 @@
<h3 style="color:var(--ink); font-size:12px;">⚡ Repair Actions</h3>
<div class="actions">
+ <button class="b-smart" id="btn-smart" style="border:2px solid #c79a3a; color:#a07000; background:linear-gradient(180deg, #fff7e0, #fef0c8);">
+ 🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>
+ </button>
<button class="b-fix gated" id="btn-fix">
🪄 Surgical Fix · marked regions <kbd>R</kbd>
</button>
@@ -790,8 +838,10 @@
for (const item of slice) {
const tile = document.createElement('div');
tile.className = 'tile';
+ tile.dataset.id = item.id;
const lbl = LABELS.get(item.id);
if (lbl) tile.classList.add('lbl-' + lbl.verdict);
+ if (SELECTED.has(item.id)) tile.classList.add('sel');
const pillText = lbl ?
(lbl.verdict === 'fp' ? '✓ CLEAN' :
lbl.verdict === 'tp_ghost' ? '⚠ GHOST' :
@@ -803,12 +853,152 @@
<div class="conf">${(item.confidence || 0).toFixed(2)}</div>
${pillText ? `<div class="verdict-pill">${pillText}</div>` : ''}
`;
- tile.onclick = () => open(VIEW.indexOf(item));
+ tile.onclick = (e) => {
+ if (BULK_MODE) {
+ toggleSelection(item.id, e.shiftKey);
+ } else {
+ open(VIEW.indexOf(item));
+ }
+ };
frag.appendChild(tile);
}
grid.appendChild(frag);
}
+ // ── Bulk select mode ───────────────────────────────────────────────────
+ let BULK_MODE = false;
+ const SELECTED = new Set();
+ let LAST_CLICKED_ID = null;
+
+ function setBulkMode(on) {
+ BULK_MODE = !!on;
+ document.body.classList.toggle('bulk-mode', BULK_MODE);
+ $('bulk-toggle').classList.toggle('active', BULK_MODE);
+ $('bulk-toggle').textContent = BULK_MODE ? '✕ EXIT BULK' : '🔲 BULK SELECT';
+ if (!BULK_MODE) { SELECTED.clear(); refreshBulkUI(); }
+ refreshBulkUI();
+ }
+ function refreshBulkUI() {
+ $('bulk-count').textContent = SELECTED.size.toLocaleString();
+ $('bulk-bar').classList.toggle('show', BULK_MODE && SELECTED.size > 0);
+ // sync tile visuals without full re-render
+ document.querySelectorAll('.tile').forEach(t => {
+ const id = parseInt(t.dataset.id, 10);
+ t.classList.toggle('sel', SELECTED.has(id));
+ });
+ }
+ function toggleSelection(id, shiftRange) {
+ if (shiftRange && LAST_CLICKED_ID !== null) {
+ // range select on current VIEW slice
+ const tilesInOrder = Array.from(document.querySelectorAll('.tile')).map(t => parseInt(t.dataset.id, 10));
+ const a = tilesInOrder.indexOf(LAST_CLICKED_ID), b = tilesInOrder.indexOf(id);
+ if (a >= 0 && b >= 0) {
+ const [lo, hi] = a < b ? [a, b] : [b, a];
+ for (let i = lo; i <= hi; i++) SELECTED.add(tilesInOrder[i]);
+ } else SELECTED.add(id);
+ } else {
+ if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
+ }
+ LAST_CLICKED_ID = id;
+ refreshBulkUI();
+ }
+
+ // Marquee drag-select on the grid wrapper
+ (function initMarquee() {
+ const wrap = $('grid-wrap');
+ const mq = $('marquee');
+ let dragging = false, sx = 0, sy = 0, addMode = true;
+ wrap.addEventListener('mousedown', (e) => {
+ if (!BULK_MODE) return;
+ // only start marquee when not clicking on a tile (or its children)
+ if (e.target.closest('.tile')) return;
+ if (e.button !== 0) return;
+ dragging = true;
+ addMode = !e.altKey; // alt-drag = deselect
+ const r = wrap.getBoundingClientRect();
+ sx = e.clientX - r.left; sy = e.clientY - r.top;
+ mq.style.left = sx + 'px'; mq.style.top = sy + 'px';
+ mq.style.width = '0px'; mq.style.height = '0px';
+ mq.style.display = 'block';
+ e.preventDefault();
+ });
+ document.addEventListener('mousemove', (e) => {
+ if (!dragging) return;
+ const r = wrap.getBoundingClientRect();
+ const x = e.clientX - r.left, y = e.clientY - r.top;
+ const l = Math.min(sx, x), t = Math.min(sy, y);
+ const w = Math.abs(x - sx), h = Math.abs(y - sy);
+ mq.style.left = l + 'px'; mq.style.top = t + 'px';
+ mq.style.width = w + 'px'; mq.style.height = h + 'px';
+ });
+ document.addEventListener('mouseup', () => {
+ if (!dragging) return;
+ dragging = false;
+ mq.style.display = 'none';
+ const mr = { l: parseFloat(mq.style.left), t: parseFloat(mq.style.top),
+ r: parseFloat(mq.style.left) + parseFloat(mq.style.width),
+ b: parseFloat(mq.style.top) + parseFloat(mq.style.height) };
+ if (mr.r - mr.l < 4 && mr.b - mr.t < 4) return; // ignore tiny clicks
+ const wrapRect = wrap.getBoundingClientRect();
+ document.querySelectorAll('.tile').forEach(tile => {
+ const id = parseInt(tile.dataset.id, 10);
+ const tr = tile.getBoundingClientRect();
+ const lx = tr.left - wrapRect.left, ty = tr.top - wrapRect.top;
+ const rx = lx + tr.width, by = ty + tr.height;
+ const hit = !(rx < mr.l || lx > mr.r || by < mr.t || ty > mr.b);
+ if (hit) { if (addMode) SELECTED.add(id); else SELECTED.delete(id); }
+ });
+ refreshBulkUI();
+ });
+ })();
+
+ $('bulk-toggle').onclick = () => setBulkMode(!BULK_MODE);
+ $('bulk-clear').onclick = () => { SELECTED.clear(); refreshBulkUI(); };
+ $('bulk-all-page').onclick = () => {
+ document.querySelectorAll('.tile').forEach(t => SELECTED.add(parseInt(t.dataset.id, 10)));
+ refreshBulkUI();
+ };
+ $('bulk-delete').onclick = async () => {
+ if (!SELECTED.size) return;
+ const ids = Array.from(SELECTED);
+ if (!confirm(`Soft-delete ${ids.length} design${ids.length===1?'':'s'}?\n\nFor each: unpublish PG row, move PNG to quarantine, save prompt+motifs to bad-aesthetic-patterns.jsonl so the next build avoids that aesthetic.\n\nReversible via: node scripts/soft-delete-ghost-flagged.js --restore --apply`)) return;
+ $('bulk-delete').disabled = true;
+ $('bulk-delete').textContent = '… deleting';
+ try {
+ const r = await fetch('/api/ghost-review/bulk-delete?admin=' + encodeURIComponent(getAdmin()), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids, learn: true }),
+ });
+ const j = await r.json();
+ if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+ // Remove deleted items from VIEW + ALL + SELECTED
+ const del = new Set(j.unpublished_ids || ids);
+ window.ALL = (window.ALL || []).filter(x => !del.has(x.id));
+ VIEW = VIEW.filter(x => !del.has(x.id));
+ SELECTED.clear();
+ render();
+ refreshBulkUI();
+ toast(`✓ Deleted ${j.unpublished} · ${j.salvaged} salvaged to bad-aesthetic-patterns.jsonl`);
+ } catch (e) {
+ alert('Delete failed: ' + e.message);
+ } finally {
+ $('bulk-delete').disabled = false;
+ $('bulk-delete').textContent = '🗑 Delete & learn';
+ }
+ };
+ function getAdmin() {
+ const u = new URL(location.href);
+ return u.searchParams.get('admin') || '';
+ }
+ function toast(msg) {
+ const t = document.querySelector('.toast') || (() => {
+ const el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el); return el;
+ })();
+ t.textContent = msg; t.classList.add('show');
+ setTimeout(() => t.classList.remove('show'), 3500);
+ }
+
// ── Modal ──────────────────────────────────────────────────────────────
const modal = $('modal');
const stage = $('stage');
@@ -1695,6 +1885,62 @@
// ── Remove + Fill (erase marked regions, inpaint background) ──────────
$('btn-remove').onclick = () => doFix('remove-fill');
+ // ── Smart Fix (auto-detect clean motif → re-tile) ─────────────────────
+ $('btn-smart').onclick = () => doSmartFix();
+ async function doSmartFix() {
+ if (CURRENT === null) return;
+ const item = VIEW[CURRENT];
+ if (!confirm(`Auto-detect the clean hero motif in #${item.id} and rebuild as a seamless OPAQUE tile? ~15-20s.`)) return;
+ const btn = $('btn-smart');
+ const stat = $('fix-status');
+ btn.disabled = true;
+ btn.innerHTML = '🎯 Detecting hero motif + regenerating opaque tile… ~15-20s';
+ stat.style.display = '';
+ stat.style.borderLeftColor = '#c79a3a';
+ stat.style.background = '#fef7e0';
+ stat.textContent = 'Step 1/2 · Gemini Vision identifying the clean hero motif…';
+ const t0 = Date.now();
+ try {
+ const r = await fetch(`/api/design/${item.id}/smart-fix`, { method:'POST' });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ stat.style.borderLeftColor = 'var(--green)';
+ stat.style.background = '#eef7ed';
+ stat.innerHTML = `✓ Smart-fixed in ${dt}s · new design <a href="/design/${j.new_id}" target="_blank" style="color:var(--green); font-weight:600">#${j.new_id}</a><br>` +
+ `<span style="font:11px var(--sans); color:var(--faint)">motif: ${(j.motif || '').slice(0, 180)}…</span>`;
+ toast(`🎯 smart-fixed → #${j.new_id} (${dt}s)`);
+ mainImg.src = `/designs/img/by-id/${j.new_id}?smart=1`;
+ const verdict = 'tp_other';
+ try {
+ await fetch('/api/ghost-review/label', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ id: item.id, verdict, crops: [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `smart-fix → #${j.new_id} · ${(j.motif||'').slice(0,200)}` }),
+ });
+ } catch {}
+ STAT.other = (STAT.other || 0) + 1;
+ LABELS.set(item.id, { verdict, crops: [], smartfix_to: j.new_id });
+ updateStats();
+ 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);
+ }, 3500);
+ } catch (e) {
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ stat.style.borderLeftColor = 'var(--red)';
+ stat.style.background = '#fce8e6';
+ stat.innerHTML = `✗ Smart-fix failed after ${dt}s: ${e.message}`;
+ toast('smart-fix failed: ' + e.message);
+ } finally {
+ btn.disabled = false;
+ btn.innerHTML = '🎯 Smart Fix · auto-detect clean motif → re-tile <kbd>A</kbd>';
+ }
+ }
+
// ── Reverse-engineer + regenerate whole image ─────────────────────────
$('btn-regen').onclick = () => doRegen();
async function doRegen() {
@@ -1780,6 +2026,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 === 'a') { doSmartFix(); }
else if (k === 'w') { toggleWand(); }
});
diff --git a/server.js b/server.js
index fbd6cb7..a5315ae 100644
--- a/server.js
+++ b/server.js
@@ -943,6 +943,34 @@ app.get('/admin/ghost-review', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin', 'ghost-review.html'));
});
+// GET /api/ghost-review/design/:id — fetch ANY design's review metadata,
+// even if it's not in the ghost-flagged set. Lets the viewer open arbitrary
+// design ids that Steve found "ugly but not detected" so he can run the
+// SHIFT-keep + Surgical Fix workflow on them.
+app.get('/api/ghost-review/design/:id', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ try {
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, category, is_published FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`);
+ if (!raw || raw.length < 3) return res.status(404).json({ error: 'design not found' });
+ const row = JSON.parse(raw);
+ res.json({
+ ok: true,
+ item: {
+ id: row.id,
+ category: row.category || '',
+ confidence: 0,
+ reason: 'opened manually — not in the auto-flagged set',
+ image_url: `/designs/img/by-id/${row.id}`,
+ ad_hoc: true,
+ },
+ });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/ghost-review/flagged', (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
const f = path.join(__dirname, 'data', 'ghost-scan-flagged.jsonl');
@@ -1018,6 +1046,114 @@ app.post('/api/ghost-review/label', express.json({ limit: '64kb' }), (req, res)
}
});
+// Bulk soft-delete from the Ghost Review UI. For each id:
+// 1. Pull prompt + motifs + category + local_path from PG
+// 2. Append to data/bad-aesthetic-patterns.jsonl (learning file the next
+// generation build reads to avoid the bad aesthetic)
+// 3. Move PNG data/generated/<file> → data/generated_ghost_quarantine/<file>
+// 4. UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id IN (...)
+// 5. Append to data/ghost-purge.jsonl (reversible via soft-delete --restore)
+app.post('/api/ghost-review/bulk-delete', express.json({ limit: '512kb' }), (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(n => Number.isFinite(n)) : [];
+ const learn = req.body?.learn !== false;
+ if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+ if (ids.length > 2000) return res.status(400).json({ error: 'max 2000 ids per call' });
+
+ const ROOT = __dirname;
+ const GEN_DIR = path.join(ROOT, 'data', 'generated');
+ const QUARANTINE = path.join(ROOT, 'data', 'generated_ghost_quarantine');
+ const PURGE_LOG = path.join(ROOT, 'data', 'ghost-purge.jsonl');
+ const LEARN_LOG = path.join(ROOT, 'data', 'bad-aesthetic-patterns.jsonl');
+ try { fs.mkdirSync(QUARANTINE, { recursive: true }); } catch {}
+
+ const idList = ids.join(',');
+ let rows;
+ try {
+ const raw = psqlQuery(
+ `SELECT row_to_json(t) FROM (
+ SELECT id, category, local_path, prompt, motifs, is_published
+ FROM spoon_all_designs WHERE id IN (${idList})
+ ) t;`
+ );
+ rows = raw.split('\n').filter(Boolean).map(l => JSON.parse(l));
+ } catch (e) {
+ return res.status(500).json({ error: 'pg lookup failed', detail: e.message });
+ }
+ if (!rows.length) return res.status(404).json({ error: 'no matching rows' });
+
+ const ts = new Date().toISOString();
+ let moved = 0, alreadyMoved = 0, missingPng = 0, salvaged = 0;
+ const unpublishedIds = [];
+ for (const r of rows) {
+ // 1. Learning file — preserve prompt + motifs even after delete
+ if (learn && r.prompt) {
+ try {
+ fs.appendFileSync(LEARN_LOG, JSON.stringify({
+ id: r.id, category: r.category, prompt: r.prompt,
+ motifs: r.motifs, reason: 'ghost-layer (bulk-deleted from /admin/ghost-review)',
+ ts,
+ }) + '\n');
+ salvaged++;
+ } catch {}
+ }
+ // 2. Move PNG → quarantine
+ let originalPath = null, quarantinePath = null, hadPng = false;
+ if (r.local_path) {
+ originalPath = r.local_path;
+ quarantinePath = path.join(QUARANTINE, path.basename(r.local_path));
+ try {
+ if (fs.existsSync(originalPath)) {
+ fs.renameSync(originalPath, quarantinePath);
+ moved++; hadPng = true;
+ } else if (fs.existsSync(quarantinePath)) {
+ alreadyMoved++; hadPng = true;
+ } else {
+ missingPng++;
+ }
+ } catch (e) { missingPng++; }
+ } else {
+ missingPng++;
+ }
+ // 3. Audit log entry
+ try {
+ fs.appendFileSync(PURGE_LOG, JSON.stringify({
+ id: r.id, category: r.category, confidence: 1,
+ original_local_path: originalPath, quarantine_path: quarantinePath,
+ had_png: hadPng, source: 'ui-bulk-delete', ts,
+ }) + '\n');
+ } catch {}
+ unpublishedIds.push(r.id);
+ }
+
+ // 4. PG unpublish — chunked, idempotent (works even if already unpublished)
+ for (let i = 0; i < unpublishedIds.length; i += 500) {
+ const chunk = unpublishedIds.slice(i, i + 500);
+ try {
+ psqlQuery(
+ `UPDATE spoon_all_designs
+ SET is_published=FALSE, user_removed=TRUE
+ WHERE id IN (${chunk.join(',')});`
+ );
+ } catch (e) {
+ return res.status(500).json({ error: 'pg update failed', detail: e.message, partial: { moved, salvaged } });
+ }
+ }
+
+ res.json({
+ ok: true,
+ requested: ids.length,
+ matched: rows.length,
+ unpublished: unpublishedIds.length,
+ unpublished_ids: unpublishedIds,
+ moved_to_quarantine: moved,
+ already_in_quarantine: alreadyMoved,
+ missing_png: missingPng,
+ salvaged,
+ learn_log: learn ? '/data/bad-aesthetic-patterns.jsonl' : null,
+ });
+});
+
// Trigger another generation from the same seed (admin-only). Spawns the
// generator as a detached child so the HTTP response returns fast.
app.post('/api/admin/inspirations/regenerate', express.json(), (req, res) => {
@@ -1183,26 +1319,50 @@ app.get('/designs/img/by-id/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(404).end();
let d = DESIGNS.find(x => x.id === id);
- // Cold-cache fallback: designs created via crop-fix / regenerate-reverse /
- // save-edited / magic-wand land in PG but aren't in the in-memory DESIGNS
- // array until reload. Lazy-lookup via PG so the Fixes Feed AFTER side
- // resolves immediately, not after the next server reboot.
- if (!d) {
+ // Cold-cache + PII-sanitized fallback:
+ // - DESIGNS may not contain the id (created post-startup via crop-fix etc.)
+ // - DESIGNS may contain the id but WITHOUT local_path (designs.json is
+ // PII-sanitized — commit 6dbeb3b dropped local_path from the snapshot).
+ // In either case, fetch the live local_path from PG so the resolution chain
+ // below can find the file. ALSO fires when DESIGNS has the id but missing
+ // local_path — otherwise the entire ghost-flagged set 404s.
+ if (!d || !d.local_path) {
try {
const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, image_url, local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`);
if (raw && raw.length > 2) {
const row = JSON.parse(raw);
- if (row && row.local_path && fs.existsSync(row.local_path)) {
- DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
- d = DESIGNS[DESIGNS.length - 1];
+ if (row && row.local_path) {
+ if (d) {
+ // Patch the existing in-memory record so subsequent hits are fast
+ d.local_path = row.local_path;
+ if (!d.image_url) d.image_url = row.image_url;
+ } else {
+ DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
+ d = DESIGNS[DESIGNS.length - 1];
+ }
}
}
} catch { /* non-fatal */ }
}
if (!d) return res.status(404).end();
- const finalPath = localPathForDesign(d) || d.local_path;
- if (!finalPath || !fs.existsSync(finalPath)) return res.status(404).end();
- res.sendFile(finalPath);
+ // Resolution chain (try in order):
+ // 1. localPathForDesign helper (PII-free filename → IMG_DIR/file)
+ // 2. d.local_path (DB column, may point at the original generated/ path)
+ // 3. quarantine path (data/generated_ghost_quarantine/<basename>) — for
+ // flagged designs that were soft-deleted via scripts/soft-delete-ghost-
+ // flagged.js; the PNG still exists, just moved.
+ const candidates = [];
+ const lp1 = localPathForDesign(d); if (lp1) candidates.push(lp1);
+ if (d.local_path) candidates.push(d.local_path);
+ if (d.local_path) {
+ const base = path.basename(d.local_path);
+ candidates.push(path.join(__dirname, 'data', 'generated_ghost_quarantine', base));
+ }
+ if (process.env.BY_ID_DEBUG) console.log('[by-id]', id, 'candidates:', candidates.map(p => `${p} (${fs.existsSync(p) ? 'OK' : 'MISS'})`));
+ for (const p of candidates) {
+ if (p && fs.existsSync(p)) return res.sendFile(p);
+ }
+ return res.status(404).end();
});
app.post('/api/merge', mergeUpload.array('uploads', 3), async (req, res) => {
@@ -7155,6 +7315,256 @@ RETURNING id`);
}
});
+// POST /api/design/:id/smart-fix — autopilot clean-up.
+// 1. Gemini Vision identifies the cleanest hero motif (or set of opaque elements) in the source
+// 2. Gemini Vision also samples the dominant background/ground tone
+// 3. Gemini-2.5-flash-image regenerates the design as a CLEAN SEAMLESS TILE of just
+// that motif on the ground tone, with the explicit constraint that edges must repeat
+// 4. Insert new row, unpublish source, log fix event
+app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), 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' });
+ 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' });
+ if (!d.local_path || !fs.existsSync(d.local_path)) {
+ return res.status(404).json({ error: 'source PNG missing on disk' });
+ }
+ const srcB64 = fs.readFileSync(d.local_path).toString('base64');
+
+ // Step 1: identify the OPAQUE hero motif + ground tone.
+ // Crucial: ask vision to MENTALLY ERASE anything translucent before describing,
+ // so the resulting description never carries the defect forward into the gen prompt.
+ const detectPrompt = (
+ `Look at this wallpaper image. MENTALLY ERASE everything that is faded, translucent, semi-transparent, ghosted, or below ~90% opacity.\n\n` +
+ `What remains: only the sharpest, most fully-opaque elements — the cleanest hero motif and the solid background.\n\n` +
+ `Now describe ONLY what remains after that mental erasure:\n` +
+ `- motif_description: 2-3 sentences describing the hero motif you kept — its subject, colors, posture, scale, framing, painting style. NEVER mention any translucent / ghost / faded elements; pretend they don't exist.\n` +
+ `- ground_hex: the solid background color hex (where there's no motif)\n` +
+ `- ground_description: short phrase like 'sage green' or 'warm cream'\n\n` +
+ `Reply with ONLY this JSON, no markdown:\n` +
+ `{"motif_description":"<...>", "ground_hex":"<#RRGGBB>", "ground_description":"<...>"}`
+ );
+ const t0 = Date.now();
+ let detectJ;
+ try {
+ detectJ = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inline_data: { mime_type: 'image/png', data: srcB64 } },
+ { text: detectPrompt },
+ ],
+ note: 'smart-fix-detect-' + id,
+ });
+ } catch (e) {
+ if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+ return res.status(502).json({ error: 'detect call failed: ' + e.message });
+ }
+ let detect;
+ try { detect = JSON.parse(((geminiText(detectJ) || '').match(/\{[\s\S]*\}/) || [''])[0]); }
+ catch { return res.status(502).json({ error: 'detect returned non-JSON: ' + (geminiText(detectJ) || '').slice(0, 200) }); }
+ if (!detect || !detect.motif_description) {
+ return res.status(502).json({ error: 'detect returned no motif description' });
+ }
+
+ // Step 2: generate as a clean seamless tile of just the opaque motif.
+ // POSITIVE LANGUAGE ONLY — negative anchors like "no ghost" have been
+ // observed to make Gemini latch onto the very thing we want to avoid.
+ function buildGenPrompt(motif, ground_desc, ground_hex, attempt) {
+ const variants = [
+ `A flat screenprint wallpaper. Hand-cut from solid vinyl. The motif: ${motif} Set against a uniform ${ground_desc || 'solid'} (${ground_hex || '#fbf8f1'}) background that has zero texture and zero gradient. Every motif pixel is a fully saturated solid color (alpha 1.0); every background pixel is the exact same ${ground_desc || 'background'} tone (alpha 1.0). Hard razor-sharp silhouette edges. Seamless tile — left edge matches right edge, top edge matches bottom edge.`,
+ `Stencil-cut wallpaper tile. ${motif} Single flat ink layer on a clean ${ground_desc || ''} (${ground_hex || ''}) ground. Every shape is filled in one solid color from edge to edge, like a vector silhouette. Edges crisp. Pattern wraps seamlessly when tiled in a 2x2 grid.`,
+ `Cut-paper collage wallpaper. ${motif} Mounted on plain ${ground_desc || ''} (${ground_hex || ''}) backdrop. Each motif is one solid color, fully opaque, hand-cut. Tileable repeat — the right edge is the continuation of the left edge.`,
+ ];
+ return variants[attempt % variants.length];
+ }
+ function pixelOpaqueGuarantee(motif, ground_desc, ground_hex) {
+ // verify-loop helper, not the gen prompt itself
+ return `Wallpaper of ${motif} on a ${ground_desc || ''} ${ground_hex || ''} background, every pixel solid color, single-layer flat screenprint, seamless tile.`;
+ }
+
+ // Verify helper: does the rendered PNG contain any translucent layers?
+ async function verifyOpacity(b64Result) {
+ const verifyPrompt = (
+ `Inspect this wallpaper image for OPACITY DEFECTS.\n\n` +
+ `Opacity defects = any motif appearing more than once at different transparency levels, faded duplicate silhouettes, semi-transparent ghost overlays, watermark-like translucent copies, halo echoes around motifs, atmospheric haze where there should be solid color.\n\n` +
+ `Reply with ONLY this JSON (no markdown):\n` +
+ `{"has_translucent_layers": true|false, "confidence": <0..1>, "what_you_see": "<short explanation>"}`
+ );
+ const j = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inline_data: { mime_type: 'image/png', data: b64Result } },
+ { text: verifyPrompt },
+ ],
+ note: 'smart-fix-verify-' + id,
+ });
+ try {
+ const m = (geminiText(j) || '').match(/\{[\s\S]*\}/);
+ return m ? JSON.parse(m[0]) : { has_translucent_layers: true, confidence: 0.5, what_you_see: 'parse fail' };
+ } catch {
+ return { has_translucent_layers: true, confidence: 0.5, what_you_see: 'parse fail' };
+ }
+ }
+
+ let data = null, lastVerify = null, attempts = 0;
+ const MAX_ATTEMPTS = 3;
+ while (attempts < MAX_ATTEMPTS) {
+ const genPrompt = buildGenPrompt(detect.motif_description, detect.ground_description, detect.ground_hex || d.dominant_hex || '#fbf8f1', attempts);
+ let imgJ;
+ try {
+ imgJ = await geminiCall({
+ model: 'gemini-2.5-flash-image',
+ parts: [{ text: genPrompt }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ note: 'smart-fix-gen-' + id + '-att' + attempts,
+ });
+ } catch (e) {
+ attempts++;
+ if (attempts >= MAX_ATTEMPTS) return res.status(502).json({ error: 'image gen failed: ' + e.message });
+ continue;
+ }
+ const candidate = geminiImage(imgJ);
+ if (!candidate) { attempts++; continue; }
+ // Verify opacity
+ try {
+ lastVerify = await verifyOpacity(candidate);
+ } catch (e) {
+ lastVerify = { has_translucent_layers: false, confidence: 0, what_you_see: 'verify failed: ' + e.message };
+ }
+ if (!lastVerify.has_translucent_layers || lastVerify.confidence < 0.6) {
+ data = candidate;
+ break;
+ }
+ // Failed verify: try another phrasing
+ attempts++;
+ if (attempts >= MAX_ATTEMPTS) {
+ // Best effort — return last candidate with a flag, don't fail the request
+ data = candidate;
+ break;
+ }
+ }
+ if (!data) {
+ return res.status(502).json({ error: 'no candidate image after retries' });
+ }
+
+ // Save — then run a guaranteed-tileable post-process pass via PIL.
+ // Gemini's "tileable" claims aren't reliable; we force it programmatically.
+ // Algorithm: offset image by W/2 horizontally + H/2 vertically with wrap
+ // (Photoshop Offset filter) → feather-blend a small band at the new center
+ // seam cross → offset back. The result tiles perfectly; visible seam shifts
+ // to the (originally interior) center but is blended down to imperceptible.
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `smartfix_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ const rawPath = path.join(require('os').tmpdir(), `smartfix_raw_${id}_${Date.now()}.png`);
+ fs.writeFileSync(rawPath, Buffer.from(data, 'base64'));
+ const seamlessPy = require('child_process').spawnSync('python3', ['-c', `
+import sys
+from PIL import Image, ImageFilter
+src = Image.open(sys.argv[1]).convert('RGB')
+W, H = src.size
+# Step 1: Photoshop Offset (W/2 right, H/2 down) with wraparound
+shifted = Image.new('RGB', (W, H))
+shifted.paste(src.crop((W//2, H//2, W, H)), (0, 0))
+shifted.paste(src.crop((0, H//2, W//2, H)), (W - W//2, 0))
+shifted.paste(src.crop((W//2, 0, W, H//2)), (0, H - H//2))
+shifted.paste(src.crop((0, 0, W//2, H//2)), (W - W//2, H - H//2))
+# Step 2: feather-blend a band at the new center cross (where original outer edges meet)
+# Band width = max(8, min(W,H)//40). Blend uses a gaussian on a thin strip.
+band = max(8, min(W, H) // 40)
+cx, cy = W // 2, H // 2
+# Vertical band around x=cx
+strip = shifted.crop((cx - band, 0, cx + band, H))
+strip_blur = strip.filter(ImageFilter.GaussianBlur(radius=band // 2))
+# Mask: opaque at center, 0 at band edges
+mask = Image.new('L', strip.size, 0)
+for x in range(strip.size[0]):
+ d = abs(x - band) / band # 0 at center, 1 at edges
+ a = int(255 * (1 - d) ** 2)
+ for y in range(0, strip.size[1], 4):
+ for yy in range(min(4, strip.size[1] - y)):
+ mask.putpixel((x, y + yy), a)
+shifted.paste(strip_blur, (cx - band, 0), mask)
+# Horizontal band around y=cy
+strip2 = shifted.crop((0, cy - band, W, cy + band))
+strip2_blur = strip2.filter(ImageFilter.GaussianBlur(radius=band // 2))
+mask2 = Image.new('L', strip2.size, 0)
+for y in range(strip2.size[1]):
+ d = abs(y - band) / band
+ a = int(255 * (1 - d) ** 2)
+ for x in range(0, strip2.size[0], 4):
+ for xx in range(min(4, strip2.size[0] - x)):
+ mask2.putpixel((x + xx, y), a)
+shifted.paste(strip2_blur, (0, cy - band), mask2)
+# Step 3: Offset back (W/2, H/2) → original orientation
+out = Image.new('RGB', (W, H))
+out.paste(shifted.crop((cx, cy, W, H)), (0, 0))
+out.paste(shifted.crop((0, cy, cx, H)), (W - cx, 0))
+out.paste(shifted.crop((cx, 0, W, cy)), (0, H - cy))
+out.paste(shifted.crop((0, 0, cx, cy)), (W - cx, H - cy))
+out.save(sys.argv[2], 'PNG')
+print('OK seamless', W, H, 'band=', band)
+`, rawPath, outPath], { encoding: 'utf8' });
+ if (seamlessPy.status !== 0) {
+ // Post-process failed; fall back to raw
+ console.warn('[smart-fix] seamlessify failed:', (seamlessPy.stderr || '').slice(0, 200));
+ fs.copyFileSync(rawPath, outPath);
+ }
+ try { fs.unlinkSync(rawPath); } catch {}
+
+ 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 || 'seamless_tile')}, 'wallco.ai',
+ ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+ 'gemini-2.5-flash-image-smart-fix',
+ ${esc(`Smart-fix of #${id} (auto-detected hero motif): ${(detect.motif_description || '').slice(0, 1500)}`)},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(detect.ground_hex || 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}`);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ appendFixEvent({ kind: 'smart-fix', src_id: id, new_id: newId, elapsed_s: elapsed,
+ motif: (detect.motif_description || '').slice(0, 300),
+ ground_hex: detect.ground_hex,
+ attempts: attempts + 1,
+ verify: lastVerify });
+ return res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed,
+ motif: detect.motif_description, ground_hex: detect.ground_hex,
+ attempts: attempts + 1, verify: lastVerify });
+ } catch (e) {
+ console.error('[smart-fix]', 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);
← 2d3c47c ghost-detector: bump 429 retry budget 4→6 attempts
·
back to Wallco Ai
·
fix(regenerate-reverse): fall back to stored prompt when sou 06ad933 →