← 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
M public/admin/seam-debug.htmlA scripts/generate-etsy-bundles.jsA scripts/heal-region-inpaint.pyM server.js
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 →