← back to Wallco Ai
seam-fix curator: heal seams of good roots as is_published=FALSE children + per-root picker
fe0b0b1c959bd8e583ddf15cb348a1eebecfe778 · 2026-06-11 13:26:56 -0700 · Steve Abrams
Steve's narrow ask: the roots are good, only the seams need fixing. This is NOT
the luxe aesthetic transform (de Gournay/1838) — each variant is a SEAM-FIXED
copy of the SAME design, composition preserved exactly (round-1 sacred).
- scripts/seam-fix-variants.py: for given root ids, mints 2 composition-preserving
seam-fix children (v1 fix-seam shift-feather, v2 per-box circular-wrap heal),
re-scores each with seam-defect-boxes (before/after overall_max), inserts as
parent_design_id=root, is_published=FALSE, generator=seam-fix-v1|v2, and
appends to data/seam-fix-queue.jsonl. ComfyUI reroll deliberately excluded
(DTD 3/0: a reroll drifts composition).
- server.js: GET /api/seam-fix-curator (isAdmin) + POST /api/seam-fix-curator/pick
(requireAdmin). Pick publishes the chosen child, unpublishes seam-fix siblings,
and NEVER touches the root. Pick validates the child is a real seam-fix child
of the root before publishing.
- public/seam-fix-curator.html: luxe-curator-style per-root picker — root + seam-fix
candidates side by side, 2x2-tile preview to reveal the seam, before/after seam
scores + verdict per tile, cleanest fix starred, keyboard picks.
Sample: 5 drunk-animals roots (2810,2778,43186,43198,2662), all FAIL seam gate.
v2 per-box heal is the consistent winner (FAIL -> WARN/PASS, 50-82% lower max).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A data/seam-fix-queue.jsonlA public/seam-fix-curator.htmlA scripts/seam-fix-variants.pyM server.js
Diff
commit fe0b0b1c959bd8e583ddf15cb348a1eebecfe778
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 13:26:56 2026 -0700
seam-fix curator: heal seams of good roots as is_published=FALSE children + per-root picker
Steve's narrow ask: the roots are good, only the seams need fixing. This is NOT
the luxe aesthetic transform (de Gournay/1838) — each variant is a SEAM-FIXED
copy of the SAME design, composition preserved exactly (round-1 sacred).
- scripts/seam-fix-variants.py: for given root ids, mints 2 composition-preserving
seam-fix children (v1 fix-seam shift-feather, v2 per-box circular-wrap heal),
re-scores each with seam-defect-boxes (before/after overall_max), inserts as
parent_design_id=root, is_published=FALSE, generator=seam-fix-v1|v2, and
appends to data/seam-fix-queue.jsonl. ComfyUI reroll deliberately excluded
(DTD 3/0: a reroll drifts composition).
- server.js: GET /api/seam-fix-curator (isAdmin) + POST /api/seam-fix-curator/pick
(requireAdmin). Pick publishes the chosen child, unpublishes seam-fix siblings,
and NEVER touches the root. Pick validates the child is a real seam-fix child
of the root before publishing.
- public/seam-fix-curator.html: luxe-curator-style per-root picker — root + seam-fix
candidates side by side, 2x2-tile preview to reveal the seam, before/after seam
scores + verdict per tile, cleanest fix starred, keyboard picks.
Sample: 5 drunk-animals roots (2810,2778,43186,43198,2662), all FAIL seam gate.
v2 per-box heal is the consistent winner (FAIL -> WARN/PASS, 50-82% lower max).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
data/seam-fix-queue.jsonl | 10 ++
public/seam-fix-curator.html | 266 +++++++++++++++++++++++++++++++++++++++++++
scripts/seam-fix-variants.py | 233 +++++++++++++++++++++++++++++++++++++
server.js | 128 +++++++++++++++++++++
4 files changed, 637 insertions(+)
diff --git a/data/seam-fix-queue.jsonl b/data/seam-fix-queue.jsonl
new file mode 100644
index 0000000..87c0995
--- /dev/null
+++ b/data/seam-fix-queue.jsonl
@@ -0,0 +1,10 @@
+{"ts": "2026-06-11T20:22:40.035606Z", "root_id": 2810, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 15.31, "after_verdict": "FAIL", "after_max": 15.07, "improved": true, "ok": true, "new_id": 57869, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2810_v1.png"}
+{"ts": "2026-06-11T20:22:40.182546Z", "root_id": 2810, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 15.31, "after_verdict": "WARN", "after_max": 5.68, "improved": true, "ok": true, "new_id": 57870, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2810_v2.png"}
+{"ts": "2026-06-11T20:22:40.432864Z", "root_id": 2778, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 12.2, "after_verdict": "FAIL", "after_max": 12.2, "improved": false, "ok": true, "new_id": 57871, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2778_v1.png"}
+{"ts": "2026-06-11T20:22:40.637114Z", "root_id": 2778, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 12.2, "after_verdict": "WARN", "after_max": 5.33, "improved": true, "ok": true, "new_id": 57872, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2778_v2.png"}
+{"ts": "2026-06-11T20:22:40.819425Z", "root_id": 43186, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 20.6, "after_verdict": "FAIL", "after_max": 13.3, "improved": true, "ok": true, "new_id": 57873, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43186_v1.png"}
+{"ts": "2026-06-11T20:22:40.959106Z", "root_id": 43186, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 20.6, "after_verdict": "WARN", "after_max": 5.77, "improved": true, "ok": true, "new_id": 57874, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43186_v2.png"}
+{"ts": "2026-06-11T20:22:41.126774Z", "root_id": 43198, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 26.92, "after_verdict": "FAIL", "after_max": 19.39, "improved": true, "ok": true, "new_id": 57875, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43198_v1.png"}
+{"ts": "2026-06-11T20:22:41.258869Z", "root_id": 43198, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 26.92, "after_verdict": "PASS", "after_max": 4.81, "improved": true, "ok": true, "new_id": 57876, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43198_v2.png"}
+{"ts": "2026-06-11T20:22:41.470605Z", "root_id": 2662, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 22.01, "after_verdict": "FAIL", "after_max": 22.0, "improved": true, "ok": true, "new_id": 57877, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2662_v1.png"}
+{"ts": "2026-06-11T20:22:41.638429Z", "root_id": 2662, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 22.01, "after_verdict": "WARN", "after_max": 11.13, "improved": true, "ok": true, "new_id": 57878, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2662_v2.png"}
diff --git a/public/seam-fix-curator.html b/public/seam-fix-curator.html
new file mode 100644
index 0000000..2ae2941
--- /dev/null
+++ b/public/seam-fix-curator.html
@@ -0,0 +1,266 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Seam-Fix Curator — heal the seams, keep the root</title>
+<style>
+ :root {
+ --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff;
+ --border:#e8e6e1; --root:#2a4d6e;
+ --pick:#1d6b3a; --reject:#7a7a7a;
+ --pass:#1d6b3a; --warn:#b8860b; --fail:#a02020;
+ --sans:-apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
+ }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 var(--sans); }
+ header { padding:18px 28px 14px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
+ header h1 { margin:0 0 4px; font-size:18px; font-weight:600; letter-spacing:-0.01em; }
+ header h1 .badge { display:inline-block; padding:2px 8px; font-size:11px; font-weight:600; border-radius:10px; background:#1a1a1a; color:#fff; margin-left:8px; vertical-align:middle; }
+ header .stats { color:var(--muted); font-size:12.5px; }
+ header .stats b { color:var(--fg); }
+ header .keyhelp { font-size:11px; color:var(--muted); margin-top:6px; }
+ header .keyhelp kbd { display:inline-block; padding:1px 5px; font-size:10px; background:#f1efe9; border:1px solid var(--border); border-radius:3px; font-family:ui-monospace,Menlo,monospace; margin:0 2px; }
+ header label.tilechk { font-size:11px; color:var(--muted); margin-left:14px; cursor:pointer; }
+ main { padding:22px 28px 80px; }
+ .row {
+ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
+ margin-bottom:18px; background:var(--card); border:2px solid var(--border);
+ border-radius:10px; padding:14px; transition:opacity .25s, border-color .15s, box-shadow .15s;
+ }
+ .row.decided { opacity:0.4; pointer-events:none; }
+ .row.focused { border-color:var(--root); box-shadow:0 0 0 4px rgba(42,77,110,0.12); }
+ .row.focused .meta::after {
+ content:'⌨ focused · 2/3 pick a seam-fix · 1/K keep root (reject fixes) · J/↓ next · Shift+K/↑ prev';
+ display:block; font:10px ui-monospace,Menlo,monospace; color:var(--root); margin-top:4px; letter-spacing:.02em;
+ }
+ .tile { position:relative; aspect-ratio:1; background:#f0eee8; overflow:hidden; border-radius:6px; cursor:pointer; transition:transform .12s, box-shadow .12s; border:3px solid transparent; }
+ .tile:hover { transform:scale(1.02); box-shadow:0 4px 14px rgba(0,0,0,0.08); }
+ .tile.picked { border-color:var(--pick); box-shadow:0 0 0 2px rgba(29,107,58,0.18); }
+ .tile.isroot { cursor:default; }
+ .tile.isroot:hover { transform:none; box-shadow:none; }
+ /* tiled mode: show the image as a 2x2 repeat so the SEAM is visible */
+ .tile .img { width:100%; height:100%; background-position:center; background-size:cover; background-repeat:no-repeat; display:block; }
+ body.tiled .tile .img { background-size:50% 50%; background-repeat:repeat; }
+ .tile .label {
+ position:absolute; top:6px; left:6px; padding:2px 8px; font-size:10px;
+ letter-spacing:0.08em; text-transform:uppercase; border-radius:2px;
+ background:rgba(0,0,0,0.78); color:#fff; font-weight:600; z-index:2;
+ }
+ .tile .label.root { background:var(--root); }
+ .tile .label.v1 { background:#7a5a30; }
+ .tile .label.v2 { background:#30607a; }
+ .tile .score {
+ position:absolute; bottom:6px; left:6px; right:6px; padding:3px 7px; font-size:10.5px;
+ background:rgba(255,255,255,0.94); color:#1a1a1a; border-radius:3px;
+ font-weight:600; font-variant-numeric:tabular-nums; z-index:2;
+ display:flex; justify-content:space-between; align-items:center; gap:6px;
+ }
+ .verdict { font-weight:700; letter-spacing:.04em; }
+ .verdict.PASS { color:var(--pass); }
+ .verdict.WARN { color:var(--warn); }
+ .verdict.FAIL { color:var(--fail); }
+ .best { position:absolute; top:6px; right:6px; padding:2px 7px; font-size:9.5px; font-weight:700; letter-spacing:.05em; background:var(--pick); color:#fff; border-radius:10px; z-index:2; }
+ .meta {
+ grid-column:1/-1; display:flex; justify-content:space-between; align-items:center;
+ font-size:11.5px; color:var(--muted); padding:4px 4px 0;
+ }
+ .meta .info { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px; }
+ .meta .pick-status { font-weight:600; }
+ .meta button.keep-root {
+ padding:5px 12px; border:1px solid var(--border); background:var(--card);
+ border-radius:14px; font:11px var(--sans); cursor:pointer; color:var(--reject);
+ text-transform:uppercase; letter-spacing:.06em; font-weight:600;
+ }
+ .meta button.keep-root:hover { background:#f1efe9; border-color:var(--reject); }
+ .empty { padding:60px; text-align:center; color:var(--muted); font-size:14px; }
+ .empty code { background:#f1efe9; padding:2px 6px; border-radius:3px; font-size:12px; }
+ .toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:8px 16px; background:#1a1a1a; color:#fff; border-radius:20px; font-size:12.5px; opacity:0; transition:opacity .2s; pointer-events:none; z-index:99; }
+ .toast.show { opacity:1; }
+</style>
+</head>
+<body>
+<header>
+ <h1>Seam-Fix Curator <span class="badge" id="count">…</span></h1>
+ <div class="stats">
+ <b id="pendingCount">…</b> roots pending · <b id="decidedCount">…</b> decided.
+ Same design, same aesthetic — only the tile <b>seams</b> healed. Click the seam-fix that tiles cleanest →
+ publishes that child + leaves the <b>root untouched</b> (round-1 sacred). <b>"keep root"</b> rejects all fixes.
+ <label class="tilechk"><input type="checkbox" id="tileToggle" checked> show as 2×2 tile (reveals the seam)</label>
+ </div>
+ <div class="keyhelp">
+ <kbd>1</kbd>/<kbd>K</kbd> keep root · <kbd>2</kbd> pick best seam-fix · <kbd>3</kbd> pick other · <kbd>J</kbd>/<kbd>↓</kbd> next · <kbd>Shift+K</kbd>/<kbd>↑</kbd> prev
+ </div>
+</header>
+<main>
+ <div id="rows"><div class="empty">Loading…</div></div>
+</main>
+<div class="toast" id="toast"></div>
+<script>
+let DATA = [];
+let FOCUS = 0;
+
+async function loadData() {
+ const r = await fetch('/api/seam-fix-curator');
+ if (!r.ok) throw new Error('fetch failed: ' + r.status);
+ return r.json();
+}
+
+function fmtDate(iso) {
+ if (!iso) return '—';
+ const d = new Date(iso); if (isNaN(d)) return '—';
+ return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+function toast(msg) {
+ const t = document.getElementById('toast');
+ t.textContent = msg; t.classList.add('show');
+ clearTimeout(t._tm); t._tm = setTimeout(() => t.classList.remove('show'), 1700);
+}
+function vClass(v){ return v==='PASS'?'PASS':v==='WARN'?'WARN':'FAIL'; }
+
+function render() {
+ const main = document.getElementById('rows');
+ document.getElementById('count').textContent = DATA.length;
+ document.getElementById('pendingCount').textContent = DATA.length;
+ if (!DATA.length) {
+ main.innerHTML = '<div class="empty">No pending roots. Mint candidates with '
+ + '<code>python3 scripts/seam-fix-variants.py --ids 2810,2778,43186,43198,2662</code></div>';
+ return;
+ }
+ main.innerHTML = DATA.map((p, i) => {
+ // best variant = lowest after_max
+ const bestId = p.variants.length ? p.variants[0].new_id : null;
+ const variantTiles = p.variants.map(v => `
+ <div class="tile" data-pick="variant" data-id="${v.new_id}" data-variant="${v.variant}" data-row="${i}"
+ title="Pick ${v.variant} (${v.generator}) — seam ${p.before_max}→${v.after_max}">
+ <div class="img" style="background-image:url('/designs/img/by-id/${v.new_id}')"></div>
+ <span class="label ${v.variant}">${v.variant==='v1'?'v1 · shift-feather':'v2 · per-box heal'}</span>
+ ${v.new_id===bestId ? '<span class="best">★ cleanest</span>' : ''}
+ <span class="score">
+ <span>Δ ${p.before_max} → <b>${v.after_max}</b></span>
+ <span class="verdict ${vClass(v.after_verdict)}">${v.after_verdict}</span>
+ </span>
+ </div>`).join('');
+ return `
+ <div class="row" data-row="${i}" data-root="${p.root_id}" id="row-${i}">
+ <div class="tile isroot" title="Root #${p.root_id} — kept regardless of pick">
+ <div class="img" style="background-image:url('/designs/img/by-id/${p.root_id}')"></div>
+ <span class="label root">Root #${p.root_id}</span>
+ <span class="score">
+ <span>seam ${p.before_max}</span>
+ <span class="verdict ${vClass(p.before_verdict)}">${p.before_verdict}</span>
+ </span>
+ </div>
+ ${variantTiles}
+ <div class="meta">
+ <span class="info" title="root #${p.root_id} · ${p.category||''}">
+ #${p.root_id} · ${p.category||''} · 🕓 ${fmtDate(p.created_at)}
+ </span>
+ <span class="pick-status" id="status-${i}"></span>
+ <button class="keep-root" data-pick="keep" data-row="${i}">Keep root — reject fixes</button>
+ </div>
+ </div>`;
+ }).join('');
+ main.querySelectorAll('[data-pick]').forEach(el => {
+ el.addEventListener('click', () => {
+ const kind = el.dataset.pick;
+ const row = parseInt(el.dataset.row, 10);
+ if (kind === 'variant') pickVariant(row, parseInt(el.dataset.id, 10), el.dataset.variant);
+ else if (kind === 'keep') keepRoot(row);
+ });
+ });
+}
+
+async function pickVariant(rowIdx, childId, variant) {
+ const p = DATA[rowIdx]; if (!p) return;
+ try {
+ const r = await fetch('/api/seam-fix-curator/pick', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ root_id:p.root_id, picked_variant:variant, picked_id:childId }),
+ });
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.error || 'pick failed');
+ const row = document.getElementById('row-' + rowIdx);
+ row.querySelectorAll('.tile').forEach(t => t.classList.remove('picked'));
+ row.querySelector(`[data-id="${childId}"]`)?.classList.add('picked');
+ document.getElementById('status-' + rowIdx).textContent =
+ `✓ ${variant} published (#${childId}) · ${j.unpublished_siblings} sibling(s) unpublished · root kept`;
+ row.classList.add('decided');
+ toast(`Seam-fix ${variant} published for root #${p.root_id}`);
+ advance(rowIdx);
+ } catch (e) { toast('ERR: ' + e.message); }
+}
+
+async function keepRoot(rowIdx) {
+ const p = DATA[rowIdx]; if (!p) return;
+ try {
+ const r = await fetch('/api/seam-fix-curator/pick', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ root_id:p.root_id, picked_variant:'NONE' }),
+ });
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.error || 'keep failed');
+ document.getElementById('status-' + rowIdx).textContent =
+ `✗ seam fixes rejected — root kept (${j.unpublished_siblings} unpublished)`;
+ document.getElementById('row-' + rowIdx).classList.add('decided');
+ toast(`Root #${p.root_id} kept (fixes rejected)`);
+ advance(rowIdx);
+ } catch (e) { toast('ERR: ' + e.message); }
+}
+
+function setFocus(rowIdx) {
+ document.querySelectorAll('.row.focused').forEach(r => r.classList.remove('focused'));
+ const row = document.getElementById('row-' + rowIdx);
+ if (row) { row.classList.add('focused'); row.scrollIntoView({behavior:'smooth', block:'center'}); FOCUS = rowIdx; }
+}
+function advance(rowIdx) {
+ for (let i = rowIdx + 1; i < DATA.length; i++) {
+ const row = document.getElementById('row-' + i);
+ if (row && !row.classList.contains('decided')) { setFocus(i); return; }
+ }
+}
+document.addEventListener('keydown', (e) => {
+ if (e.target.matches('input,textarea')) return;
+ const p = DATA[FOCUS]; if (!p) return;
+ const key = e.key.toLowerCase();
+ if ((key === 'k' && e.shiftKey) || key === 'arrowup') {
+ for (let i = FOCUS - 1; i >= 0; i--) {
+ if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
+ }
+ e.preventDefault(); return;
+ }
+ if (key === 'j' || key === 'arrowdown') {
+ for (let i = FOCUS + 1; i < DATA.length; i++) {
+ if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
+ }
+ e.preventDefault(); return;
+ }
+ if (key === '1' || key === 'k') { keepRoot(FOCUS); e.preventDefault(); return; }
+ if (key === '2' || key === '3') {
+ const v = p.variants[parseInt(key,10) - 2];
+ if (v) { pickVariant(FOCUS, v.new_id, v.variant); e.preventDefault(); }
+ return;
+ }
+});
+function focusFirstUndecided() {
+ for (let i = 0; i < DATA.length; i++) {
+ const row = document.getElementById('row-' + i);
+ if (row && !row.classList.contains('decided')) { setFocus(i); break; }
+ }
+}
+document.getElementById('tileToggle').addEventListener('change', e => {
+ document.body.classList.toggle('tiled', e.target.checked);
+});
+
+loadData().then(d => {
+ DATA = d.items || [];
+ document.getElementById('decidedCount').textContent = d.decided || 0;
+ document.body.classList.add('tiled');
+ render();
+ focusFirstUndecided();
+}).catch(e => {
+ document.getElementById('rows').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
+});
+</script>
+</body>
+</html>
diff --git a/scripts/seam-fix-variants.py b/scripts/seam-fix-variants.py
new file mode 100644
index 0000000..b1f0fa0
--- /dev/null
+++ b/scripts/seam-fix-variants.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""
+seam-fix-variants — for a list of GOOD root design ids that fail the seam gate,
+produce 2 COMPOSITION-PRESERVING seam-fix candidates each and land them as NEW
+children (parent_design_id=root, is_published=FALSE) for the seam-fix picker.
+
+Variants (both preserve every subject pixel — round-1 sacred):
+ v1 shift-feather — scripts/fix-seam.py shift_and_heal (seam → center +
+ thin gaussian band). Whole-tile, robust on edge-wrap.
+ v2 heal-region — per-box targeted heal (v_mid/h_mid band-average +
+ edge-wrap mirror-average) borrowing the heal math from
+ scripts/heal-seam-region.py, on the boxes that
+ scripts/seam-defect-boxes.py flags for this root.
+
+For each variant we re-score with seam-defect-boxes and record before/after.
+A variant that does not improve overall_max is still landed (Steve decides in
+the picker) but flagged improved=false.
+
+NEW children are inserted into all_designs with:
+ parent_design_id=<root>, is_published=FALSE, user_removed=FALSE,
+ generator='seam-fix-v1'|'seam-fix-v2', category=<root.category>,
+ tags += {'seam-fixed','seamfix-from-<root>'}, image_url='/designs/img/by-id/<id>'.
+
+A queue entry per (root,variant) is appended to data/seam-fix-queue.jsonl, which
+backs /api/seam-fix-curator and /seam-fix-curator.html.
+
+DB: resolves DATABASE_URL from .env (psql). Never hard-codes the password.
+
+Usage:
+ python3 scripts/seam-fix-variants.py --ids 2810,2778,43186,43198,2662
+ python3 scripts/seam-fix-variants.py --ids 2810 --dry-run
+"""
+import argparse, json, os, subprocess, sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+ROOT = os.path.expanduser('~/Projects/wallco-ai')
+OUTDIR = os.path.join(ROOT, 'data', 'seam-fix')
+QUEUE = os.path.join(ROOT, 'data', 'seam-fix-queue.jsonl')
+
+sys.path.insert(0, os.path.join(ROOT, 'scripts'))
+from importlib import import_module
+seam = import_module('seam-defect-boxes')
+fixseam = import_module('fix-seam')
+
+
+def _database_url():
+ if os.environ.get('DATABASE_URL'):
+ return os.environ['DATABASE_URL']
+ envf = os.path.join(ROOT, '.env')
+ if os.path.exists(envf):
+ for line in open(envf):
+ if line.startswith('DATABASE_URL='):
+ return line.split('=', 1)[1].strip().strip('"')
+ raise RuntimeError('no DATABASE_URL')
+
+
+DB = _database_url()
+
+
+def psql(sql):
+ r = subprocess.run(['psql', DB, '-At', '-q', '-c', sql],
+ capture_output=True, text=True, timeout=30)
+ if r.returncode != 0:
+ raise RuntimeError(f'psql failed: {r.stderr.strip()}')
+ return r.stdout.strip()
+
+
+def root_meta(rid):
+ row = psql(f"SELECT row_to_json(t) FROM (SELECT id, category, local_path FROM all_designs WHERE id={rid}) t;")
+ if not row:
+ raise RuntimeError(f'no design {rid}')
+ return json.loads(row)
+
+
+# ---- v2 heal math (path-side reimplementation of heal-seam-region) ----
+BLEND_PX = 6
+BAND_PX = 32
+
+
+def heal_band_mid(arr, axis, idx, 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)
+ 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)
+ for x in range(x0, x1):
+ w = max(0.0, min(1.0, 1 - abs(x - idx) / (BAND_PX / 2)))
+ arr[start:end, x, :] = (avg * w + arr[start:end, x, :] * (1 - w)).round().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)
+ for y in range(y0, y1):
+ w = max(0.0, min(1.0, 1 - abs(y - idx) / (BAND_PX / 2)))
+ arr[y, start:end, :] = (avg * w + arr[y, start:end, :] * (1 - w)).round().astype('uint8')
+
+
+def heal_edge_wrap(arr, kind, start, end):
+ 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_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 make_v2_heal(src_path, boxes, out_path):
+ from PIL import Image
+ import numpy as np
+ img = Image.open(src_path).convert('RGB')
+ arr = np.asarray(img).copy()
+ H, W = arr.shape[:2]
+ for b in boxes:
+ 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'):
+ heal_edge_wrap(arr, k, x, x + w)
+ elif k in ('left_edge', 'right_edge'):
+ heal_edge_wrap(arr, k, y, y + h)
+ Image.fromarray(arr).save(out_path, optimize=True)
+
+
+def make_v1_shift(src_path, out_path):
+ from PIL import Image
+ im = Image.open(src_path).convert('RGB')
+ fixed = fixseam.shift_and_heal(im)
+ fixed.save(out_path, optimize=True)
+
+
+def insert_child(root, generator, new_path):
+ new_id = int(psql(
+ "INSERT INTO all_designs "
+ "(category, kind, prompt, negative_prompt, local_path, dominant_hex, "
+ " width_in, height_in, seed, tags, is_published, user_removed, generator, "
+ " parent_design_id, source_dw_sku) "
+ "SELECT category, kind, prompt, negative_prompt, "
+ f" '{new_path}', dominant_hex, width_in, height_in, seed, "
+ f" (COALESCE(tags, ARRAY[]::text[])) || ARRAY['seam-fixed','seamfix-from-{root}']::text[], "
+ f" FALSE, FALSE, '{generator}', {root}, source_dw_sku "
+ f"FROM all_designs WHERE id={root} RETURNING id;"))
+ psql(f"UPDATE all_designs SET image_url='/designs/img/by-id/' || id WHERE id={new_id};")
+ return new_id
+
+
+def process(root, dry_run=False):
+ os.makedirs(OUTDIR, exist_ok=True)
+ meta = root_meta(root)
+ src = meta.get('local_path')
+ if not src or not os.path.exists(src):
+ return [{'root_id': root, 'ok': False, 'error': 'no source file'}]
+ scan_root = seam.scan_path(Path(src))
+ before = {'verdict': scan_root['verdict'], 'scores': scan_root['scores']}
+ boxes = scan_root.get('boxes', [])
+ results = []
+
+ plan = [
+ ('seam-fix-v1', 'v1', lambda outp: make_v1_shift(src, outp)),
+ ('seam-fix-v2', 'v2', lambda outp: make_v2_heal(src, boxes, outp) if boxes else None),
+ ]
+ for generator, vlabel, fn in plan:
+ outp = os.path.join(OUTDIR, f'{root}_{vlabel}.png')
+ entry = {'ts': None, 'root_id': root, 'variant': vlabel, 'generator': generator,
+ 'category': meta.get('category'),
+ 'before_verdict': before['verdict'], 'before_max': before['scores']['overall_max']}
+ if vlabel == 'v2' and not boxes:
+ entry.update({'ok': False, 'skip': 'no-boxes'})
+ results.append(entry); continue
+ produced = fn(outp) # noqa
+ if not os.path.exists(outp):
+ entry.update({'ok': False, 'error': 'fixer produced no file'})
+ results.append(entry); continue
+ scan_after = seam.scan_path(Path(outp))
+ entry['after_verdict'] = scan_after['verdict']
+ entry['after_max'] = scan_after['scores']['overall_max']
+ entry['improved'] = scan_after['scores']['overall_max'] < before['scores']['overall_max']
+ if dry_run:
+ entry.update({'ok': True, 'dry_run': True, 'out': outp})
+ results.append(entry); continue
+ new_id = insert_child(root, generator, outp)
+ import datetime
+ entry.update({'ok': True, 'new_id': new_id, 'out': outp,
+ 'ts': datetime.datetime.utcnow().isoformat() + 'Z'})
+ results.append(entry)
+ return results
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--ids', required=True)
+ ap.add_argument('--dry-run', action='store_true')
+ args = ap.parse_args()
+ ids = [int(x) for x in args.ids.split(',') if x.strip()]
+ all_results = []
+ for rid in ids:
+ rs = process(rid, dry_run=args.dry_run)
+ all_results.extend(rs)
+ for r in rs:
+ if r.get('ok') and not r.get('dry_run'):
+ with open(QUEUE, 'a') as q:
+ q.write(json.dumps(r) + '\n')
+ for r in rs:
+ tag = r.get('variant', '?')
+ if r.get('ok'):
+ print(f" ✓ root {rid} {tag}: {r.get('before_max')}({r.get('before_verdict')}) "
+ f"→ {r.get('after_max')}({r.get('after_verdict')}) "
+ f"improved={r.get('improved')} new_id={r.get('new_id','dry')}")
+ else:
+ print(f" ✗ root {rid} {tag}: {r.get('error') or r.get('skip')}")
+ print(json.dumps({'results': all_results}, default=str))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/server.js b/server.js
index 55b71f1..c28b919 100644
--- a/server.js
+++ b/server.js
@@ -21386,6 +21386,134 @@ app.post('/api/luxe-curator/pick', requireAdmin, express.json({ limit: '4kb' }),
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
+// ───────────────────────── SEAM-FIX CURATOR ─────────────────────────
+// Steve's narrow ask (2026-06-11): the roots are good — ONLY the seams need
+// healing. This is NOT the luxe (de Gournay / 1838) aesthetic transform; the
+// variant per root is a SEAM-FIXED copy of the SAME design (composition
+// preserved exactly). Children are minted by scripts/seam-fix-variants.py as
+// parent_design_id=<root>, is_published=FALSE, generator='seam-fix-v1|v2'.
+//
+// /api/seam-fix-curator — backing data for /seam-fix-curator.html. Reads
+// data/seam-fix-queue.jsonl (the audit trail from seam-fix-variants.py), groups
+// by root, and surfaces every root that has seam-fix candidates AND no decision
+// yet. Each row = { root_id, category, before_max, before_verdict,
+// variants: [{ variant, new_id, generator, after_max, after_verdict, improved }] }.
+app.get('/api/seam-fix-curator', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ try {
+ const logF = path.join(__dirname, 'data', 'seam-fix-queue.jsonl');
+ if (!fs.existsSync(logF)) return res.json({ count: 0, items: [], decided: 0 });
+ const byRoot = new Map();
+ for (const line of fs.readFileSync(logF, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ if (!r.ok || !r.new_id) continue;
+ if (!byRoot.has(r.root_id)) {
+ byRoot.set(r.root_id, {
+ root_id: r.root_id, category: r.category || null,
+ before_max: r.before_max, before_verdict: r.before_verdict,
+ created_at: r.ts || null, variantMap: new Map(),
+ });
+ }
+ // latest entry for a given (root,variant) wins
+ byRoot.get(r.root_id).variantMap.set(r.variant, {
+ variant: r.variant, new_id: r.new_id, generator: r.generator,
+ after_max: r.after_max, after_verdict: r.after_verdict, improved: r.improved,
+ });
+ if (r.ts) {
+ const cur = byRoot.get(r.root_id).created_at;
+ if (!cur || r.ts < cur) byRoot.get(r.root_id).created_at = r.ts;
+ }
+ } catch {}
+ }
+ let decided = new Set();
+ try {
+ const out = psqlQuery(`SELECT root_id FROM seam_fix_choices`);
+ if (out) decided = new Set(out.split('\n').filter(Boolean).map(s => parseInt(s, 10)));
+ } catch { /* table may not exist yet */ }
+ const items = [...byRoot.values()]
+ .filter(r => !decided.has(r.root_id))
+ .map(r => ({
+ root_id: r.root_id, category: r.category,
+ before_max: r.before_max, before_verdict: r.before_verdict,
+ created_at: r.created_at,
+ // best-first so the cleanest seam fix sorts to the left
+ variants: [...r.variantMap.values()].sort((a, b) => (a.after_max ?? 99) - (b.after_max ?? 99)),
+ }))
+ .sort((a, b) => a.root_id - b.root_id);
+ res.json({ count: items.length, decided: decided.size, items });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// POST /api/seam-fix-curator/pick — record Steve's per-root seam-fix decision.
+// Body: { root_id, picked_variant: 'v1'|'v2'|'NONE', picked_id?: <child id> }
+// picked_variant='NONE' means "none of the seam fixes are good — keep the
+// root as-is, reject all candidates".
+// Effects (ROOT IS NEVER TOUCHED — round-1 sacred):
+// - Insert into seam_fix_choices (idempotent on root_id).
+// - If a variant was picked: publish that child (is_published=TRUE) and
+// unpublish the OTHER seam-fix siblings of this root. The root keeps its
+// own is_published state untouched.
+// - If NONE: unpublish all seam-fix children of this root, leave root alone.
+app.post('/api/seam-fix-curator/pick', requireAdmin, express.json({ limit: '4kb' }), (req, res) => {
+ const rootId = parseInt(req.body?.root_id, 10);
+ const picked = String(req.body?.picked_variant || '').toLowerCase();
+ const childId = parseInt(req.body?.picked_id, 10);
+ if (!Number.isFinite(rootId) || !['v1', 'v2', 'none'].includes(picked)) {
+ return res.status(400).json({ ok: false, error: 'bad input' });
+ }
+ if (picked !== 'none' && !Number.isFinite(childId)) {
+ return res.status(400).json({ ok: false, error: 'picked_id required when picking a variant' });
+ }
+ try {
+ psqlExecLocal(`
+ CREATE TABLE IF NOT EXISTS seam_fix_choices (
+ root_id BIGINT PRIMARY KEY,
+ picked_variant TEXT NOT NULL,
+ picked_id BIGINT,
+ decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )`);
+ // Safety: confirm the picked child is genuinely a seam-fix child of this
+ // root before publishing it (defense against a forged picked_id).
+ if (picked !== 'none') {
+ const ok = psqlQuery(`SELECT 1 FROM all_designs WHERE id=${childId} AND parent_design_id=${rootId} AND generator LIKE 'seam-fix-%' LIMIT 1`);
+ if (!ok || !ok.trim()) {
+ return res.status(400).json({ ok: false, error: 'picked_id is not a seam-fix child of this root' });
+ }
+ }
+ psqlExecLocal(`
+ INSERT INTO seam_fix_choices (root_id, picked_variant, picked_id, decided_at)
+ VALUES (${rootId}, '${picked}', ${Number.isFinite(childId) ? childId : 'NULL'}, NOW())
+ ON CONFLICT (root_id) DO UPDATE
+ SET picked_variant = EXCLUDED.picked_variant,
+ picked_id = EXCLUDED.picked_id,
+ decided_at = NOW()`);
+ let publishedId = null, unpublishedSiblings = 0;
+ if (picked !== 'none') {
+ psqlExecLocal(`UPDATE all_designs SET is_published=TRUE WHERE id=${childId}`);
+ publishedId = childId;
+ const out = psqlQuery(`
+ UPDATE all_designs SET is_published=FALSE
+ WHERE parent_design_id=${rootId}
+ AND generator LIKE 'seam-fix-%'
+ AND id <> ${childId}
+ RETURNING id`);
+ unpublishedSiblings = out ? out.split('\n').filter(Boolean).length : 0;
+ } else {
+ const out = psqlQuery(`
+ UPDATE all_designs SET is_published=FALSE
+ WHERE parent_design_id=${rootId}
+ AND generator LIKE 'seam-fix-%'
+ RETURNING id`);
+ unpublishedSiblings = out ? out.split('\n').filter(Boolean).length : 0;
+ }
+ res.json({ ok: true, root_id: rootId, picked, published_id: publishedId, unpublished_siblings: unpublishedSiblings });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
// /api/luxe-c-gallery — backing data for /luxe-c-gallery.html.
// Reads data/luxe-c-rollout.jsonl (the audit trail from scripts/roll-luxe-c.js)
// and returns OK rows for the root→luxe gallery view, deduped on root_id.
← bda8252 id-collision resolutions re-run 1..14999 (125 live: 77 F,F s
·
back to Wallco Ai
·
TODO: log seam-fix curator sample + Steve pick step 4a70b85 →