← back to Wallco Ai
ghost-review: Fix / Remove+Fill / Reverse-Regenerate action buttons + expanded sort
725d76328513378973f2afc8b77163e6cfa4dbc2 · 2026-05-23 23:17:09 -0700 · Steve Abrams
VIEWER — three new Repair Action buttons in the modal right panel,
placed prominently ABOVE the verdict actions since "mark then fix" is
the natural flow:
🪄 Surgical Fix (R) — bbox of marked rects to Gemini image-edit,
regenerates that region only · ~10s
🗑 Remove + Fill (D) — erases marked regions and fills back in
with the surrounding background pattern · ~10s
↻ Reverse-Engineer + Regenerate Whole (E)
— Gemini Vision describes the image as a
clean prompt, then re-rolls the whole
thing · ~15s · prompts to confirm
All three:
- auto-save a tp_* label before firing so the action is recorded
- swap the modal image to the new design on success (badge link to /design/:new_id)
- auto-advance to next unlabeled after 3-3.5s
SERVER:
- New `remove-fill` fix_kind in /api/design/:id/crop-fix with explicit
"erase + fill with background" prompt (no new motifs)
- New POST /api/design/:id/regenerate-reverse:
Step 1: gemini-2.0-flash Vision describes the image as a CLEAN
generation prompt (no ghosts, no translucent dupes, flat)
Step 2: gemini-2.5-flash-image generates fresh from that prompt
Step 3: insert new design, unpublish original, return new_id + new_prompt
SORT — expanded the dropdown beyond confidence:
- Category A-Z (group walk by category)
- By verdict (unlabeled first)
- Reason length (long = detector uncertain)
Live: http://127.0.0.1:9877/admin/ghost-review (hard-reload to pick up)
Files touched
M public/admin/ghost-review.htmlM server.js
Diff
commit 725d76328513378973f2afc8b77163e6cfa4dbc2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 23:17:09 2026 -0700
ghost-review: Fix / Remove+Fill / Reverse-Regenerate action buttons + expanded sort
VIEWER — three new Repair Action buttons in the modal right panel,
placed prominently ABOVE the verdict actions since "mark then fix" is
the natural flow:
🪄 Surgical Fix (R) — bbox of marked rects to Gemini image-edit,
regenerates that region only · ~10s
🗑 Remove + Fill (D) — erases marked regions and fills back in
with the surrounding background pattern · ~10s
↻ Reverse-Engineer + Regenerate Whole (E)
— Gemini Vision describes the image as a
clean prompt, then re-rolls the whole
thing · ~15s · prompts to confirm
All three:
- auto-save a tp_* label before firing so the action is recorded
- swap the modal image to the new design on success (badge link to /design/:new_id)
- auto-advance to next unlabeled after 3-3.5s
SERVER:
- New `remove-fill` fix_kind in /api/design/:id/crop-fix with explicit
"erase + fill with background" prompt (no new motifs)
- New POST /api/design/:id/regenerate-reverse:
Step 1: gemini-2.0-flash Vision describes the image as a CLEAN
generation prompt (no ghosts, no translucent dupes, flat)
Step 2: gemini-2.5-flash-image generates fresh from that prompt
Step 3: insert new design, unpublish original, return new_id + new_prompt
SORT — expanded the dropdown beyond confidence:
- Category A-Z (group walk by category)
- By verdict (unlabeled first)
- Reason length (long = detector uncertain)
Live: http://127.0.0.1:9877/admin/ghost-review (hard-reload to pick up)
---
public/admin/ghost-review.html | 236 ++++++++++++++++++++++++++++++++++++++++-
server.js | 123 +++++++++++++++++++++
2 files changed, 354 insertions(+), 5 deletions(-)
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index d97e076..03914ba 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -264,6 +264,17 @@
.panel .actions .b-ghost.gated,
.panel .actions .b-seam.gated,
.panel .actions .b-other.gated { opacity:.45; cursor:not-allowed; }
+ .panel .actions .b-fix { border-color:#3b78d8 !important; color:#3b78d8 !important; }
+ .panel .actions .b-fix:hover:not(.gated) { background:#3b78d8 !important; color:#fff !important; }
+ .panel .actions .b-fix.gated { opacity:.45; cursor:not-allowed; }
+ .panel .actions .b-fix.busy { background:#3b78d8 !important; color:#fff !important; cursor:wait; }
+ .panel .actions .b-regen { border-color:#7a4ab8; color:#7a4ab8; }
+ .panel .actions .b-regen:hover:not(.busy) { background:#7a4ab8; color:#fff; }
+ .panel .actions .b-regen.busy { background:#7a4ab8; color:#fff; cursor:wait; }
+ .panel .actions .b-remove { border-color:#c44b4b; color:#c44b4b; }
+ .panel .actions .b-remove:hover:not(.gated):not(.busy) { background:#c44b4b; color:#fff; }
+ .panel .actions .b-remove.gated { opacity:.45; cursor:not-allowed; }
+ .panel .actions .b-remove.busy { background:#c44b4b; color:#fff; cursor:wait; }
.panel .undo-row {
display:flex; gap:6px; font:11px var(--sans); color:var(--faint); align-items:center;
}
@@ -375,10 +386,19 @@
<select id="filter-cat"><option value="">all</option></select>
<label>Sort</label>
<select id="filter-sort">
- <option value="conf-desc">Confidence ↓</option>
- <option value="conf-asc">Confidence ↑</option>
- <option value="id-desc">Newest</option>
- <option value="id-asc">Oldest</option>
+ <optgroup label="Detector confidence">
+ <option value="conf-desc">Confidence ↓ (worst first)</option>
+ <option value="conf-asc">Confidence ↑</option>
+ </optgroup>
+ <optgroup label="Age">
+ <option value="id-desc">Newest</option>
+ <option value="id-asc">Oldest</option>
+ </optgroup>
+ <optgroup label="Grouping">
+ <option value="cat-asc">Category A→Z (group by category)</option>
+ <option value="verdict">By verdict (unlabeled first)</option>
+ <option value="reason-len">Reason length (long = uncertain)</option>
+ </optgroup>
</select>
<label>Density</label>
<input type="range" id="density" min="120" max="360" step="20" value="200">
@@ -470,7 +490,26 @@
<button id="clear-btn">clear all</button>
</div>
- <h3>Verdict</h3>
+ <h3 style="color:var(--ink); font-size:12px;">⚡ Repair Actions</h3>
+ <div class="actions">
+ <button class="b-fix gated" id="btn-fix">
+ 🪄 Surgical Fix · marked regions <kbd>R</kbd>
+ </button>
+ <button class="b-remove gated" id="btn-remove">
+ 🗑 Remove + Fill · erase marked regions <kbd>D</kbd>
+ </button>
+ <button class="b-regen" id="btn-regen">
+ ↻ Reverse-Engineer + Regenerate Whole <kbd>E</kbd>
+ </button>
+ </div>
+ <div style="font:11px var(--sans); color:var(--faint); margin-top:-4px">
+ <b>Fix</b>: bbox of marked rects → Gemini image-edit, regenerates that area · ~10s<br>
+ <b>Remove</b>: erase the marked regions and fill in with surrounding pattern · ~10s<br>
+ <b>Regen</b>: Gemini Vision reverse-engineers a clean prompt → fresh roll · ~15s
+ </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>Verdict (saves to training labels)</h3>
<div class="actions">
<button class="b-fp" id="btn-fp">✓ Clean (false-positive) <kbd>F</kbd></button>
<button class="b-ghost gated" id="btn-ghost">⚠ Ghost-layer · <span id="bn-ghost">0</span> regions <kbd>G</kbd></button>
@@ -543,6 +582,15 @@
else if (SORT === 'conf-asc') VIEW.sort((a,b) => (a.confidence||0) - (b.confidence||0));
else if (SORT === 'id-desc') VIEW.sort((a,b) => b.id - a.id);
else if (SORT === 'id-asc') VIEW.sort((a,b) => a.id - b.id);
+ else if (SORT === 'cat-asc') VIEW.sort((a,b) => (a.category||'').localeCompare(b.category||'') || b.id - a.id);
+ else if (SORT === 'verdict') {
+ const rank = { undefined: 0, 'fp':3, 'tp_ghost':1, 'tp_seam':2, 'tp_other':4, 'skip':5 };
+ VIEW.sort((a,b) => {
+ const va = LABELS.get(a.id)?.verdict, vb = LABELS.get(b.id)?.verdict;
+ return (rank[va] || 0) - (rank[vb] || 0) || b.id - a.id;
+ });
+ }
+ else if (SORT === 'reason-len') VIEW.sort((a,b) => ((b.reason||'').length) - ((a.reason||'').length));
}
function updateStats() {
@@ -904,6 +952,8 @@
$('btn-ghost').classList.toggle('gated', gn === 0);
$('btn-seam').classList.toggle('gated', sn === 0);
$('btn-other').classList.toggle('gated', on === 0);
+ $('btn-fix').classList.toggle('gated', n === 0);
+ $('btn-remove').classList.toggle('gated', n === 0);
}
$('undo-btn').onclick = () => { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } };
@@ -1191,6 +1241,179 @@
$('btn-other').onclick = () => save('tp_other');
$('btn-skip').onclick = () => save('skip');
+ // ── Surgical Fix (Gemini image-edit on marked regions) ─────────────────
+ // Computes the union bounding box of all RECTS, maps from [0..1] →
+ // PERCENT [0..100], picks fix_kind from the dominant rect tag, hits the
+ // existing /api/design/:id/crop-fix endpoint. Auto-saves a tp_* label
+ // before firing so the action is recorded even if the fix succeeds and
+ // the original design becomes unpublished.
+ $('btn-fix').onclick = () => doFix();
+ async function doFix(kindOverride) {
+ if (CURRENT === null) return;
+ if (!RECTS.length) { toast('mark at least one region first'); return; }
+ const seamRects = RECTS.filter(r => r.view === 'seam-quadrant-swap');
+ if (seamRects.length && seamRects.length !== RECTS.length) {
+ toast('mixed seam-view + normal-view rects — exit seam test first or clear rects');
+ return;
+ }
+ if (seamRects.length) {
+ toast('seam-view fixes not yet supported — exit seam view first');
+ return;
+ }
+ const item = VIEW[CURRENT];
+ // Pick fix_kind by the most-marked tag OR explicit override (remove-fill)
+ const tagCounts = { ghost: 0, seam: 0, other: 0 };
+ RECTS.forEach(r => { tagCounts[r.tag] = (tagCounts[r.tag] || 0) + 1; });
+ const dominantTag = Object.entries(tagCounts).sort((a,b) => b[1] - a[1])[0][0];
+ const fixKind = kindOverride
+ || { ghost: 'ghost-layer', seam: 'overlap', other: 'composition' }[dominantTag];
+ const verdict = { ghost: 'tp_ghost', seam: 'tp_seam', other: 'tp_other' }[dominantTag];
+
+ // Union bounding box across all rects
+ let minX = 1, minY = 1, maxX = 0, maxY = 0;
+ for (const r of RECTS) {
+ if (r.x < minX) minX = r.x;
+ if (r.y < minY) minY = r.y;
+ if (r.x + r.w > maxX) maxX = r.x + r.w;
+ if (r.y + r.h > maxY) maxY = r.y + r.h;
+ }
+ // Inflate by 4% on each side so Gemini has context to blend
+ const PAD = 0.04;
+ minX = Math.max(0, minX - PAD); minY = Math.max(0, minY - PAD);
+ maxX = Math.min(1, maxX + PAD); maxY = Math.min(1, maxY + PAD);
+ const region = {
+ x: minX * 100,
+ y: minY * 100,
+ w: (maxX - minX) * 100,
+ h: (maxY - minY) * 100,
+ };
+
+ // 1) Save label first so the action is recorded even if fix fails or the
+ // original gets unpublished. crops keep the original per-rect coords.
+ try {
+ await fetch('/api/ghost-review/label', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ id: item.id, verdict, crops: RECTS, notes: `pre-fix snapshot · fix_kind=${fixKind}` }),
+ });
+ } catch {}
+
+ // 2) Fire the Gemini fix
+ const btn = $(fixKind === 'remove-fill' ? 'btn-remove' : 'btn-fix');
+ const stat = $('fix-status');
+ btn.classList.add('busy');
+ btn.classList.add('gated');
+ btn.innerHTML = fixKind === 'remove-fill' ? '🗑 Removing + filling… ~10s' : '🪄 Fixing… ~10s';
+ stat.style.display = '';
+ stat.textContent = `Sending region (${region.x.toFixed(1)}%, ${region.y.toFixed(1)}%, ${region.w.toFixed(1)}%, ${region.h.toFixed(1)}%) to Gemini for ${fixKind} edit…`;
+ const t0 = Date.now();
+ try {
+ const r = await fetch(`/api/design/${item.id}/crop-fix`, {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ region, fix_kind: fixKind }),
+ });
+ 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 = `✓ 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> · original #${item.id} unpublished`;
+ toast(`✓ fixed → #${j.new_id} (${dt}s)`);
+ // Reload the image to the fixed version (in case Steve wants to confirm)
+ mainImg.src = `/designs/img/by-id/${j.new_id}?fixed=1`;
+ // Update label stats — count as tp
+ STAT[verdict === 'tp_ghost' ? 'ghost' : verdict === 'tp_seam' ? 'seam' : 'other']++;
+ LABELS.set(item.id, { verdict, crops: RECTS, fixed_to: j.new_id });
+ updateStats();
+ // Auto-advance after 3s so Steve sees the fixed version briefly
+ setTimeout(() => {
+ if (FILTER === 'unlabeled') {
+ recomputeView(); render();
+ if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
+ if (VIEW.length === 0) { close(); return; }
+ open(CURRENT);
+ } else {
+ recomputeView(); render();
+ if (CURRENT < VIEW.length - 1) navigate(1);
+ }
+ }, 3000);
+ } catch (e) {
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ stat.style.borderLeftColor = 'var(--red)';
+ stat.style.background = '#fce8e6';
+ stat.innerHTML = `✗ Fix failed after ${dt}s: ${e.message}`;
+ toast('fix failed: ' + e.message);
+ btn.classList.remove('busy');
+ btn.classList.remove('gated');
+ btn.innerHTML = fixKind === 'remove-fill'
+ ? '🗑 Retry Remove+Fill <kbd>D</kbd>'
+ : '🪄 Retry Fix <kbd>R</kbd>';
+ }
+ }
+
+ // ── Remove + Fill (erase marked regions, inpaint background) ──────────
+ $('btn-remove').onclick = () => doFix('remove-fill');
+
+ // ── Reverse-engineer + regenerate whole image ─────────────────────────
+ $('btn-regen').onclick = () => doRegen();
+ async function doRegen() {
+ if (CURRENT === null) return;
+ const item = VIEW[CURRENT];
+ if (!confirm(`Re-roll #${item.id}? Gemini Vision will describe the image, then generate a clean version. ~15s.`)) return;
+ const btn = $('btn-regen');
+ const stat = $('fix-status');
+ btn.classList.add('busy');
+ btn.disabled = true;
+ btn.innerHTML = '↻ Reverse-engineering + regenerating… ~15s';
+ stat.style.display = '';
+ stat.style.borderLeftColor = '#7a4ab8';
+ stat.style.background = '#f3eaff';
+ stat.textContent = 'Gemini Vision: describing the existing image as a clean generation prompt…';
+ const t0 = Date.now();
+ try {
+ const r = await fetch(`/api/design/${item.id}/regenerate-reverse`, { 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 = `✓ Regenerated 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)">new prompt: ${(j.new_prompt || '').slice(0, 200)}…</span>`;
+ toast(`✓ regenerated → #${j.new_id} (${dt}s)`);
+ mainImg.src = `/designs/img/by-id/${j.new_id}?regen=1`;
+ // Save a tp_other label to record the action
+ 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: RECTS.length ? RECTS : [{x:0,y:0,w:1,h:1,tag:'other'}], notes: `regenerated whole → #${j.new_id}` }),
+ });
+ } catch {}
+ STAT.other = (STAT.other || 0) + 1;
+ LABELS.set(item.id, { verdict, crops: RECTS, regen_to: j.new_id });
+ updateStats();
+ setTimeout(() => {
+ if (FILTER === 'unlabeled') {
+ recomputeView(); render();
+ if (CURRENT >= VIEW.length) CURRENT = VIEW.length - 1;
+ if (VIEW.length === 0) { close(); return; }
+ open(CURRENT);
+ } else {
+ recomputeView(); render();
+ 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 = `✗ Regenerate failed after ${dt}s: ${e.message}`;
+ toast('regen failed: ' + e.message);
+ } finally {
+ btn.classList.remove('busy');
+ btn.disabled = false;
+ btn.innerHTML = '↻ Reverse-Engineer + Regenerate Whole <kbd>E</kbd>';
+ }
+ }
+
// ── Keyboard ───────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('open')) return;
@@ -1209,6 +1432,9 @@
else if (k === '2') { DRAW_MODE = 'seam'; updateModeButtons(); }
else if (k === '3') { DRAW_MODE = 'other'; updateModeButtons(); }
else if (k === 'x') { toggleSeamView(); }
+ 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(); }
});
// ── Boot ───────────────────────────────────────────────────────────────
diff --git a/server.js b/server.js
index 0d40fd7..5846766 100644
--- a/server.js
+++ b/server.js
@@ -6818,6 +6818,7 @@ print('OK', left, top, right, bottom)
'ghost-layer': 'There is a GHOST-LAYER artifact inside the red rectangle — faint duplicated motifs, ghosted edges, or layered bleed. Regenerate JUST that region in the same flat single-layer silhouette aesthetic as the rest of the image, using the same palette tones. No translucency, no fading, no second layer.',
'overlap': 'There is an OVERLAP / collision inside the red rectangle — two motifs are intersecting in a way that reads as a single confused mass. Separate the colliding shapes so each motif has its own clear silhouette boundary. Preserve the count of distinct subjects, just clean up the edges.',
'composition': 'The composition inside the red rectangle is off — too dense / too empty / wrong scale relative to the rest of the canvas. Regenerate that region only, matching the density and scale of the surrounding areas.',
+ 'remove-fill': 'ERASE everything inside the red rectangle and fill it back in with the BACKGROUND pattern / negative space that surrounds it. Use the same palette and the same flat single-layer aesthetic. Do NOT add any new motifs in the erased region — only the surrounding background, ground tone, and continuation of any pattern (e.g. continue stripes, continue plain field, continue ground texture). The result should look like the defective content was never there.',
})[fixKind] || 'Regenerate the area inside the red rectangle as a flat clean silhouette in the existing palette.';
const editPrompt = (
@@ -6894,6 +6895,128 @@ RETURNING id`);
}
});
+// POST /api/design/:id/regenerate-reverse — full re-roll via reverse-engineered prompt.
+// Step 1: gemini-2.0-flash Vision reads the source image and writes a CLEAN
+// generation prompt describing it (no ghost layers, no translucent
+// duplicates, single flat layer)
+// Step 2: gemini-2.5-flash-image generates a fresh image from that prompt
+// Step 3: insert as new design, unpublish original, return { new_id, new_prompt }
+app.post('/api/design/:id/regenerate-reverse', 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: Vision describes the design as a clean prompt
+ const describePrompt = (
+ `You are reverse-engineering a wallpaper-design generation prompt from this image.\n` +
+ `Write a 2-3 sentence prompt that another AI could use to generate a CLEAN version of this exact design.\n\n` +
+ `Capture: the motifs (what's in the design), their colors, the layout/composition, the overall style/era.\n\n` +
+ `CRITICAL: do NOT reproduce any visible defects. Specifically:\n` +
+ ` - NO ghost layers (no faded duplicates of the same motif at lower opacity)\n` +
+ ` - NO translucent overlays of the whole scene\n` +
+ ` - NO unwanted seam-breaks at the tile edges\n` +
+ ` - NO half-tones, NO atmospheric haze on flat designs\n` +
+ `Describe the INTENDED design — what the artist would have wanted — then add the directive:\n` +
+ `"Render as a flat single-layer screenprint with hard razor-sharp silhouette edges. Tileable repeat. No translucency, no duplicates, no fading, no atmospheric perspective."\n\n` +
+ `Reply with ONLY the prompt text. No preamble, no markdown.`
+ );
+ const t0 = Date.now();
+ let describeJ;
+ try {
+ describeJ = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inline_data: { mime_type: 'image/png', data: srcB64 } },
+ { text: describePrompt },
+ ],
+ note: 'regenerate-reverse-describe-' + 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: 'vision describe failed: ' + e.message });
+ }
+ const newPrompt = (geminiText(describeJ) || '').trim();
+ if (!newPrompt || newPrompt.length < 30) {
+ return res.status(502).json({ error: 'gemini vision returned empty/short prompt: ' + newPrompt.slice(0, 200) });
+ }
+
+ // Step 2: Generate fresh image from the reverse-engineered prompt
+ let imgJ;
+ try {
+ imgJ = await geminiCall({
+ model: 'gemini-2.5-flash-image',
+ parts: [{ text: newPrompt }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ note: 'regenerate-reverse-gen-' + id,
+ });
+ } catch (e) {
+ return res.status(502).json({ error: 'image gen failed: ' + e.message });
+ }
+ const data = geminiImage(imgJ);
+ if (!data) {
+ const text = geminiText(imgJ);
+ return res.status(502).json({ error: 'gemini returned no image: ' + (text || '').slice(0, 240) });
+ }
+
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `regenrev_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+
+ 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'},
+ 'gemini-2.5-flash-image-regen-reverse',
+ ${esc(`Reverse-regen of #${id}: ${newPrompt.slice(0, 1500)}`)},
+ ${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}`);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ return res.json({ ok: true, new_id: newId, new_prompt: newPrompt, elapsed_s: elapsed, src_id: id });
+ } catch (e) {
+ console.error('[regenerate-reverse]', 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);
← 47f7a9c fix(ghost-detector): kill ~36% false-positive flag rate on f
·
back to Wallco Ai
·
fix(db): psqlExecLocal multi-line SQL — JSON.stringify of ne 7eb0a50 →