[object Object]

← back to Wallco Ai

seam-debug: click boxes to select + 'Heal selected' button (also 'Heal all'); POST /api/seam-debug/:id/heal runs targeted mid-band/edge-wrap heal via PIL, saves as NEW design w/ healed-from tag, returns before/after scan

c9af59b4c640f5f12afdbf395dcd1fc7c7731dd2 · 2026-05-28 06:38:23 -0700 · Steve Abrams

Files touched

Diff

commit c9af59b4c640f5f12afdbf395dcd1fc7c7731dd2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 06:38:23 2026 -0700

    seam-debug: click boxes to select + 'Heal selected' button (also 'Heal all'); POST /api/seam-debug/:id/heal runs targeted mid-band/edge-wrap heal via PIL, saves as NEW design w/ healed-from tag, returns before/after scan
---
 public/admin/drunk-curator.html |  21 +++--
 public/admin/seam-debug.html    |  44 ++++++++++-
 scripts/heal-seam-region.py     | 166 ++++++++++++++++++++++++++++++++++++++++
 server.js                       |  19 +++++
 4 files changed, 241 insertions(+), 9 deletions(-)

diff --git a/public/admin/drunk-curator.html b/public/admin/drunk-curator.html
index fef5d12..504b5b3 100644
--- a/public/admin/drunk-curator.html
+++ b/public/admin/drunk-curator.html
@@ -105,9 +105,9 @@
   .rruler.h .rl{top:5px;transform:translateX(-50%);} .rruler.v .rl{left:5px;transform:translateY(-50%);}
   /* manual "mark misaligned" highlighter — admin drags red boxes on the flat tile */
   .mboxes{position:absolute;inset:0;z-index:5;}
-  .mbox{position:absolute;border:2px solid #ff3b3b;background:rgba(255,59,59,.20);cursor:pointer;border-radius:2px;}
-  .mbox.drawing{background:rgba(255,59,59,.32);pointer-events:none;}
-  .mbox b{position:absolute;top:-1px;left:-1px;background:#ff3b3b;color:#fff;font:800 9px sans-serif;padding:1px 5px;border-radius:3px 0 3px 0;}
+  .mbox{position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.10);cursor:pointer;border-radius:2px;}
+  .mbox.drawing{background:rgba(255,59,59,.22);pointer-events:none;}
+  .mbox b{position:absolute;top:-2px;left:-2px;min-width:18px;text-align:center;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;}
   .mhint{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);z-index:6;pointer-events:none;background:rgba(0,0,0,.55);color:#ffd0d0;font:700 9px sans-serif;padding:2px 7px;border-radius:10px;white-space:nowrap;}
 </style>
 </head>
@@ -371,8 +371,11 @@ function drawMboxes(){
   let layer=document.getElementById('mboxes');
   if(!layer){ layer=document.createElement('div'); layer.id='mboxes'; layer.className='mboxes'; si.appendChild(layer); }
   const manual = curBoxes.filter(b=>!b.auto);
-  layer.innerHTML = manual.map(b=>`<div class="mbox" style="left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%" title="click to remove"><b>${(b.label||'misaligned').replace(/</g,'')}</b></div>`).join('')
-    + `<div class="mhint">click = enlarge · drag = mark misaligned · click a box to remove</div>`;
+  // sequential numbering (1, 2, 3...) by draw order — auto-renumbers on add/remove.
+  // The persisted label is preserved (notes the box's reason); the displayed number
+  // is purely positional so it stays right when boxes change.
+  layer.innerHTML = manual.map((b,k)=>`<div class="mbox" style="left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%" title="${(b.label||'wrong').replace(/"/g,'')} — click to remove"><b>${k+1}</b></div>`).join('')
+    + `<div class="mhint">click = enlarge · drag = number a wrong area · click a numbered box to remove</div>`;
   [...layer.querySelectorAll('.mbox')].forEach((el,k)=>el.addEventListener('click',ev=>{
     ev.stopPropagation(); const target=manual[k]; curBoxes=curBoxes.filter(b=>b!==target); saveBoxes(); drawMboxes();
   }));
@@ -395,7 +398,13 @@ function startDraw(e){
     const L=Math.max(0,Math.min(x0,x)),T=Math.max(0,Math.min(y0,y));
     const W=Math.min(1-L,Math.abs(x-x0)),H=Math.min(1-T,Math.abs(y-y0));
     ghost.remove();
-    if(W>0.02&&H>0.02){ curBoxes.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'misaligned',auto:false}); saveBoxes(); drawMboxes(); }
+    if(W>0.02&&H>0.02){
+      // label is the box's NUMBER at creation time (display will renumber on render,
+      // but storing the original gives a stable provenance string for the corpus).
+      const n = curBoxes.filter(b=>!b.auto).length + 1;
+      curBoxes.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'wrong-'+n,auto:false});
+      saveBoxes(); drawMboxes();
+    }
     else { openZoom(); }   // negligible drag = a plain click → open the larger view
   };
   document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index 357491f..4fd47be 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -27,10 +27,16 @@
   .seam-line.edge{background:rgba(74,163,255,.55);}
   .seam-line.v{width:2px;top:0;bottom:0;}
   .seam-line.h{height:2px;left:0;right:0;}
-  .box{position:absolute;border:2px solid var(--bad);background:rgba(255,77,77,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,77,77,.45);}
+  .box{position:absolute;border:2px solid var(--bad);background:rgba(255,77,77,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,77,77,.45);cursor:pointer;pointer-events:auto;}
   .box.edge{border-color:#ff9a3c;background:rgba(255,154,60,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,154,60,.45);}
+  .box.sel{border-color:#7bc96f;background:rgba(123,201,111,.32);box-shadow:0 0 0 2px #7bc96f inset, 0 0 18px rgba(123,201,111,.6);}
   .box .lbl{position:absolute;top:-18px;left:0;background:var(--bad);color:#fff;font:11px/1 ui-monospace,monospace;padding:2px 5px;border-radius:3px;white-space:nowrap;}
   .box.edge .lbl{background:#ff9a3c;color:#1a1a1a;}
+  .box.sel .lbl{background:#7bc96f;color:#0a0b0c;}
+  .overlay{pointer-events:none}            /* re-enable on .box only */
+  button.heal{background:#143b1d;color:#7bc96f;border:1px solid #2a6b35;border-radius:7px;padding:5px 11px;font-size:13px;cursor:pointer;font-weight:700}
+  button.heal:disabled{opacity:.4;cursor:not-allowed}
+  button.heal:not(:disabled):hover{background:#7bc96f;color:#0a0b0c;}
   aside{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;align-self:start;}
   aside h2{font-size:13px;margin:0 0 8px;letter-spacing:.05em;text-transform:uppercase;color:var(--mut);font-weight:600;}
   aside .row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px dashed var(--line);}
@@ -53,6 +59,8 @@
   <span class="pill" id="dims" style="font-size:12px;color:var(--mut)"></span>
   <label class="toggle"><input type="checkbox" id="t_box" checked>boxes</label>
   <label class="toggle"><input type="checkbox" id="t_seam" checked>seam-lines</label>
+  <button class="heal" id="healbtn" disabled title="click boxes to select, then heal only those regions">⚕ Heal selected (0)</button>
+  <button class="heal" id="healall" style="background:#3b2e08;color:#f0b400;border-color:#5a4710" title="heal every detected defect">⚕ Heal all</button>
   <a id="openraw" style="font-size:12px;color:var(--mut);text-decoration:none" target="_blank">open raw image ↗</a>
 </header>
 <div id="err" style="display:none"></div>
@@ -86,6 +94,8 @@ $('t_seam').addEventListener('change',redraw);
 addEventListener('keydown',e=>{ if(e.key==='Enter'&&document.activeElement===$('idin')) load(); });
 
 let CUR=null;
+let SEL=new Set();   // selected box indices for healing
+function updHeal(){ const n=SEL.size; const b=$('healbtn'); b.disabled=n===0; b.textContent=`⚕ Heal selected (${n})`; }
 
 function lensRow(name, obj){
   const score = obj.score!=null?obj.score:obj;
@@ -113,11 +123,13 @@ function redraw(){
   if($('t_box').checked){
     (CUR.boxes||[]).forEach((b,i)=>{
       const isEdge=/_edge$/.test(b.kind);
-      const d=document.createElement('div'); d.className='box'+(isEdge?' edge':'');
+      const d=document.createElement('div'); d.className='box'+(isEdge?' edge':'')+(SEL.has(i)?' sel':'');
       d.style.left=(b.x*sx)+'px'; d.style.top=(b.y*sy)+'px';
       d.style.width=Math.max(2,b.w*sx)+'px'; d.style.height=Math.max(2,b.h*sy)+'px';
       d.innerHTML=`<span class="lbl">${b.kind} · ΔE ${b.peak_de}</span>`;
-      d.title=`${b.kind}${b.edge_pair?' ['+b.edge_pair+']':''} · x=${b.x} y=${b.y} w=${b.w} h=${b.h} · peak ΔE ${b.peak_de} (mean ${b.mean_de})`;
+      d.title=`click to ${SEL.has(i)?'deselect':'select for healing'} · ${b.kind}${b.edge_pair?' ['+b.edge_pair+']':''} · x=${b.x} y=${b.y} w=${b.w} h=${b.h} · peak ΔE ${b.peak_de} (mean ${b.mean_de})`;
+      d.dataset.idx=i;
+      d.onclick=ev=>{ ev.stopPropagation(); if(SEL.has(i)) SEL.delete(i); else SEL.add(i); redraw(); updHeal(); };
       ovl.appendChild(d);
     });
   }
@@ -149,6 +161,32 @@ async function load(){
     addEventListener('resize',redraw);
   }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
 }
+async function healSelected(boxIds){
+  if(!boxIds.length){ alert('Click boxes to select what to heal.'); return; }
+  const id=$('idin').value.trim(); if(!id) return;
+  const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${boxIds.length}…`;
+  try{
+    const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds})});
+    const j=await r.json();
+    if(!r.ok||!j.ok){ alert('Heal failed: '+(j.error||r.status)+(j.stderr?'\n\n'+j.stderr:'')); btn.textContent=orig; updHeal(); return; }
+    // Show a side-by-side toast w/ scores + offer to load the new id
+    const sb=j.scan_before.scores, sa=j.scan_after.scores;
+    const delta=(k)=>{ const d=(sa[k]-sb[k]); return d<0?`<span style="color:var(--ok)">${sb[k].toFixed(1)}→${sa[k].toFixed(1)} (${d.toFixed(1)})</span>`:`<span style="color:#ff9a9a">${sb[k].toFixed(1)}→${sa[k].toFixed(1)} (+${d.toFixed(1)})</span>`; };
+    const t=document.createElement('div');
+    t.style.cssText='position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#143b1d;border:1px solid #2a6b35;color:#e9ecef;padding:14px 18px;border-radius:10px;z-index:120;font:13px ui-monospace,monospace;box-shadow:0 8px 32px rgba(0,0,0,.7);min-width:520px';
+    t.innerHTML=`<div style="font-weight:700;color:var(--ok);margin-bottom:6px">⚕ Healed ${j.healed_boxes} region(s) — new design #${j.new_id}</div>
+      <div>v_mid: ${delta('v_mid')} · h_mid: ${delta('h_mid')}</div>
+      <div>edges_max: ${delta('edges_max')} · verdict: ${j.scan_before.verdict} → ${j.scan_after.verdict}</div>
+      <div style="margin-top:10px;display:flex;gap:8px">
+        <button onclick="$('idin').value='${j.new_id}';location.search='?id=${j.new_id}'+(ADMIN?'&admin=${ADMIN}':'');" style="background:var(--ok);color:#0a0b0c;border:0;padding:5px 11px;border-radius:5px;cursor:pointer;font-weight:700">Open #${j.new_id}</button>
+        <button onclick="this.parentElement.parentElement.remove()" style="background:transparent;color:var(--mut);border:1px solid var(--line);padding:5px 11px;border-radius:5px;cursor:pointer">close</button>
+      </div>`;
+    document.body.appendChild(t);
+    SEL.clear(); redraw(); updHeal();
+  }catch(e){ alert('Heal failed: '+e.message); btn.textContent=orig; updHeal(); }
+}
+$('healbtn').addEventListener('click',()=>healSelected([...SEL]));
+$('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i)=>i)));
 if(ID0) load();
 </script>
 </body>
diff --git a/scripts/heal-seam-region.py b/scripts/heal-seam-region.py
new file mode 100644
index 0000000..7707423
--- /dev/null
+++ b/scripts/heal-seam-region.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""
+heal-seam-region — apply a targeted seam-heal on JUST the boxes the curator
+selected in /admin/seam-debug/<id>. Saves the healed image as a NEW design
+id (preserves the original) and re-scans so the viewer can show before/after.
+
+Why a NEW id: per project rule "Round-1 outputs are sacred" + heal pipelines
+have historically blurred a 12 px band that edges-agent is blind to
+([[feedback_wallco_heal_blur_band_edges_agent_blind]]). Side-by-side keeps
+the original recoverable if the heal regresses.
+
+Per-box heal strategy:
+  v_mid  — 32-px vertical band centered on x=W/2 over the box's y-range:
+           replace with the LR-averaged column from a 6-px window on each
+           side, then a 3-px gaussian to soften the joint.
+  h_mid  — same recipe, transposed (32-px horizontal band centered on y=H/2).
+  *_edge — circular-wrap heal: average the 16-px edge strip with its
+           opposite-edge mirror so the wrap is invisible when tiled.
+
+Usage:
+  python3 scripts/heal-seam-region.py --id 13201 --boxes '[0,3,5]'
+  python3 scripts/heal-seam-region.py --id 13201 --kinds v_mid,h_mid  # heal all of these
+Output: JSON {ok, src_id, new_id, new_path, scan_before, scan_after}.
+"""
+import argparse, json, os, shutil, subprocess, sys
+from pathlib import Path
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent))
+from importlib import import_module
+seam = import_module('seam-defect-boxes')  # reuse the scanner
+
+BLEND_PX = 6        # px on each side to AVERAGE across the seam
+BAND_PX = 32        # full heal-band width (centered on the seam line)
+GAUSS = 3           # gaussian sigma after band-replace
+
+def heal_band_mid(arr, axis, idx, start, end):
+    """axis='v' = vertical seam at x=idx, healing rows [start:end]
+       axis='h' = horizontal seam at y=idx, healing cols [start:end]"""
+    import numpy as np
+    H, W = arr.shape[:2]
+    if axis == 'v':
+        x0 = max(0, idx - BAND_PX // 2); x1 = min(W, idx + BAND_PX // 2)
+        # average column from 6 px L + 6 px R of the seam
+        left  = arr[start:end, max(0,idx-BLEND_PX-1):idx, :].mean(axis=1)
+        right = arr[start:end, idx:min(W,idx+BLEND_PX), :].mean(axis=1)
+        avg = ((left + right) / 2).round().astype('uint8')
+        # write the averaged column across the BAND_PX width
+        for x in range(x0, x1):
+            # cosine-window the band: full average at center, 0 weight at edges
+            w = 1 - abs(x - idx) / (BAND_PX / 2)
+            w = max(0, min(1, w))
+            band = avg[:, None, :].repeat(1, axis=1)[:, 0, :]   # (h, 3)
+            arr[start:end, x, :] = (band * w + arr[start:end, x, :] * (1 - w)).astype('uint8')
+    else:
+        y0 = max(0, idx - BAND_PX // 2); y1 = min(H, idx + BAND_PX // 2)
+        top    = arr[max(0,idx-BLEND_PX-1):idx, start:end, :].mean(axis=0)
+        bot    = arr[idx:min(H,idx+BLEND_PX),    start:end, :].mean(axis=0)
+        avg = ((top + bot) / 2).round().astype('uint8')
+        for y in range(y0, y1):
+            w = 1 - abs(y - idx) / (BAND_PX / 2)
+            w = max(0, min(1, w))
+            band = avg[None, :, :].repeat(1, axis=0)[0, :, :]   # (w, 3)
+            arr[y, start:end, :] = (band * w + arr[y, start:end, :] * (1 - w)).astype('uint8')
+
+def heal_edge_wrap(arr, kind, start, end):
+    """For edge wraps — average each edge strip with its mirror so the tile-
+    repeat boundary is invisible. kind∈{top,bottom,left,right}_edge."""
+    import numpy as np
+    H, W = arr.shape[:2]
+    S = 16
+    if kind in ('top_edge', 'bottom_edge'):
+        x0, x1 = start, end
+        top    = arr[0:S, x0:x1, :].astype('float32')
+        bottom = arr[H-S:H, x0:x1, :].astype('float32')
+        # bottom needs to be flipped so its "outer-most" row aligns with top's outer-most
+        bottom_f = bottom[::-1, :, :]
+        blend  = ((top + bottom_f) / 2).astype('uint8')
+        arr[0:S, x0:x1, :] = blend
+        arr[H-S:H, x0:x1, :] = blend[::-1, :, :]
+    elif kind in ('left_edge', 'right_edge'):
+        y0, y1 = start, end
+        left  = arr[y0:y1, 0:S, :].astype('float32')
+        right = arr[y0:y1, W-S:W, :].astype('float32')
+        right_f = right[:, ::-1, :]
+        blend  = ((left + right_f) / 2).astype('uint8')
+        arr[y0:y1, 0:S, :] = blend
+        arr[y0:y1, W-S:W, :] = blend[:, ::-1, :]
+
+def psql(sql):
+    r = subprocess.run(['psql','dw_unified','-At','-q','-c',sql],
+                       capture_output=True, text=True, timeout=15)
+    if r.returncode != 0: raise RuntimeError(r.stderr)
+    return r.stdout.strip()
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--id', type=int, required=True)
+    ap.add_argument('--boxes', help='JSON list of box indices to heal')
+    ap.add_argument('--kinds', help='comma-separated kinds to heal (v_mid,h_mid,top_edge,...)')
+    args = ap.parse_args()
+
+    # 1. Pull the source design + scan it
+    scan = seam.scan_id(args.id)
+    if not scan.get('ok'): print(json.dumps({'ok': False, 'error': scan.get('error')})); sys.exit(1)
+    src_path = scan['path']
+    boxes_all = scan['boxes']
+
+    # 2. Filter boxes
+    if args.boxes:
+        idxs = json.loads(args.boxes)
+        sel = [boxes_all[i] for i in idxs if 0 <= i < len(boxes_all)]
+    elif args.kinds:
+        wanted = set(args.kinds.split(','))
+        sel = [b for b in boxes_all if b['kind'] in wanted]
+    else:
+        sel = boxes_all
+
+    if not sel: print(json.dumps({'ok': False, 'error': 'no boxes selected'})); sys.exit(1)
+
+    # 3. Load + heal in numpy
+    from PIL import Image, ImageFilter
+    import numpy as np
+    img = Image.open(src_path).convert('RGB')
+    arr = np.asarray(img).copy()
+    H, W = arr.shape[:2]
+
+    for b in sel:
+        k = b['kind']
+        x, y, w, h = b['x'], b['y'], b['w'], b['h']
+        if k == 'v_mid':
+            heal_band_mid(arr, 'v', W // 2, y, y + h)
+        elif k == 'h_mid':
+            heal_band_mid(arr, 'h', H // 2, x, x + w)
+        elif k in ('top_edge', 'bottom_edge', 'left_edge', 'right_edge'):
+            heal_edge_wrap(arr, k, x, x + w) if 'top' in k or 'bottom' in k else heal_edge_wrap(arr, k, y, y + h)
+
+    # 4. Save as a NEW design id — copy all metadata, swap local_path
+    new_filename = Path(src_path).stem + f'_healed.png'
+    new_path = str(Path(src_path).parent / new_filename)
+    Image.fromarray(arr).save(new_path, optimize=True)
+
+    new_id = int(psql(
+        "INSERT INTO all_designs (category, kind, prompt, negative_prompt, local_path, image_url, "
+        "  dominant_hex, width_in, height_in, seed, tags, is_published, user_removed, web_viewer, generator, source_dw_sku) "
+        f"SELECT category, kind, prompt, negative_prompt, '{new_path}', '/designs/img/by-id/' || (SELECT COALESCE(MAX(id),0)+1 FROM all_designs), "
+        f"  dominant_hex, width_in, height_in, seed, (COALESCE(tags, ARRAY[]::text[])) || ARRAY['healed-from-{args.id}','seam-healed']::text[], "
+        f"  FALSE, FALSE, FALSE, 'heal-seam-region', source_dw_sku "
+        f"FROM all_designs WHERE id={args.id} RETURNING id;"
+    ))
+
+    # 5. Re-scan the new id
+    scan_after = seam.scan_path(Path(new_path))
+    out = {
+        'ok': True,
+        'src_id': args.id,
+        'new_id': new_id,
+        'new_path': new_path,
+        'healed_boxes': len(sel),
+        'scan_before': {'verdict': scan['verdict'], 'scores': scan['scores']},
+        'scan_after':  {'verdict': scan_after['verdict'], 'scores': scan_after['scores']},
+    }
+    print(json.dumps(out))
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index b691888..ce79016 100644
--- a/server.js
+++ b/server.js
@@ -1173,6 +1173,25 @@ app.get(['/admin/seam-debug', '/admin/seam-debug/:id'], (req, res) => {
   if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
   res.sendFile(path.join(__dirname, 'public', 'admin', 'seam-debug.html'));
 });
+// POST /api/seam-debug/:id/heal  body { box_ids: [int…] }  — heal ONLY the
+// selected mid/edge defect regions; save as a NEW design (preserves source).
+// Returns new_id + before/after scan so the viewer can show side-by-side.
+app.post('/api/seam-debug/:id/heal', express.json({ limit: '16kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  const boxIds = Array.isArray(req.body && req.body.box_ids) ? req.body.box_ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  if (!boxIds.length) return res.status(400).json({ error: 'no boxes selected' });
+  try {
+    const out = _execFileSyncSeam('python3',
+      [path.join(__dirname, 'scripts', 'heal-seam-region.py'),
+       '--id', String(id), '--boxes', JSON.stringify(boxIds)],
+      { encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
+    res.json(JSON.parse(out));
+  } catch (e) {
+    res.status(500).json({ error: e.message, stderr: (e.stderr || '').toString().slice(0, 500) });
+  }
+});
 
 // ── /admin/defect-registry — read-only viewer over wallco_defect_registry ──
 // "Keep all bad items in their own db so we never recreate the same error"

← fd80752 curator: fix bgswap matcher (gen name is 'gemini-…-bg-swap',  ·  back to Wallco Ai  ·  etsy bucket: wallco_etsy_bucket table + /api/etsy-bucket/{ad 78093f0 →