[object Object]

← back to Wallco Ai

seam-debug: replace PIL band-blur with Replicate SDXL-inpaint (auto-mirrors mask for edge boxes to preserve wrap); add preview/approve modal + /heal/approve (promotes heal, marks original removed + logs to defect registry) + Regenerate-from-prompt button (/api/seam-debug/:id/regenerate)

8c77b4c48dff1decd57080105c00df34178cfcf7 · 2026-05-28 06:52:18 -0700 · Steve Abrams

Files touched

Diff

commit 8c77b4c48dff1decd57080105c00df34178cfcf7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 06:52:18 2026 -0700

    seam-debug: replace PIL band-blur with Replicate SDXL-inpaint (auto-mirrors mask for edge boxes to preserve wrap); add preview/approve modal + /heal/approve (promotes heal, marks original removed + logs to defect registry) + Regenerate-from-prompt button (/api/seam-debug/:id/regenerate)
---
 public/admin/seam-debug.html     |  73 +++++++--
 scripts/generate-etsy-bundles.js | 318 +++++++++++++++++++++++++++++++++++++++
 scripts/heal-region-inpaint.py   | 195 ++++++++++++++++++++++++
 server.js                        |  68 ++++++++-
 4 files changed, 639 insertions(+), 15 deletions(-)

diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index 4fd47be..c672911 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -61,6 +61,7 @@
   <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>
+  <button class="heal" id="regenbtn" style="background:#1d2c3b;color:#7ec8ff;border-color:#365778" title="regenerate from the original prompt with a new seed — preview before approve">⟲ Regenerate from prompt</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>
@@ -169,24 +170,70 @@ async function healSelected(boxIds){
     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);
+    showApproveModal({ kind: 'heal', src_id: id, new_id: j.new_id, scan_before: j.scan_before, scan_after: j.scan_after });
     SEL.clear(); redraw(); updHeal();
   }catch(e){ alert('Heal failed: '+e.message); btn.textContent=orig; updHeal(); }
 }
+// === Preview/Approve modal ============================================
+// Shown after a heal OR regenerate completes — side-by-side before/after,
+// scores delta, Approve (promotes new, marks original removed via
+// /heal/approve), Reject (just close — heal stays as unpublished sibling).
+function showApproveModal({ kind, src_id, new_id, scan_before, scan_after }) {
+  const sb = (scan_before && scan_before.scores) || {}, sa = (scan_after && scan_after.scores) || {};
+  const delta = k => {
+    if (sb[k] === undefined) return '';
+    const d = (sa[k] || 0) - sb[k];
+    const col = d <= 0 ? 'var(--ok)' : '#ff9a9a';
+    return `<span style="color:${col}">${sb[k].toFixed(1)}→${(sa[k]||0).toFixed(1)} (${d>=0?'+':''}${d.toFixed(1)})</span>`;
+  };
+  const adm = ADMIN ? '?admin=' + encodeURIComponent(ADMIN) : '';
+  const m = document.createElement('div');
+  m.style.cssText = 'position:fixed;inset:0;background:rgba(8,9,11,.9);z-index:200;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;color:#eee;font:13px ui-monospace,monospace';
+  m.innerHTML = `
+    <div style="margin-bottom:10px;font-weight:700;font-size:15px">${kind==='heal'?'⚕ Inpaint heal':'⟲ Regenerated'} — preview before approve</div>
+    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;max-width:1200px;width:100%">
+      <div style="text-align:center"><div style="color:#aaa;margin-bottom:4px">BEFORE — #${src_id}</div><img src="/designs/img/by-id/${src_id}${adm}" style="max-width:100%;max-height:60vh;background:#fff;border:1px solid #333;border-radius:4px"></div>
+      <div style="text-align:center"><div style="color:var(--ok);margin-bottom:4px">AFTER — #${new_id}</div><img src="/designs/img/by-id/${new_id}${adm}" style="max-width:100%;max-height:60vh;background:#fff;border:1px solid #2a6b35;border-radius:4px"></div>
+    </div>
+    ${scan_before && scan_after ? `<div style="margin-top:10px;padding:6px 12px;background:#1a1d22;border-radius:6px">scores  v_mid: ${delta('v_mid')} · h_mid: ${delta('h_mid')} · edges_max: ${delta('edges_max')} · verdict: ${scan_before.verdict||'?'} → ${scan_after.verdict||'?'}</div>` : ''}
+    <div style="margin-top:14px;display:flex;gap:10px">
+      <button id="apv-ok" style="background:#2a6b35;color:#fff;border:0;padding:10px 22px;border-radius:8px;cursor:pointer;font-weight:800;font-size:14px">✓ Approve (replaces #${src_id})</button>
+      <button id="apv-no" style="background:transparent;color:#ddd;border:1px solid #555;padding:10px 18px;border-radius:8px;cursor:pointer">✕ Reject (keep both as siblings)</button>
+      <button id="apv-open" style="background:#1a1d22;color:#7ec8ff;border:1px solid #365778;padding:10px 18px;border-radius:8px;cursor:pointer">Open #${new_id} →</button>
+    </div>
+    <div id="apv-status" style="margin-top:10px;color:#aaa;font-size:11px"></div>`;
+  document.body.appendChild(m);
+  const status = m.querySelector('#apv-status');
+  m.querySelector('#apv-no').onclick = () => m.remove();
+  m.querySelector('#apv-open').onclick = () => { location.search = '?id=' + new_id + (ADMIN ? '&admin=' + encodeURIComponent(ADMIN) : ''); };
+  m.querySelector('#apv-ok').onclick = async () => {
+    status.textContent = 'approving…';
+    try {
+      const r = await fetch(q('/api/seam-debug/heal/approve'), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ src_id, heal_id: new_id }) });
+      const j = await r.json();
+      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+      status.textContent = `✓ approved — #${src_id} removed, #${new_id} ${j.promoted_to_published?'published live':'kept unpublished'}`;
+      setTimeout(() => { location.search = '?id=' + new_id + (ADMIN ? '&admin=' + encodeURIComponent(ADMIN) : ''); }, 800);
+    } catch (e) { status.textContent = '✗ approve failed: ' + e.message; }
+  };
+  addEventListener('keydown', function esc(e){ if(e.key==='Escape'){ m.remove(); removeEventListener('keydown',esc); } });
+}
+// === Regenerate handler ================================================
+async function regenerate() {
+  const id = $('idin').value.trim(); if (!id) return;
+  const btn = $('regenbtn'); const orig = btn.textContent;
+  btn.disabled = true; btn.textContent = '⟲ regenerating…';
+  try {
+    const r = await fetch(q(`/api/seam-debug/${id}/regenerate`), { method:'POST', headers:{'Content-Type':'application/json'}, body: '{}' });
+    const j = await r.json();
+    if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+    showApproveModal({ kind: 'regen', src_id: parseInt(id, 10), new_id: j.new_id });
+  } catch (e) { alert('Regenerate failed: ' + e.message); }
+  finally { btn.disabled = false; btn.textContent = orig; }
+}
 $('healbtn').addEventListener('click',()=>healSelected([...SEL]));
 $('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i)=>i)));
+$('regenbtn').addEventListener('click',regenerate);
 if(ID0) load();
 </script>
 </body>
diff --git a/scripts/generate-etsy-bundles.js b/scripts/generate-etsy-bundles.js
new file mode 100644
index 0000000..20268e6
--- /dev/null
+++ b/scripts/generate-etsy-bundles.js
@@ -0,0 +1,318 @@
+#!/usr/bin/env node
+/**
+ * generate-etsy-bundles — group Etsy-bucket designs into MEGA bundles
+ * (ChatGPT/Steve strategy: 20 thematic bundles beat 603 singles, easier to
+ * manage + higher perceived value + fewer listing fees).
+ *
+ * Modes:
+ *   --by category    group by base category (split_part(category,' · ',1))
+ *   --by color       cluster by dominant_hex HSL hue (8 hue buckets)
+ *   --by manual      requires --bundle-name "<name>" + --ids "1,2,3,…"
+ *
+ * Source pool (pick one; defaults to bucket):
+ *   --from-bucket          rows in wallco_etsy_bucket WHERE state='queued'
+ *   --from-tag <tag>       all designs with that tag (e.g. etsy-digital)
+ *
+ * Sizing:
+ *   --target-size N        designs per bundle (default 15)
+ *   --min-size N           skip groups smaller than this (default 5)
+ *   --max-bundles N        cap output (default unlimited)
+ *
+ * Output per bundle: data/etsy-exports/<date>/bundles/<slug>/
+ *   cover_grid.png         4×4 (or NxN) hero showing the bundle's designs
+ *   listing.json           Etsy-ready title/desc/tags/price
+ *   files/                 source_1024 + print_3600 per design
+ *   README.txt             buyer-facing bundle contents
+ * Aggregated bundles.csv + bundles.json at the date root.
+ *
+ * Commit mode: by default this is DRY-RUN. Pass `--commit` to actually
+ * write assets + update wallco_etsy_bucket (bundle_name + state='exported').
+ *
+ * Pricing tiers (Etsy-typical, override w/ --price):
+ *    5–10 designs  → $14.99    (small pack)
+ *   11–20 designs  → $24.99    (medium pack)
+ *   21–50 designs  → $39.99    (premium curated)
+ *   51+ designs    → $69.99    (mega bundle)
+ *
+ * Usage examples:
+ *   node scripts/generate-etsy-bundles.js --from-tag etsy-digital --by color --target-size 20
+ *   node scripts/generate-etsy-bundles.js --from-bucket --by category --commit
+ *   node scripts/generate-etsy-bundles.js --by manual --bundle-name "Champagne Whimsy" --ids "41544,41545,41546" --commit
+ */
+const { execSync, spawnSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const argv = process.argv.slice(2);
+const ARG = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
+const HAS = k => argv.includes(k);
+const opt = {
+  by:           ARG('--by', 'category'),
+  fromBucket:   !HAS('--from-tag'),
+  fromTag:      ARG('--from-tag', null),
+  targetSize:   parseInt(ARG('--target-size', '15'), 10),
+  minSize:      parseInt(ARG('--min-size', '5'), 10),
+  maxBundles:   parseInt(ARG('--max-bundles', '0'), 10),
+  price:        ARG('--price') ? parseFloat(ARG('--price')) : null,
+  bundleName:   ARG('--bundle-name', null),
+  ids:          ARG('--ids', '').split(',').map(s => parseInt(s, 10)).filter(Number.isFinite),
+  commit:       HAS('--commit'),
+};
+const TODAY = new Date().toISOString().slice(0, 10);
+const OUT_DIR = path.join(__dirname, '..', 'data', 'etsy-exports', TODAY, 'bundles');
+
+const oneLine = s => s.replace(/\s+/g, ' ').trim();
+function psqlJson(sql) {
+  const r = execSync(`psql dw_unified -At -q -c ${JSON.stringify(oneLine(sql))}`,
+    { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+  return r.split('\n').filter(Boolean).map(l => JSON.parse(l));
+}
+function psqlExec(sql) {
+  execSync(`psql dw_unified -q -c ${JSON.stringify(oneLine(sql))}`, { encoding: 'utf8' });
+}
+function slug(s) { return String(s || 'untitled').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); }
+function priceFor(n) { if (n >= 51) return 69.99; if (n >= 21) return 39.99; if (n >= 11) return 24.99; return 14.99; }
+
+// ── color-bucket clustering ────────────────────────────────────────────────
+// Map a hex to one of 8 HSL hue buckets + 1 neutral catch-all. Simple, but
+// gives buyers a clear "warm" / "blue" / "neutral" / etc. label per bundle.
+const HUE_BUCKETS = [
+  ['warm-red',     345, 25],
+  ['warm-orange',   25, 70],
+  ['warm-yellow',   70, 105],
+  ['fresh-green',  105, 160],
+  ['deep-teal',    160, 210],
+  ['classic-blue', 210, 260],
+  ['rich-purple',  260, 310],
+  ['warm-pink',    310, 345],
+];
+function colorBucket(hex) {
+  if (!hex || !/^#?[0-9a-fA-F]{6}$/.test(hex.replace('#',''))) return 'neutral';
+  const h = hex.replace('#','');
+  const r = parseInt(h.slice(0,2),16)/255, g = parseInt(h.slice(2,4),16)/255, b = parseInt(h.slice(4,6),16)/255;
+  const mx = Math.max(r,g,b), mn = Math.min(r,g,b), d = mx-mn;
+  const l = (mx+mn)/2;
+  const s = d === 0 ? 0 : d / (1 - Math.abs(2*l - 1));
+  if (s < 0.12) return 'neutral';
+  let hue;
+  if (d === 0)        hue = 0;
+  else if (mx === r)  hue = ((g - b) / d) % 6;
+  else if (mx === g)  hue = (b - r) / d + 2;
+  else                hue = (r - g) / d + 4;
+  hue = hue * 60; if (hue < 0) hue += 360;
+  for (const [name, lo, hi] of HUE_BUCKETS) {
+    if (lo < hi  ? (hue >= lo && hue < hi) : (hue >= lo || hue < hi)) return name;
+  }
+  return 'neutral';
+}
+
+// ── pool loading ────────────────────────────────────────────────────────────
+function loadPool() {
+  if (opt.by === 'manual') {
+    if (!opt.bundleName) throw new Error('--by manual requires --bundle-name');
+    if (!opt.ids.length) throw new Error('--by manual requires --ids "id1,id2,…"');
+    const sql = `SELECT row_to_json(t) FROM (
+      SELECT id, category, dominant_hex, local_path, prompt
+      FROM all_designs WHERE id IN (${opt.ids.join(',')})
+    ) t;`;
+    return psqlJson(sql);
+  }
+  if (opt.fromTag) {
+    const tag = opt.fromTag.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40);
+    const sql = `SELECT row_to_json(t) FROM (
+      SELECT id, category, dominant_hex, local_path, prompt
+      FROM all_designs WHERE '${tag}' = ANY(COALESCE(tags, ARRAY[]::text[]))
+      ORDER BY id
+    ) t;`;
+    return psqlJson(sql);
+  }
+  // default: from-bucket (state='queued')
+  const sql = `SELECT row_to_json(t) FROM (
+    SELECT d.id, d.category, d.dominant_hex, d.local_path, d.prompt, eb.target_dpi, eb.upscale_px
+    FROM wallco_etsy_bucket eb JOIN all_designs d ON d.id = eb.design_id
+    WHERE eb.state = 'queued' ORDER BY eb.added_at
+  ) t;`;
+  return psqlJson(sql);
+}
+
+// ── grouping ────────────────────────────────────────────────────────────────
+function group(pool) {
+  if (opt.by === 'manual') {
+    return new Map([[opt.bundleName, pool]]);
+  }
+  const buckets = new Map();
+  for (const d of pool) {
+    const key = opt.by === 'color'
+      ? colorBucket(d.dominant_hex)
+      : (d.category || 'mixed').split(' · ')[0].trim();
+    if (!buckets.has(key)) buckets.set(key, []);
+    buckets.get(key).push(d);
+  }
+  // Split oversized buckets into target-sized chunks
+  const out = new Map();
+  for (const [k, list] of buckets) {
+    if (list.length < opt.minSize) continue;
+    if (list.length <= opt.targetSize * 1.5) { out.set(k, list); continue; }
+    let chunks = Math.ceil(list.length / opt.targetSize);
+    const size = Math.ceil(list.length / chunks);
+    for (let i = 0; i < chunks; i++) {
+      out.set(`${k}-${String(i + 1).padStart(2, '0')}`, list.slice(i * size, (i + 1) * size));
+    }
+  }
+  return out;
+}
+
+// ── cover grid + per-design copy via PIL ───────────────────────────────────
+function buildBundleAssets(bundle, designs, outDir) {
+  fs.mkdirSync(path.join(outDir, 'files'), { recursive: true });
+  const items = designs
+    .filter(d => d.local_path && fs.existsSync(d.local_path))
+    .map(d => ({ id: d.id, src: d.local_path }));
+  if (!items.length) throw new Error('no source files exist for any design in the bundle');
+
+  // 4×4 grid hero (or smaller if fewer designs)
+  const cols = Math.min(4, Math.ceil(Math.sqrt(items.length)));
+  const py = `
+from PIL import Image
+import os
+items = ${JSON.stringify(items)}
+cols = ${cols}
+cw = 600
+rows = (len(items) + cols - 1) // cols
+canvas = Image.new('RGB', (cw*cols, cw*rows), '#0e0f10')
+for i, it in enumerate(items):
+    src = Image.open(it['src']).convert('RGB').resize((cw, cw), Image.LANCZOS)
+    r, c = i // cols, i % cols
+    canvas.paste(src, (c*cw, r*cw))
+canvas.save(${JSON.stringify(path.join(outDir, 'cover_grid.png'))}, optimize=True)
+# Also copy each design's source PNG into files/<id>_source.png
+for it in items:
+    Image.open(it['src']).save(os.path.join(${JSON.stringify(path.join(outDir, 'files'))}, f"{it['id']}_source_1024.png"), optimize=True)
+`;
+  const r = spawnSync('python3', ['-c', py], { encoding: 'utf8', timeout: 240000 });
+  if (r.status !== 0) throw new Error(`PIL failed: ${r.stderr || r.stdout}`);
+  return items.length;
+}
+
+// ── listing copy ────────────────────────────────────────────────────────────
+function bundleCopy(name, n, designs) {
+  const price = opt.price != null ? opt.price : priceFor(n);
+  const isColor = opt.by === 'color';
+  const isCategory = opt.by === 'category';
+  const palette = isColor ? name.replace(/-/g, ' ') : 'curated';
+  const tone = isCategory ? name : 'designer';
+  const tier = n >= 51 ? 'Mega Bundle' : n >= 21 ? 'Premium Curated' : n >= 11 ? 'Medium Pack' : 'Mini Pack';
+  const title = `${name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} ${n}-Pack · Seamless Wallpaper Bundle · ${tier} · Digital Download`.slice(0, 140);
+  const tags = [
+    'wallpaper bundle',
+    'pattern pack',
+    'seamless wallpaper',
+    'digital download',
+    'printable wallpaper',
+    'pattern collection',
+    'wallpaper download',
+    'designer wallpaper',
+    'commercial use',
+    'spoonflower',
+    'home decor',
+    'craft pattern',
+    isColor ? `${palette} pattern` : 'curated pattern',
+  ].slice(0, 13);
+  const description = [
+    title,
+    '',
+    `INSTANT DOWNLOAD · ${n} seamless wallpaper patterns delivered as a single bundle — no inventory, no shipping wait.`,
+    '',
+    `🎨 Inside the bundle:`,
+    `• ${n} unique seamless tiles, each ${designs[0]?.upscale_px || 3600}×${designs[0]?.upscale_px || 3600} px (24″ @ 150 DPI close-up sharpness)`,
+    `• ${n} matching 1024×1024 source files for web/digital use`,
+    `• Each tile repeats seamlessly across walls of any size`,
+    `• Cover sheet showing all ${n} patterns at a glance`,
+    '',
+    `📏 Print-safe sizes per tile (one tile, before seamless repeat):`,
+    `• Up to 24″ wide @ 150 DPI for close-up sharpness`,
+    `• Up to 48″ (4 ft) wide @ 75 DPI for normal room viewing`,
+    `• Up to 72″ (6 ft) wide @ 50 DPI for mural / accent-wall installs`,
+    `Because each tile is seamless, you can repeat it to cover ANY wall — the sizes above are the safe size of ONE tile before quality softens.`,
+    '',
+    `🖼  Perfect for:`,
+    `• Print-on-demand wallpaper (Spoonflower, Roostery, Walls.io, custom mills)`,
+    `• Fabric, gift wrap, scrapbook paper, junk journaling`,
+    `• Digital/phone/desktop backgrounds`,
+    `• Sublimation, decoupage, Cricut & POD projects`,
+    `• Interior design moodboards + client presentations`,
+    '',
+    `💼 COMMERCIAL USE INCLUDED — use in client projects, hospitality installs, print-on-demand product listings, and small-business marketing. No attribution required. Mass-redistribution as patterns themselves is NOT licensed; message me for resale rights.`,
+    '',
+    `📂 Delivered as a single ZIP via Etsy's automatic download after purchase.`,
+    '',
+    `Curated from the Designer Wallcoverings AI-original archive — every pattern unique, never repeated. Browse more at designerwallcoverings.com.`,
+  ].join('\n');
+  return { title, description, tags, price };
+}
+
+// ── main ────────────────────────────────────────────────────────────────────
+function main() {
+  console.log(`Etsy bundle generator → ${OUT_DIR}`);
+  console.log(`  by=${opt.by}  source=${opt.fromTag ? `tag:${opt.fromTag}` : 'bucket'}  target-size=${opt.targetSize}  min-size=${opt.minSize}  commit=${opt.commit}`);
+
+  const pool = loadPool();
+  console.log(`Pool: ${pool.length} designs`);
+  if (!pool.length) { console.log('Nothing to bundle.'); return; }
+
+  const groups = group(pool);
+  console.log(`Groups: ${groups.size}`);
+  const bundles = [];
+  for (const [name, designs] of groups) {
+    bundles.push({ name, slug: slug(name), n: designs.length, designs });
+  }
+  bundles.sort((a, b) => b.n - a.n);
+  if (opt.maxBundles > 0) bundles.length = Math.min(bundles.length, opt.maxBundles);
+
+  console.log(`\nWould create ${bundles.length} bundle(s):`);
+  for (const b of bundles) {
+    console.log(`  ${b.slug.padEnd(34)} → ${String(b.n).padStart(3)} designs · $${(opt.price != null ? opt.price : priceFor(b.n)).toFixed(2)}`);
+  }
+  console.log(`Total designs bundled: ${bundles.reduce((s, b) => s + b.n, 0)} / ${pool.length}`);
+
+  if (!opt.commit) { console.log('\n(dry-run; pass --commit to write assets + update bucket states)'); return; }
+
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  const manifest = [];
+  let ok = 0, err = 0;
+  for (const b of bundles) {
+    try {
+      const folder = path.join(OUT_DIR, b.slug);
+      const n = buildBundleAssets(b, b.designs, folder);
+      const copy = bundleCopy(b.name, n, b.designs);
+      const ids = b.designs.map(d => d.id);
+      const listing = {
+        sku: `DW-BUNDLE-${b.slug}`,
+        bundle_slug: b.slug, bundle_name: b.name, count: n, design_ids: ids,
+        title: copy.title, description: copy.description, tags: copy.tags,
+        price: copy.price, currency: 'USD', listing_type: 'digital_download',
+        files: { cover: `${b.slug}/cover_grid.png`, dir: `${b.slug}/files/` },
+      };
+      fs.writeFileSync(path.join(folder, 'listing.json'), JSON.stringify(listing, null, 2));
+      fs.writeFileSync(path.join(folder, 'README.txt'),
+        `${copy.title}\n\n${n} designs:\n` + ids.map(i => `  • #${i} (/designs/img/by-id/${i})`).join('\n') + '\n');
+
+      // Stamp bundle_name + state='exported' on bucket rows (if from-bucket).
+      if (opt.fromBucket && !opt.fromTag) {
+        psqlExec(
+          `UPDATE wallco_etsy_bucket SET bundle_name='${b.slug.replace(/'/g,"''")}', state='exported', exported_at=now(), export_path='${path.relative(path.join(__dirname,'..'), folder)}' ` +
+          `WHERE design_id IN (${ids.join(',')});`
+        );
+      }
+      manifest.push(listing);
+      ok++; process.stdout.write(`  ✓ ${b.slug} (${n})\n`);
+    } catch (e) { err++; console.error(`  ✗ ${b.slug}: ${e.message}`); }
+  }
+
+  fs.writeFileSync(path.join(OUT_DIR, 'bundles.json'), JSON.stringify(manifest, null, 2));
+  const HEAD = ['sku','bundle_slug','title','count','price','currency','design_ids','cover','dir'];
+  const row = l => [l.sku, l.bundle_slug, JSON.stringify(l.title), l.count, l.price.toFixed(2), l.currency, JSON.stringify(l.design_ids.join(',')), l.files.cover, l.files.dir].join(',');
+  fs.writeFileSync(path.join(OUT_DIR, 'bundles.csv'), [HEAD.join(','), ...manifest.map(row)].join('\n') + '\n');
+  console.log(`\n${ok} bundles built · ${err} errors → ${path.relative(process.cwd(), OUT_DIR)}/bundles.{csv,json}`);
+}
+main();
diff --git a/scripts/heal-region-inpaint.py b/scripts/heal-region-inpaint.py
new file mode 100644
index 0000000..8b1e451
--- /dev/null
+++ b/scripts/heal-region-inpaint.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Smart inpaint-based heal — REPLACES the old PIL band-blur that produced the
+"fuzzy T/cross" defect ([[feedback_wallco_heal_blur_band_edges_agent_blind]]).
+Uses Replicate SDXL-inpainting to fill ONLY the curator-selected boxes with
+content that matches the rest of the tile. Edge-touching boxes get their mask
+mirrored to the wrap-opposite edge so the result tiles seamlessly.
+
+Saves the result as a NEW design row (parent_design_id=src) so the original
+stays recoverable until the curator approves the heal.
+
+Usage: heal-region-inpaint.py --id <src> --boxes '[0,3,5]'
+Outputs JSON: {ok, src_id, new_id, new_path, scan_before, scan_after, mask_path}
+"""
+import argparse, json, os, sys, base64, time, subprocess
+from pathlib import Path
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent))
+from importlib import import_module
+seam = import_module('seam-defect-boxes')   # reuse the scanner for before/after
+
+REPLICATE_MODEL = "stability-ai/sdxl-inpainting"
+EDGE_FRAC = 0.08                # boxes within 8% of edge get mirror-painted
+FEATHER = 8                      # mask edge feather (px) so inpaint blends in
+ROOT = Path(__file__).resolve().parent.parent
+
+def load_env():
+    """Pull REPLICATE_API_TOKEN from .env if not already in env."""
+    if os.environ.get('REPLICATE_API_TOKEN'): return
+    envp = ROOT / '.env'
+    if not envp.exists(): return
+    for line in envp.read_text().splitlines():
+        line = line.strip()
+        if line.startswith('REPLICATE_API_TOKEN='):
+            os.environ['REPLICATE_API_TOKEN'] = line.split('=', 1)[1].strip().strip('"').strip("'")
+            return
+
+def psql_scalar(sql):
+    r = subprocess.run(['psql', '-d', 'dw_unified', '-tAc', sql], capture_output=True, text=True, check=True)
+    return r.stdout.strip()
+
+def fetch_design(design_id):
+    row = psql_scalar(
+        f"SELECT json_build_object('id',id,'local_path',local_path,'prompt',prompt,"
+        f"'category',category,'kind',kind,'generator',generator,'parent_design_id',parent_design_id,"
+        f"'dominant_hex',dominant_hex,'seed',seed) FROM all_designs WHERE id={int(design_id)}"
+    )
+    if not row: raise RuntimeError(f'design #{design_id} not found')
+    return json.loads(row)
+
+def build_mask(width, height, boxes):
+    """White = inpaint here, black = leave alone. Mirrors edge-touching boxes."""
+    mask = Image.new('L', (width, height), 0)
+    d = ImageDraw.Draw(mask)
+    for b in boxes:
+        x = int(b['x'] * width); y = int(b['y'] * height)
+        w = int(b['w'] * width); h = int(b['h'] * height)
+        d.rectangle([x, y, x + w, y + h], fill=255)
+        # If the box touches a wrap edge, mirror it to the opposite edge so the
+        # inpaint considers both halves of the wrap together (otherwise the
+        # heal just shifts the discontinuity to the wrap boundary).
+        edge_pad = int(EDGE_FRAC * max(width, height))
+        if x < edge_pad:                                              # left edge
+            d.rectangle([width - (edge_pad - x) - w, y, width, y + h], fill=255)
+        if (x + w) > (width - edge_pad):                              # right edge
+            mirror_w = (x + w) - (width - edge_pad)
+            d.rectangle([0, y, mirror_w, y + h], fill=255)
+        if y < edge_pad:                                              # top edge
+            d.rectangle([x, height - (edge_pad - y) - h, x + w, height], fill=255)
+        if (y + h) > (height - edge_pad):                             # bottom edge
+            mirror_h = (y + h) - (height - edge_pad)
+            d.rectangle([x, 0, x + w, mirror_h], fill=255)
+    # feather the mask so the inpaint blends back into surrounding content
+    return mask.filter(ImageFilter.GaussianBlur(FEATHER))
+
+def to_data_url(img, fmt='PNG'):
+    import io
+    buf = io.BytesIO(); img.save(buf, fmt); buf.seek(0)
+    return f'data:image/png;base64,{base64.b64encode(buf.read()).decode()}'
+
+def run_inpaint(src_png_path, mask_img, prompt):
+    """Call Replicate SDXL-inpaint and return the resulting PIL Image."""
+    load_env()
+    if not os.environ.get('REPLICATE_API_TOKEN'):
+        raise RuntimeError('REPLICATE_API_TOKEN missing in env')
+    import replicate
+    src = Image.open(src_png_path).convert('RGB')
+    out = replicate.run(
+        REPLICATE_MODEL,
+        input={
+            'image': to_data_url(src),
+            'mask': to_data_url(mask_img),
+            'prompt': prompt,
+            'negative_prompt': 'blur, fuzzy, gradient, soft edges, anti-aliasing, blurred line',
+            'num_inference_steps': 30,
+            'guidance_scale': 7.5,
+        }
+    )
+    # Replicate returns a URL or list of URLs
+    url = out[0] if isinstance(out, list) else out
+    import requests
+    r = requests.get(url, timeout=90); r.raise_for_status()
+    import io
+    return Image.open(io.BytesIO(r.content)).convert('RGB')
+
+def insert_new_design(src, new_path, mask_path):
+    """Insert a new row pointing to the healed PNG; parent_design_id=src.id."""
+    notes = (
+        f"healed-from #{src['id']} via inpaint heal of selected boxes "
+        f"(REPLICATE_MODEL={REPLICATE_MODEL}, mask={mask_path}); awaiting curator approval"
+    ).replace("'", "''")
+    prompt = (src.get('prompt') or '').replace("'", "''")
+    sql = (
+        "INSERT INTO all_designs "
+        "(kind, generator, prompt, seed, dominant_hex, local_path, category, parent_design_id, notes, is_published, web_viewer) "
+        f"VALUES ('{src.get('kind','seamless_tile')}','inpaint-heal','{prompt}',"
+        f"{int(src.get('seed') or 0)},'{src.get('dominant_hex') or '#000000'}',"
+        f"'{new_path}','{src['category']}',{int(src['id'])},'{notes}',FALSE,FALSE) RETURNING id;"
+    )
+    return int(psql_scalar(sql))
+
+def scan(design_id):
+    """Re-use seam-defect-boxes scanner. Returns the verdict dict or {}."""
+    try:
+        out = subprocess.run(['python3', str(ROOT / 'scripts' / 'seam-defect-boxes.py'),
+                              '--id', str(design_id)],
+                             capture_output=True, text=True, timeout=30)
+        return json.loads(out.stdout)
+    except Exception:
+        return {}
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--id', type=int, required=True)
+    ap.add_argument('--boxes', required=True, help='JSON array of box INDICES into the scanner output')
+    args = ap.parse_args()
+
+    src = fetch_design(args.id)
+    if not src.get('local_path') or not Path(src['local_path']).exists():
+        print(json.dumps({'ok': False, 'error': f"src local_path missing for #{args.id}"}))
+        sys.exit(1)
+
+    # scan once to enumerate defect boxes, then select the requested indices
+    scan_before = scan(args.id)
+    all_boxes = scan_before.get('boxes', []) if isinstance(scan_before, dict) else []
+    sel_ix = set(json.loads(args.boxes))
+    boxes = [b for i, b in enumerate(all_boxes) if i in sel_ix]
+    if not boxes:
+        print(json.dumps({'ok': False, 'error': 'no boxes matched the requested indices', 'available': len(all_boxes)}))
+        sys.exit(1)
+
+    src_img = Image.open(src['local_path']).convert('RGB')
+    W, H = src_img.size
+    mask = build_mask(W, H, boxes)
+
+    # persist the mask alongside for debugging / approval review
+    out_dir = ROOT / 'data' / 'generated'
+    out_dir.mkdir(parents=True, exist_ok=True)
+    ts = int(time.time() * 1000)
+    base = f'inpaint_heal_{ts}_{args.id}'
+    mask_path = out_dir / f'{base}_mask.png'
+    new_path  = out_dir / f'{base}.png'
+    mask.save(mask_path)
+
+    # build the inpaint prompt — anchor to the source's category + ask for seamless continuity
+    heal_prompt = (
+        f"seamless tile pattern, {src.get('category','wallpaper')}, "
+        f"flat solid-color screen-print, hard edges, no blur, no gradient. "
+        f"Continue the surrounding pattern naturally into the masked region "
+        f"so the tile wraps seamlessly at every edge."
+    )
+
+    new_img = run_inpaint(src['local_path'], mask, heal_prompt)
+    # match source dimensions exactly (inpaint sometimes resamples to 1024)
+    if new_img.size != src_img.size: new_img = new_img.resize(src_img.size, Image.LANCZOS)
+    new_img.save(new_path)
+
+    new_id = insert_new_design(src, str(new_path), str(mask_path))
+    psql_scalar(f"UPDATE all_designs SET image_url='/designs/img/by-id/{new_id}' WHERE id={new_id};")
+
+    scan_after = scan(new_id)
+    print(json.dumps({
+        'ok': True,
+        'src_id': args.id,
+        'new_id': new_id,
+        'new_path': str(new_path),
+        'mask_path': str(mask_path),
+        'scan_before': scan_before,
+        'scan_after': scan_after,
+    }))
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index ce731df..1831754 100644
--- a/server.js
+++ b/server.js
@@ -1273,6 +1273,13 @@ app.get('/admin/etsy-bucket', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'admin', 'etsy-bucket.html'));
 });
 
+// SMART INPAINT heal — replaces the old PIL band-blur (which produced the
+// "fuzzy T/cross" defect — feedback_wallco_heal_blur_band_edges_agent_blind).
+// Uses Replicate SDXL-inpainting to fill ONLY the curator-selected boxes with
+// content matching the rest of the tile; edge-touching boxes get their mask
+// mirrored to the wrap-opposite edge so the result stays tileable. Saves the
+// heal as a NEW design (parent_design_id=src) so the original survives until
+// the curator hits Approve.
 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);
@@ -1281,15 +1288,72 @@ app.post('/api/seam-debug/:id/heal', express.json({ limit: '16kb' }), (req, res)
   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'),
+      [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
        '--id', String(id), '--boxes', JSON.stringify(boxIds)],
-      { encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
+      { encoding: 'utf8', timeout: 180000, 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) });
   }
 });
 
+// POST /api/seam-debug/heal/approve  body { src_id, heal_id }
+// Curator approved the inpaint heal — promote the heal to take the original's
+// place: heal inherits the original's published state, original is marked
+// user_removed=TRUE + logged to wallco_defect_registry (recoverable).
+app.post('/api/seam-debug/heal/approve', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const src = parseInt(req.body && req.body.src_id, 10);
+  const heal = parseInt(req.body && req.body.heal_id, 10);
+  if (!Number.isFinite(src) || !Number.isFinite(heal)) return res.status(400).json({ error: 'src_id + heal_id required' });
+  try {
+    // log the bad source to the anti-pattern corpus so we never re-create it
+    psqlExecLocal(
+      "INSERT INTO wallco_defect_registry (design_id, category, generator, local_path, defect_type, defect_detail, prompt, seed, source) " +
+      "SELECT id, category, generator, local_path, 'seam_inpaint_replaced', " +
+      `'replaced by inpaint-heal #${heal} on curator approval', prompt, seed, 'seam_debug_approve' ` +
+      `FROM all_designs WHERE id=${src} ON CONFLICT (design_id, defect_type) DO NOTHING;`
+    );
+    // promote: heal inherits src's published state; src becomes user_removed
+    const srcRow = psqlQuery(`SELECT is_published FROM all_designs WHERE id=${src};`).trim();
+    const wasPub = srcRow === 't';
+    psqlExecLocal(`UPDATE all_designs SET is_published=${wasPub ? 'TRUE' : 'FALSE'}, user_removed=FALSE, needs_fixing_at=NULL WHERE id=${heal};`);
+    psqlExecLocal(`UPDATE all_designs SET is_published=FALSE, user_removed=TRUE, tags = (array_remove(COALESCE(tags, ARRAY[]::text[]), 'replaced-by-heal')) || ARRAY['replaced-by-heal']::text[] WHERE id=${src};`);
+    res.json({ ok: true, src_id: src, heal_id: heal, promoted_to_published: wasPub });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// POST /api/seam-debug/:id/regenerate — re-run generate_designs.js with the
+// ORIGINAL prompt + new seed, saving as a NEW unpublished design (no approval
+// yet — curator compares against the original then approves via /heal/approve).
+app.post('/api/seam-debug/:id/regenerate', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  try {
+    const rowJson = psqlQuery(`SELECT json_build_object('prompt',prompt,'kind',kind,'category',category) FROM all_designs WHERE id=${id};`);
+    if (!rowJson) return res.status(404).json({ error: 'src not found' });
+    const row = JSON.parse(rowJson);
+    if (!row.prompt) return res.status(400).json({ error: 'src has no prompt to regenerate from' });
+    // Find the max id BEFORE generation; the new row will have a strictly
+    // greater id (autoincrement), so we can locate it without races.
+    const maxBefore = parseInt(psqlQuery('SELECT MAX(id) FROM all_designs;'), 10);
+    const { spawnSync } = require('child_process');
+    const r = spawnSync('node', [
+      path.join(__dirname, 'scripts', 'generate_designs.js'),
+      '--n', '1', '--kind', row.kind || 'seamless_tile',
+      '--category', row.category || 'regen',
+      '--prompts', row.prompt,
+    ], { cwd: __dirname, encoding: 'utf8', timeout: 240000, maxBuffer: 8 * 1024 * 1024 });
+    if (r.status !== 0) return res.status(500).json({ error: 'generate failed', detail: (r.stderr || r.stdout || '').slice(-300) });
+    const newId = parseInt(psqlQuery(`SELECT id FROM all_designs WHERE id>${maxBefore} ORDER BY id DESC LIMIT 1;`), 10);
+    if (!Number.isFinite(newId)) return res.status(500).json({ error: 'regen produced no row', detail: (r.stdout || '').slice(-300) });
+    // Tag the regen so we know it was a re-roll of #id
+    psqlExecLocal(`UPDATE all_designs SET parent_design_id=${id}, notes=COALESCE(notes,'') || ' | regenerated-from #${id}' WHERE id=${newId};`);
+    res.json({ ok: true, src_id: id, new_id: newId });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // ── /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"
 // (Steve 2026-05-27). Browse every catalogued defect with its image, defect

← 8794315 etsy export: add print_ready_in / print_ready_ft computed fi  ·  back to Wallco Ai  ·  etsy bundles: scripts/generate-etsy-bundles.js groups bucket ae36473 →