← 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
M public/admin/drunk-curator.htmlM public/admin/seam-debug.htmlA scripts/heal-seam-region.pyM server.js
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 →