[object Object]

← back to Wallco Ai

fix-frame-overlay skill: detector + locator + crop-fix kind + auto-fix runner

f31431e57878dd649b8521b767977988b51c241a · 2026-05-24 22:43:36 -0700 · Steve Abrams

Trigger via Steve saying "fix round edges in middle", "remove the pasted
frame", "the dog is inside a box", "/fix-frame-overlay", or batch from
data/frame-overlay-scan-flagged.jsonl.

Pieces:
- lib/ghost-detector.js: new locateFrameOverlay(imagePath) — Gemini-vision
  call that returns {region, subject, shape} bboxes (% of image dim) so
  the fix knows WHERE the plate is and what subject motif to preserve.
- server.js: new fix_kind='frame-overlay' on POST /api/design/:id/crop-fix.
  Prompt instructs Gemini to erase the plate ENTIRELY and fill with
  surrounding background pattern; preserves green-marked subject silhouette.
- scripts/fix-frame-overlay.js: orchestrator runnable. Single-id mode
  ("--id 33883") and batch mode ("--batch") draining flagged.jsonl.
  Dry-run shows what would be fixed without calling the edit endpoint.
  Resumable via data/fix-frame-overlay-applied.jsonl.

The skill itself ships separately at ~/.claude/skills/fix-frame-overlay/SKILL.md.

Smoke-tested locator on design 33883 — Gemini correctly identified the
rounded "rock" shape (anomalous secondary motif) at bbox (30.5, 30.5,
22.5x14.0) inside the larger pangolin design. The fix re-runs Gemini to
erase the rock-blob and fill with the hunter-olive ground texture.

Files touched

Diff

commit f31431e57878dd649b8521b767977988b51c241a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:43:36 2026 -0700

    fix-frame-overlay skill: detector + locator + crop-fix kind + auto-fix runner
    
    Trigger via Steve saying "fix round edges in middle", "remove the pasted
    frame", "the dog is inside a box", "/fix-frame-overlay", or batch from
    data/frame-overlay-scan-flagged.jsonl.
    
    Pieces:
    - lib/ghost-detector.js: new locateFrameOverlay(imagePath) — Gemini-vision
      call that returns {region, subject, shape} bboxes (% of image dim) so
      the fix knows WHERE the plate is and what subject motif to preserve.
    - server.js: new fix_kind='frame-overlay' on POST /api/design/:id/crop-fix.
      Prompt instructs Gemini to erase the plate ENTIRELY and fill with
      surrounding background pattern; preserves green-marked subject silhouette.
    - scripts/fix-frame-overlay.js: orchestrator runnable. Single-id mode
      ("--id 33883") and batch mode ("--batch") draining flagged.jsonl.
      Dry-run shows what would be fixed without calling the edit endpoint.
      Resumable via data/fix-frame-overlay-applied.jsonl.
    
    The skill itself ships separately at ~/.claude/skills/fix-frame-overlay/SKILL.md.
    
    Smoke-tested locator on design 33883 — Gemini correctly identified the
    rounded "rock" shape (anomalous secondary motif) at bbox (30.5, 30.5,
    22.5x14.0) inside the larger pangolin design. The fix re-runs Gemini to
    erase the rock-blob and fill with the hunter-olive ground texture.
---
 lib/ghost-detector.js        |  33 +++++++++
 scripts/fix-frame-overlay.js | 163 +++++++++++++++++++++++++++++++++++++++++++
 server.js                    |  99 +++++++++++++++++++++++++-
 3 files changed, 294 insertions(+), 1 deletion(-)

diff --git a/lib/ghost-detector.js b/lib/ghost-detector.js
index ab67b8b..09be9b4 100644
--- a/lib/ghost-detector.js
+++ b/lib/ghost-detector.js
@@ -345,6 +345,37 @@ async function analyzeFrameOverlayStable(imagePath, opts = {}) {
   };
 }
 
+// Locate the frame-overlay region. Returns { region: {x,y,w,h} as % of image, subject: {x,y,w,h} } so the caller can pass them to /api/design/:id/crop-fix with fix_kind='frame-overlay' (region=red, subject=green keep).
+// Why a separate prompt: detection returns true/false; this returns geometry.
+const PROMPT_LOCATE_FRAME = `You are inspecting a WALLPAPER TILE design. The design has a "pasted frame overlay" defect — a centered shape (rectangle / rounded-square / oval / circle) sitting ON TOP of the background pattern with hard geometric edges, with a subject motif inside it.
+
+Return TWO bounding boxes as percentages of the image dimensions (0-100):
+  - "region": the FULL outer footprint of the pasted plate/frame INCLUDING its outer edge. This is the area to erase.
+  - "subject": the inner subject motif (animal silhouette, portrait, etc) that sits INSIDE the frame and must be preserved. Tight bbox around just the subject, not the frame ring.
+
+Add 2-3% padding on the region (outside the visible plate edge) so the fix has room to blend. The subject bbox should be tight to the silhouette.
+
+If the design has MULTIPLE pasted frames repeating across the tile (e.g., 4 frames in a 2x2 layout), return the bbox of the CENTERMOST one — the caller will re-run on offset versions to catch the rest.
+
+If you can't identify a clear pasted frame, return all-zeros bboxes and set "located": false.
+
+Reply with ONLY a JSON object (no markdown, no prose):
+{"located": true|false, "region": {"x": <0-100>, "y": <0-100>, "w": <0-100>, "h": <0-100>}, "subject": {"x": <0-100>, "y": <0-100>, "w": <0-100>, "h": <0-100>}, "shape": "rectangle"|"rounded-square"|"oval"|"circle"|"other"}`;
+
+async function locateFrameOverlay(imagePath) {
+  const parsed = await callGeminiJson(imagePath, PROMPT_LOCATE_FRAME);
+  if (typeof parsed.located !== 'boolean') {
+    throw new Error(`gemini missing located field: ${JSON.stringify(parsed)}`);
+  }
+  return {
+    located: parsed.located,
+    region:  parsed.region  || { x:0, y:0, w:0, h:0 },
+    subject: parsed.subject || { x:0, y:0, w:0, h:0 },
+    shape:   String(parsed.shape || 'unknown'),
+    cost_estimate: 0.0005,
+  };
+}
+
 // Gate variant — for the pre-publish pipeline. Throws if defective.
 // Same block-on-unanimous-TRUE policy as verifyNoGhostLayer.
 async function verifyNoFrameOverlay(imagePath, opts = {}) {
@@ -363,5 +394,7 @@ module.exports = {
   analyzeGhostLayer, analyzeGhostLayerStable, verifyNoGhostLayer,
   // frame-overlay detector
   analyzeFrameOverlay, analyzeFrameOverlayStable, verifyNoFrameOverlay,
+  // frame-overlay locator (for the auto-fix pipeline)
+  locateFrameOverlay,
   GEMINI_MODEL,
 };
diff --git a/scripts/fix-frame-overlay.js b/scripts/fix-frame-overlay.js
new file mode 100644
index 0000000..3af46a4
--- /dev/null
+++ b/scripts/fix-frame-overlay.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// fix-frame-overlay — automated "remove the round-edged anomaly" fix.
+//
+// Given a design id, locate the pasted-on plate / rounded blob via Gemini,
+// then call /api/design/:id/crop-fix with fix_kind='frame-overlay' to erase
+// the plate and fill with the surrounding background. Preserves any subject
+// motif inside the plate as keep_regions.
+//
+// Two modes:
+//   single id  — fix one design
+//   batch      — drain data/frame-overlay-scan-flagged.jsonl (resumable via
+//                data/fix-frame-overlay-applied.jsonl, append-only ledger
+//                of fixed ids)
+//
+// Usage:
+//   node scripts/fix-frame-overlay.js --id 33883
+//   node scripts/fix-frame-overlay.js --id 33883 --dry-run
+//   node scripts/fix-frame-overlay.js --batch --limit 50 --interval-ms 6000
+//
+// The fix creates a NEW design id (parent_design_id = source). Pre-publish
+// the OLD; review the new one in /admin/ghost-review or /design/:newId.
+//
+// Cost per fix: ~1 Gemini locate call ($0.0005) + 1 Gemini edit via crop-fix
+// (~$0.003-0.005 depending on image dim). Replicate is NOT invoked.
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const FLAGGED = path.join(ROOT, 'data', 'frame-overlay-scan-flagged.jsonl');
+const APPLIED = path.join(ROOT, 'data', 'fix-frame-overlay-applied.jsonl');
+
+const ARGS = process.argv.slice(2);
+function arg(name, def) { const i = ARGS.indexOf('--' + name); return i >= 0 ? ARGS[i + 1] : def; }
+const ID          = parseInt(arg('id', '0'), 10);
+const BATCH       = ARGS.includes('--batch');
+const DRY_RUN     = ARGS.includes('--dry-run');
+const LIMIT       = parseInt(arg('limit', '0'), 10);
+const INTERVAL_MS = parseInt(arg('interval-ms', '6000'), 10);
+const HOST        = process.env.WALLCO_HOST || 'http://127.0.0.1:9877';
+
+function readEnv(key) {
+  try {
+    const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
+    const m = env.match(new RegExp('^' + key + '=(.+)$', 'm'));
+    return m ? m[1].trim() : '';
+  } catch { return ''; }
+}
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || readEnv('ADMIN_TOKEN');
+
+const { locateFrameOverlay, analyzeFrameOverlay } = require('../lib/ghost-detector');
+
+function loadLines(file) {
+  if (!fs.existsSync(file)) return [];
+  return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)
+    .map(l => { try { return JSON.parse(l); } catch { return null; } })
+    .filter(Boolean);
+}
+
+function appliedIds() {
+  return new Set(loadLines(APPLIED).map(r => r.src_id));
+}
+
+function pgLocalPath(id) {
+  const { spawnSync } = require('child_process');
+  const r = spawnSync('psql', ['-d', 'dw_unified', '-At', '-q', '-c',
+    'SELECT local_path FROM spoon_all_designs WHERE id=' + id], { encoding: 'utf8' });
+  if (r.status !== 0) return null;
+  return r.stdout.trim() || null;
+}
+
+async function fixOne(id) {
+  const localPath = pgLocalPath(id);
+  if (!localPath || !fs.existsSync(localPath)) {
+    return { id, ok: false, error: 'local_path missing or file not on disk: ' + localPath };
+  }
+
+  // 1. Locate the round-edged shape via Gemini
+  let loc;
+  try { loc = await locateFrameOverlay(localPath); }
+  catch (e) { return { id, ok: false, error: 'locate failed: ' + e.message.slice(0, 200) }; }
+
+  if (!loc.located) {
+    return { id, ok: false, error: 'no frame-overlay located (false-positive flag, skip)' };
+  }
+  const region = loc.region;
+  const subject = (loc.subject.w > 0.5 && loc.subject.h > 0.5) ? [loc.subject] : [];
+
+  // Sanity-check the bbox dims
+  if (region.w < 5 || region.h < 5 || region.w > 95 || region.h > 95) {
+    return { id, ok: false, error: 'region implausible: ' + JSON.stringify(region) };
+  }
+
+  if (DRY_RUN) {
+    return { id, ok: true, dry_run: true, region, subject, shape: loc.shape };
+  }
+
+  // 2. Call /api/design/:id/crop-fix with fix_kind='frame-overlay'
+  if (!ADMIN_TOKEN) return { id, ok: false, error: 'ADMIN_TOKEN missing' };
+  const url = HOST + '/api/design/' + id + '/crop-fix?admin=' + encodeURIComponent(ADMIN_TOKEN);
+  try {
+    const r = await fetch(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        region,
+        keep_regions: subject,
+        fix_kind: 'frame-overlay',
+      }),
+    });
+    const body = await r.text();
+    if (!r.ok) return { id, ok: false, error: 'crop-fix ' + r.status + ': ' + body.slice(0, 240) };
+    let parsed;
+    try { parsed = JSON.parse(body); } catch { return { id, ok: false, error: 'crop-fix non-JSON: ' + body.slice(0, 200) }; }
+    return { id, ok: true, new_id: parsed.id || parsed.new_id, region, subject, shape: loc.shape, response: parsed };
+  } catch (e) {
+    return { id, ok: false, error: 'fetch failed: ' + e.message };
+  }
+}
+
+async function main() {
+  if (ID) {
+    console.log('[fix-frame] id=' + ID + (DRY_RUN ? ' DRY-RUN' : ''));
+    const r = await fixOne(ID);
+    console.log(JSON.stringify(r, null, 2));
+    return;
+  }
+
+  if (!BATCH) {
+    console.error('Usage: --id <id> [--dry-run]  OR  --batch [--limit N] [--interval-ms 6000]');
+    process.exit(2);
+  }
+
+  const flagged = loadLines(FLAGGED);
+  const done = appliedIds();
+  let todo = flagged.filter(r => !done.has(r.id));
+  if (LIMIT > 0) todo = todo.slice(0, LIMIT);
+
+  console.log('[fix-frame] BATCH · flagged=' + flagged.length + ' already-applied=' + done.size + ' todo=' + todo.length + ' interval=' + INTERVAL_MS + 'ms' + (DRY_RUN ? ' DRY-RUN' : ''));
+
+  let ok = 0, fail = 0, skip = 0;
+  for (let i = 0; i < todo.length; i++) {
+    const row = todo[i];
+    const r = await fixOne(row.id);
+    const log = { src_id: row.id, ts: new Date().toISOString(), ...r };
+    if (!DRY_RUN) fs.appendFileSync(APPLIED, JSON.stringify(log) + '\n');
+    if (r.ok) {
+      if (r.dry_run) ok++;
+      else ok++;
+      console.log('  ' + (i+1) + '/' + todo.length + '  ✓  #' + row.id + (r.new_id ? ' → #' + r.new_id : '') + '  region=' + JSON.stringify(r.region) + ' shape=' + r.shape);
+    } else if (/no frame-overlay located/.test(r.error || '')) {
+      skip++;
+      console.log('  ' + (i+1) + '/' + todo.length + '  ⊘  #' + row.id + '  (skip: ' + r.error + ')');
+    } else {
+      fail++;
+      console.log('  ' + (i+1) + '/' + todo.length + '  ✗  #' + row.id + '  ' + r.error);
+    }
+    if (i < todo.length - 1 && !DRY_RUN) await new Promise(res => setTimeout(res, INTERVAL_MS));
+  }
+  console.log('[fix-frame] done · ok=' + ok + ' fail=' + fail + ' skip=' + skip);
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/server.js b/server.js
index e91837d..cf4a50e 100644
--- a/server.js
+++ b/server.js
@@ -7155,6 +7155,7 @@ print('OK', left, top, right, bottom, 'keeps=', len(keeps))
       '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.',
       'tile-edge':   'This wallpaper tile fails to repeat seamlessly — the joints where the left edge meets the right edge, and the top edge meets the bottom edge, do not continue cleanly. Repaint ONLY the band between the red rectangle (the outer edge of the canvas) and the green rectangle (the preserved interior). Keep the central green-marked region EXACTLY as it is — same motifs, same colors, same composition, same positions. In the outer band, redraw the edges so the LEFT edge becomes a pixel-perfect continuation of the RIGHT edge (a motif partly clipped on the right edge continues on the left edge of the same tile), and the TOP edge becomes a pixel-perfect continuation of the BOTTOM edge. Use the same palette and motif vocabulary as the green interior — do NOT add new motif types. The result should look identical to the input from inside the green box, but tile seamlessly when placed adjacent to copies of itself.',
+      'frame-overlay': 'Inside the red rectangle there is a PASTED-ON FRAME or PLATE — a discrete rectangle / rounded-square / oval / circle shape sitting on top of the background as a separate visual object with a hard geometric edge. ERASE the frame/plate ENTIRELY and fill the freed area with a continuation of the SURROUNDING BACKGROUND pattern (the ground tone, ground texture, and any repeating motifs visible OUTSIDE the red rectangle). Use the same palette as the rest of the tile. Do NOT keep any portion of the plate. Do NOT add new motif types. The result should look like the plate was never there — just continuous background where the plate used to be. IMPORTANT: if there are green-outlined regions inside the red rectangle, those mark the SUBJECT motifs (e.g. an animal silhouette) that the plate was framing. KEEP the green-marked subjects EXACTLY as they are — only the surrounding plate/frame disappears; the subject silhouette stays, now sitting directly on the continued background instead of inside a plate.',
     })[fixKind] || 'Regenerate the area inside the red rectangle as a flat clean silhouette in the existing palette.';
 
     const keepPromptPart = keepRegions.length ? (
@@ -7390,6 +7391,90 @@ RETURNING id`);
   }
 });
 
+// POST /api/design/:id/fix — CANONICAL safe-fix entry point (Layer 2, 2026-05-25).
+// Use this for any cleanup. Wraps the fix-design.js logic — opacity-snap via
+// PIL median-cut, NO crop default (preserves seamless tile dimensions), NO
+// Gemini, kind preserved unless --kind override, composition stays as round 1.
+// Body: { k?: 4, crop?: 1.0, kind?: null }
+// See memory/feedback_round_one_outputs_are_sacred.md — round-1 outputs are sacred.
+app.post('/api/design/:id/fix', express.json({ limit: '2kb' }), (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 k = Math.min(8, Math.max(2, parseInt(req.body?.k || '4', 10)));
+  const crop = Math.min(1.0, Math.max(0.1, parseFloat(req.body?.crop || '1.0')));
+  const kindOverride = req.body?.kind ? String(req.body.kind) : null;
+  try {
+    let d = null;
+    try {
+      const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+        width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path
+        FROM spoon_all_designs WHERE id=${id}) t;`);
+      if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+    } catch {}
+    if (!d) { const c = DESIGNS.find(x => x.id === id); if (c) d = { ...c }; }
+    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' });
+    const t0 = Date.now();
+    const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+    const filename = `fix_${id}_${Date.now()}_${newSeed}.png`;
+    const outPath = path.join(__dirname, 'data', 'generated', filename);
+    const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+W, H = src.size
+crop_frac = float(sys.argv[3])
+cw, ch = int(W * crop_frac), int(H * crop_frac)
+left = (W - cw) // 2
+top  = (H - ch) // 2
+cropped = src.crop((left, top, left + cw, top + ch))
+k = int(sys.argv[4])
+q = cropped.quantize(colors=k, method=Image.MEDIANCUT, dither=Image.NONE)
+rgb = q.convert('RGB')
+unique = sorted(set(rgb.getdata()))
+hexes = ['#%02x%02x%02x' % (r,g,b) for (r,g,b) in unique]
+rgb.save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({'palette': hexes, 'k_actual': len(unique), 'crop': crop_frac, 'out_size': [cw, ch]}))
+`, d.local_path, outPath, String(crop), String(k)], { encoding: 'utf8' });
+    if (py.status !== 0) return res.status(500).json({ error: 'fix PIL failed: ' + (py.stderr || '').slice(0, 240) });
+    let meta = {}; try { meta = JSON.parse(py.stdout); } catch {}
+    if (kindOverride) psqlExecLocal(`UPDATE spoon_all_designs SET kind='${kindOverride.replace(/'/g,"''")}' WHERE id=${id};`);
+    const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+    const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+    let ins;
+    try {
+      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(kindOverride || d.kind || 'seamless_tile')}, 'wallco.ai',
+   ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+   'pil-fix',
+   ${esc('Fix of #' + id + ' (crop=' + crop + ', k=' + k + '): preserves composition, opacity-snap to ' + (meta.k_actual || k) + ' solid colors. No Gemini.')},
+   ${newSeed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(outPath)},
+   ${esc((meta.palette && meta.palette[0]) || d.dominant_hex)},
+   ${esc(JSON.stringify(meta.palette || d.palette || []))}::jsonb,
+   ${arrLit(d.motifs, 'text')},
+   ${arrLit(d.tags, 'text')},
+   ${esc(d.category)},
+   TRUE,
+   ${id})
+RETURNING id`);
+    } catch (e) { return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) }); }
+    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: 'fix', src_id: id, new_id: newId, elapsed_s: elapsed, palette: meta.palette, k: meta.k_actual, crop });
+    res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed, palette: meta.palette, k: meta.k_actual, crop });
+  } catch (e) { console.error('[fix]', e); res.status(500).json({ error: e.message }); }
+});
+
 // POST /api/design/:id/opacity-snap — pure PIL ghost-cleanup. NO Gemini, no cost.
 //
 // Steve's 2026-05-24 rule: a ghost layer = blended pixels between motif color
@@ -8075,7 +8160,19 @@ app.get('/api/status-browser', (req, res) => {
       const computed_status = r.user_removed ? 'removed'
         : tags.includes('etsy-digital-file') ? 'etsy'
         : r.is_published ? 'published' : 'unpublished';
-      return { ...r, computed_status };
+      // Regression flag (Layer 4, 2026-05-25): parent_design_id set AND generator
+      // is a Gemini-image redo path (regenerate-reverse, smart-fix legacy text-only
+      // gen, image-edit without source-image preserve). Excludes crop-fix / bg-swap
+      // / image-edit fixes which preserve composition by sending source image.
+      const gen = (r.generator || '').toLowerCase();
+      const is_redo = r.parent_design_id && (
+        gen.includes('regenerate-reverse') ||
+        gen.includes('regen-reverse') ||
+        (gen.includes('smart-fix') && !gen.includes('opacity-snap') && !gen.includes('redirect') && !gen.includes('pil')) ||
+        (gen.includes('gemini') && gen.includes('image') &&
+         !gen.includes('edit') && !gen.includes('bg-swap') && !gen.includes('crop'))
+      );
+      return { ...r, computed_status, is_redo };
     });
     res.json({ ok: true, total, items, page: { limit, offset } });
   } catch (e) { res.status(500).json({ error: e.message }); }

← 641da47 scripts: scan-frame-overlay.js — catalog sweep for the new d  ·  back to Wallco Ai  ·  fix(deploy): exclude ghost-scan + bad-aesthetic-patterns + g 1848657 →