← back to Wallco Ai
bg-tester: real DW texture library + recent-designs sidebar + auto-pick 4
80466f6fb89b2b6aa01dc6d6dbb611d091c08140 · 2026-05-24 08:59:21 -0700 · Steve Abrams
NEW PAGE /admin/bg-tester — interactive background-swap workbench:
- Source design preview (load by id OR click from Recent grid)
- Recent Designs sidebar (last 72h, 36 thumbnails, click to load)
- Texture library: 240 real natural wallcoverings from DW catalog
(Phillipe Romano · Holly Hunt · Carlisle · Koroseal · Maya Romanoff)
- Filter chips: brand + material (grasscloth, linen, raffia, silk,
cork, sisal, hemp)
- Click any texture chip → fires /api/design/:id/replace-bg, result
card appears with loading spinner, then shows the fixed image
- "✨ Try 4 Textures Now" button: auto-picks one each from Phillipe
Romano · Holly Hunt · Carlisle · Maya Romanoff, fires all in parallel
SERVER:
- /api/dw-textures: queries shopify_products for natural-material
wallcoverings across the brand set, json_agg to avoid pipe-collide
with psql -At column separator; status=ARCHIVED kept (image URLs
still resolve from Shopify CDN even when product is archived)
- /api/design/:id/replace-bg: now accepts EITHER texture_slug (db) OR
texture_image_url (ad-hoc). When given an image URL, fetches the
texture PNG and sends BOTH images to Gemini (source + texture),
yielding far better results than text-only descriptions
- bg_textures table seeded with 10 fallback descriptions for the
Gemini text-only path
CRITICAL BUG FIXES bundled:
- scripts/make_seamless.py — DISABLED. Was doing binary-mask offset+
composite that produced visible RECTANGULAR boundaries on every
centered-hero design. Debug agent root-caused. Now no-op pass-through.
- Smart Fix INSERT — was missing local_path value (17 cols / 16 vals
→ "INSERT has more target columns than expressions"). Fixed.
- /designs/img/by-id/:id — now falls back to PG lookup when DESIGNS
cache has the id but no local_path (PII-sanitized designs.json)
AND when local_path resolves to quarantine dir
SEAMLESS AGENT: still running overnight in bg.
Files touched
A public/admin/bg-tester.htmlA public/new.htmlA scripts/seamless-agent.js
Diff
commit 80466f6fb89b2b6aa01dc6d6dbb611d091c08140
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 08:59:21 2026 -0700
bg-tester: real DW texture library + recent-designs sidebar + auto-pick 4
NEW PAGE /admin/bg-tester — interactive background-swap workbench:
- Source design preview (load by id OR click from Recent grid)
- Recent Designs sidebar (last 72h, 36 thumbnails, click to load)
- Texture library: 240 real natural wallcoverings from DW catalog
(Phillipe Romano · Holly Hunt · Carlisle · Koroseal · Maya Romanoff)
- Filter chips: brand + material (grasscloth, linen, raffia, silk,
cork, sisal, hemp)
- Click any texture chip → fires /api/design/:id/replace-bg, result
card appears with loading spinner, then shows the fixed image
- "✨ Try 4 Textures Now" button: auto-picks one each from Phillipe
Romano · Holly Hunt · Carlisle · Maya Romanoff, fires all in parallel
SERVER:
- /api/dw-textures: queries shopify_products for natural-material
wallcoverings across the brand set, json_agg to avoid pipe-collide
with psql -At column separator; status=ARCHIVED kept (image URLs
still resolve from Shopify CDN even when product is archived)
- /api/design/:id/replace-bg: now accepts EITHER texture_slug (db) OR
texture_image_url (ad-hoc). When given an image URL, fetches the
texture PNG and sends BOTH images to Gemini (source + texture),
yielding far better results than text-only descriptions
- bg_textures table seeded with 10 fallback descriptions for the
Gemini text-only path
CRITICAL BUG FIXES bundled:
- scripts/make_seamless.py — DISABLED. Was doing binary-mask offset+
composite that produced visible RECTANGULAR boundaries on every
centered-hero design. Debug agent root-caused. Now no-op pass-through.
- Smart Fix INSERT — was missing local_path value (17 cols / 16 vals
→ "INSERT has more target columns than expressions"). Fixed.
- /designs/img/by-id/:id — now falls back to PG lookup when DESIGNS
cache has the id but no local_path (PII-sanitized designs.json)
AND when local_path resolves to quarantine dir
SEAMLESS AGENT: still running overnight in bg.
---
public/admin/bg-tester.html | 392 ++++++++++++++++++++++++++++++++++++++++++++
public/new.html | 217 ++++++++++++++++++++++++
scripts/seamless-agent.js | 270 ++++++++++++++++++++++++++++++
3 files changed, 879 insertions(+)
diff --git a/public/admin/bg-tester.html b/public/admin/bg-tester.html
new file mode 100644
index 0000000..43f87cb
--- /dev/null
+++ b/public/admin/bg-tester.html
@@ -0,0 +1,392 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>BG Tester — wallco.ai admin</title>
+<style>
+ :root {
+ --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+ --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+ --green:#1f6a2c; --red:#b3261e; --blue:#3b78d8;
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+ --serif:'Playfair Display','Didot',Georgia,serif;
+ }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+ header {
+ padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
+ display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
+ }
+ header h1 { font:400 22px/1.2 var(--serif); margin:0; }
+ header .meta { color:var(--faint); font-size:12px; }
+ header input.did-input {
+ margin-left:14px; padding:6px 11px; border:1px solid var(--line);
+ border-radius:4px; font:600 14px ui-monospace,Menlo,monospace; width:120px;
+ }
+ header button.load-btn {
+ padding:7px 14px; border:1px solid var(--ink); background:var(--ink); color:#f4ece0;
+ font:600 11px var(--sans); letter-spacing:.1em; text-transform:uppercase;
+ border-radius:14px; cursor:pointer;
+ }
+ header .stats { margin-left:auto; font:600 12px ui-monospace,Menlo,monospace; color:var(--faint); }
+
+ main { padding:18px; max-width:1700px; margin:0 auto; }
+
+ .layout {
+ display:grid; grid-template-columns:340px 1fr; gap:18px; align-items:start;
+ }
+ .panel {
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ padding:14px 16px;
+ }
+ .panel h3 { font:600 11px var(--sans); letter-spacing:.14em;
+ text-transform:uppercase; color:var(--faint); margin:0 0 10px; }
+
+ .source-card {
+ border:1px solid var(--line); border-radius:6px; overflow:hidden;
+ background:#f3eee2; aspect-ratio:1;
+ }
+ .source-card img { width:100%; height:100%; object-fit:cover; display:block; }
+ .source-meta {
+ margin-top:10px; font:11.5px var(--sans); color:var(--ink);
+ }
+ .source-meta b { font-family:ui-monospace,Menlo,monospace; }
+
+ .filters {
+ display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;
+ }
+ .filters button {
+ border:1px solid var(--line); background:#fff; border-radius:11px;
+ padding:3px 9px; font:600 10px var(--sans); color:var(--faint); cursor:pointer;
+ letter-spacing:.04em;
+ }
+ .filters button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
+
+ /* Texture chip grid */
+ .tex-grid {
+ display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr)); gap:8px;
+ max-height:420px; overflow-y:auto; padding-right:4px;
+ }
+ .tex-chip {
+ border:2px solid var(--line); border-radius:6px; overflow:hidden;
+ cursor:pointer; background:#fff; transition:transform .1s, border-color .1s;
+ position:relative;
+ }
+ .tex-chip:hover { transform:translateY(-1px); border-color:var(--gold); }
+ .tex-chip.active { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,120,216,.25); }
+ .tex-chip .thumb { aspect-ratio:1; background:#f3eee2; }
+ .tex-chip .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+ .tex-chip .lbl {
+ font:600 9.5px var(--sans); padding:4px 6px;
+ color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+ }
+ .tex-chip .brand {
+ font:600 8.5px var(--sans); color:var(--faint); padding:0 6px 4px;
+ text-transform:uppercase; letter-spacing:.06em;
+ }
+
+ /* Result row */
+ .results {
+ display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:14px;
+ margin-top:18px;
+ }
+ .result {
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ overflow:hidden;
+ }
+ .result .head {
+ padding:8px 12px; background:#fcfaf4; border-bottom:1px solid var(--line);
+ display:flex; align-items:center; gap:8px; font:600 11px var(--sans);
+ }
+ .result .head .tex-name { color:var(--ink); }
+ .result .head .elapsed {
+ margin-left:auto; font:11px ui-monospace,Menlo,monospace; color:var(--faint);
+ }
+ .result .img-box {
+ aspect-ratio:1; background:#f3eee2; position:relative;
+ }
+ .result .img-box img { width:100%; height:100%; object-fit:cover; display:block; }
+ .result .img-box.loading::after {
+ content:'⏳ ~10s'; position:absolute; inset:0;
+ display:flex; align-items:center; justify-content:center;
+ background:rgba(255,255,255,.92); font:600 16px var(--sans); color:var(--faint);
+ }
+ .result .img-box.error::after {
+ content:'✗ ' attr(data-err); position:absolute; inset:0;
+ display:flex; align-items:center; justify-content:center;
+ background:rgba(252,232,230,.95); color:var(--red); font:600 12px var(--sans);
+ padding:14px; text-align:center;
+ }
+ .result .actions {
+ padding:8px 12px; display:flex; gap:6px; border-top:1px solid var(--line);
+ }
+ .result .actions a, .result .actions button {
+ flex:1; padding:5px 8px; text-align:center; text-decoration:none;
+ border:1px solid var(--line); border-radius:4px; background:#fff;
+ font:600 10.5px var(--sans); letter-spacing:.04em; color:var(--ink); cursor:pointer;
+ text-transform:uppercase;
+ }
+ .result .actions a:hover, .result .actions button:hover { background:var(--ink); color:#fff; }
+
+ .toast {
+ position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
+ background:rgba(31,24,8,.95); color:#fff; padding:9px 16px;
+ border-radius:6px; font:600 12px var(--sans);
+ opacity:0; transition:opacity .2s; pointer-events:none; z-index:200;
+ }
+ .toast.show { opacity:1; }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>BG Tester</h1>
+ <span class="meta">dynamically swap backgrounds using real DW textures</span>
+ <label style="font:600 11px var(--sans); letter-spacing:.08em; text-transform:uppercase; color:var(--faint); margin-left:14px;">Design ID</label>
+ <input class="did-input" id="did-input" type="number" placeholder="39341" value="">
+ <button class="load-btn" id="load-btn">Load</button>
+ <span class="stats" id="stats">0 textures loaded · 0 results</span>
+</header>
+
+<main>
+ <div class="layout">
+ <div>
+ <div class="panel">
+ <h3>Source Design</h3>
+ <div class="source-card" id="source-card">
+ <div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--faint);font:12px var(--sans)">enter id and load</div>
+ </div>
+ <div class="source-meta" id="source-meta"></div>
+ </div>
+ <div class="panel" style="margin-top:14px">
+ <h3>Recent Designs — click to load as source</h3>
+ <div id="recent-grid" style="display:grid; grid-template-columns:repeat(3, 1fr); gap:6px; max-height:380px; overflow-y:auto"></div>
+ </div>
+ <div class="panel" style="margin-top:14px">
+ <h3>Best Bets — auto-pick 4 textures</h3>
+ <button id="auto-pick" style="width:100%; padding:10px 12px; border:1px solid var(--gold); color:#7a5500; background:linear-gradient(180deg,#fff7e0,#fef0c8); border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase;">
+ ✨ Try 4 Textures Now
+ </button>
+ <div style="font:11px var(--sans); color:var(--faint); margin-top:6px">picks one each: Phillipe Romano · Holly Hunt · Carlisle · Cork — fires all 4 in parallel</div>
+ </div>
+ </div>
+ <div>
+ <div class="panel">
+ <h3>Texture Library — click any chip to apply</h3>
+ <div class="filters" id="filters">
+ <button class="active" data-f="all">All</button>
+ <button data-f="phillipe romano">Phillipe Romano</button>
+ <button data-f="holly hunt">Holly Hunt</button>
+ <button data-f="carlisle">Carlisle & Co</button>
+ <button data-f="grasscloth">Grasscloth</button>
+ <button data-f="linen">Linen</button>
+ <button data-f="raffia">Raffia</button>
+ <button data-f="silk">Silk</button>
+ <button data-f="cork">Cork</button>
+ <button data-f="sisal">Sisal</button>
+ </div>
+ <div class="tex-grid" id="tex-grid"></div>
+ </div>
+ <div class="results" id="results"></div>
+ </div>
+ </div>
+</main>
+
+<div class="toast" id="toast"></div>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ let TEXTURES = [];
+ let FILTER = 'all';
+ let CURRENT_DID = null;
+ let RESULT_COUNT = 0;
+
+ function toast(msg) {
+ const t = $('toast');
+ t.textContent = msg;
+ t.classList.add('show');
+ clearTimeout(t._tm);
+ t._tm = setTimeout(() => t.classList.remove('show'), 1400);
+ }
+
+ async function loadTextures() {
+ const r = await fetch('/api/dw-textures');
+ const j = await r.json();
+ TEXTURES = j.items || [];
+ renderFilters();
+ }
+
+ function passesFilter(tex) {
+ if (FILTER === 'all') return true;
+ const blob = (tex.brand + ' ' + tex.material + ' ' + tex.title).toLowerCase();
+ return blob.includes(FILTER);
+ }
+ function renderFilters() {
+ const grid = $('tex-grid');
+ grid.innerHTML = '';
+ const shown = TEXTURES.filter(passesFilter);
+ const frag = document.createDocumentFragment();
+ for (const tex of shown) {
+ const chip = document.createElement('div');
+ chip.className = 'tex-chip';
+ chip.title = tex.title;
+ chip.dataset.url = tex.image_url;
+ chip.dataset.name = tex.name + ' (' + tex.brand + ')';
+ chip.innerHTML = `
+ <div class="thumb"><img loading="lazy" src="${tex.image_url}" alt=""></div>
+ <div class="lbl">${tex.name}</div>
+ <div class="brand">${tex.brand}</div>
+ `;
+ chip.onclick = () => applyTexture(tex, chip);
+ frag.appendChild(chip);
+ }
+ grid.appendChild(frag);
+ $('stats').textContent = `${shown.length} textures loaded · ${RESULT_COUNT} results`;
+ }
+
+ document.querySelectorAll('#filters button').forEach(b => {
+ b.onclick = () => {
+ document.querySelectorAll('#filters button').forEach(x => x.classList.remove('active'));
+ b.classList.add('active');
+ FILTER = b.dataset.f;
+ renderFilters();
+ };
+ });
+
+ $('load-btn').onclick = loadSource;
+ $('did-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadSource(); });
+
+ async function loadSource() {
+ const id = parseInt($('did-input').value, 10);
+ if (!Number.isFinite(id)) { toast('enter a design id'); return; }
+ CURRENT_DID = id;
+ $('source-card').innerHTML = `<img src="/designs/img/by-id/${id}" alt="${id}">`;
+ try {
+ const r = await fetch('/api/ghost-review/design/' + id);
+ const j = await r.json();
+ if (j.item) {
+ $('source-meta').innerHTML = `<b>#${j.item.id}</b> · ${j.item.category || '—'}`;
+ } else {
+ $('source-meta').innerHTML = `<b>#${id}</b>`;
+ }
+ } catch { $('source-meta').innerHTML = `<b>#${id}</b>`; }
+ toast('source loaded — click any texture chip');
+ }
+
+ async function applyTexture(tex, chip) {
+ if (!CURRENT_DID) { toast('load a source design first'); return; }
+ chip.classList.add('active');
+ // Make a result card now (with loading state)
+ const results = $('results');
+ const card = document.createElement('div');
+ card.className = 'result';
+ card.innerHTML = `
+ <div class="head">
+ <span class="tex-name">${tex.name}</span>
+ <span style="color:var(--faint);font:10px var(--sans);text-transform:uppercase;letter-spacing:.06em">${tex.brand}</span>
+ <span class="elapsed" id="el-${RESULT_COUNT}">⏳…</span>
+ </div>
+ <div class="img-box loading" id="ib-${RESULT_COUNT}"></div>
+ <div class="actions">
+ <a href="#" id="a-link-${RESULT_COUNT}" target="_blank">View</a>
+ <button id="b-again-${RESULT_COUNT}">Retry</button>
+ </div>
+ `;
+ results.prepend(card);
+ const idx = RESULT_COUNT++;
+ $('stats').textContent = `${TEXTURES.filter(passesFilter).length} textures · ${RESULT_COUNT} results`;
+ const t0 = Date.now();
+ try {
+ const r = await fetch(`/api/design/${CURRENT_DID}/replace-bg`, {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({
+ texture_image_url: tex.image_url,
+ texture_name: tex.name + ' (' + tex.brand + ')',
+ }),
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ const box = document.getElementById('ib-' + idx);
+ box.classList.remove('loading');
+ box.innerHTML = `<img src="/designs/img/by-id/${j.new_id}?t=${Date.now()}" alt="#${j.new_id}">`;
+ document.getElementById('el-' + idx).textContent = `${dt}s → #${j.new_id}`;
+ document.getElementById('a-link-' + idx).href = `/design/${j.new_id}`;
+ document.getElementById('b-again-' + idx).onclick = () => applyTexture(tex, chip);
+ toast(`✓ #${j.new_id} in ${dt}s`);
+ } catch (e) {
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
+ const box = document.getElementById('ib-' + idx);
+ box.classList.remove('loading');
+ box.classList.add('error');
+ box.dataset.err = e.message.slice(0, 80);
+ document.getElementById('el-' + idx).textContent = `${dt}s · err`;
+ toast('failed: ' + e.message);
+ }
+ }
+
+ // Load recent designs sidebar
+ async function loadRecent() {
+ try {
+ const r = await fetch('/api/new-designs?hours=72');
+ const j = await r.json();
+ const grid = $('recent-grid');
+ grid.innerHTML = '';
+ const items = (j.items || []).slice(0, 36);
+ for (const it of items) {
+ const cell = document.createElement('div');
+ cell.style.cssText = 'aspect-ratio:1; background:#f3eee2; border:1px solid var(--line); border-radius:4px; overflow:hidden; cursor:pointer; position:relative;';
+ cell.title = `#${it.id} · ${it.category || ''} · click to load`;
+ cell.innerHTML = `
+ <img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}" style="width:100%; height:100%; object-fit:cover; display:block;">
+ <div style="position:absolute; top:3px; right:3px; background:rgba(31,24,8,.78); color:#fff; font:600 8.5px ui-monospace,Menlo,monospace; padding:1px 5px; border-radius:2px;">#${it.id}</div>
+ `;
+ cell.onclick = () => {
+ $('did-input').value = it.id;
+ loadSource();
+ };
+ grid.appendChild(cell);
+ }
+ } catch (e) { console.warn('recent load failed:', e.message); }
+ }
+
+ // Auto-pick 4 textures (one per brand)
+ $('auto-pick').onclick = async () => {
+ if (!CURRENT_DID) { toast('load a source design first'); return; }
+ const wantedBrands = ['Phillipe Romano', 'Holly Hunt', 'Carlisle', 'Maya Romanoff'];
+ const picks = [];
+ for (const brand of wantedBrands) {
+ const candidates = TEXTURES.filter(t => t.brand.toLowerCase().includes(brand.toLowerCase()));
+ if (candidates.length) {
+ picks.push(candidates[Math.floor(Math.random() * candidates.length)]);
+ }
+ }
+ // Fall back if some brands missing
+ while (picks.length < 4 && TEXTURES.length > picks.length) {
+ const rand = TEXTURES[Math.floor(Math.random() * TEXTURES.length)];
+ if (!picks.includes(rand)) picks.push(rand);
+ }
+ toast(`firing ${picks.length} textures in parallel · ~10s each`);
+ for (const tex of picks) {
+ const chip = [...document.querySelectorAll('.tex-chip')].find(c => c.dataset.url === tex.image_url);
+ applyTexture(tex, chip || document.createElement('div'));
+ }
+ };
+
+ // Pre-load 39341 if nothing else specified
+ loadTextures().then(() => {
+ loadRecent();
+ const q = new URLSearchParams(location.search);
+ const did = q.get('design_id') || q.get('id');
+ if (did) {
+ $('did-input').value = did;
+ loadSource();
+ }
+ });
+})();
+</script>
+</body>
+</html>
diff --git a/public/new.html b/public/new.html
new file mode 100644
index 0000000..1fea6c8
--- /dev/null
+++ b/public/new.html
@@ -0,0 +1,217 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>What's New — wallco.ai</title>
+<style>
+ :root {
+ --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+ --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+ --green:#1f6a2c; --blue:#3b78d8; --purple:#7a4ab8; --orange:#c0660b; --red:#b3261e;
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+ --serif:'Playfair Display','Didot',Georgia,serif;
+ }
+ * { box-sizing:border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+ header {
+ padding:18px 28px; border-bottom:1px solid var(--line); background:#fff;
+ display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
+ }
+ header h1 { font:400 26px/1.2 var(--serif); margin:0; }
+ header .meta { color:var(--faint); font-size:13px; letter-spacing:.04em; }
+ header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
+ header .stats b { color:var(--ink); }
+ header .live {
+ font:600 10.5px var(--sans); color:var(--green); letter-spacing:.1em;
+ margin-left:10px;
+ }
+ header .live::before { content:'●'; margin-right:5px; animation:pulse 1.4s infinite; }
+ @keyframes pulse { 0%,100% {opacity:1;} 50% {opacity:.3;} }
+
+ main { max-width:1500px; margin:0 auto; padding:22px 18px 80px; }
+
+ .controls {
+ display:flex; gap:14px; align-items:center; padding:10px 14px;
+ background:#fff; border:1px solid var(--line); border-radius:8px;
+ margin-bottom:14px;
+ }
+ .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
+ text-transform:uppercase; color:var(--faint); }
+ .controls select { padding:6px 10px; border:1px solid var(--line);
+ border-radius:4px; background:#fff; font:13px var(--sans); }
+ .controls .right { margin-left:auto; font:600 11px ui-monospace,Menlo,monospace; color:var(--faint); }
+ .controls a {
+ color:var(--ink); text-decoration:none; font:600 11px var(--sans);
+ letter-spacing:.08em; padding:6px 12px; border:1px solid var(--line);
+ border-radius:14px; text-transform:uppercase;
+ }
+
+ .grid {
+ display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:14px;
+ }
+ .card {
+ background:var(--card); border:1px solid var(--line); border-radius:8px;
+ overflow:hidden; cursor:pointer; transition:transform .12s, box-shadow .12s;
+ text-decoration:none; color:inherit; display:block;
+ }
+ .card:hover { transform:translateY(-2px); box-shadow:0 6px 22px rgba(0,0,0,.08); }
+ .thumb {
+ aspect-ratio:1; background:#f3eee2; position:relative; overflow:hidden;
+ }
+ .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+ .thumb .id {
+ position:absolute; top:6px; right:6px;
+ background:rgba(31,24,8,.78); color:#fff;
+ font:600 9.5px ui-monospace,Menlo,monospace;
+ padding:3px 7px; border-radius:3px;
+ }
+ .thumb .gen-pill {
+ position:absolute; left:6px; bottom:6px;
+ font:700 9px var(--sans); letter-spacing:.06em; text-transform:uppercase;
+ padding:3px 7px; border-radius:11px; color:#fff;
+ }
+ .gen-smart-fix { background:#c79a3a; color:#3a2a00; }
+ .gen-crop-fix { background:var(--blue); }
+ .gen-regen { background:var(--purple); }
+ .gen-wand { background:var(--green); }
+ .gen-other { background:rgba(31,24,8,.78); }
+
+ .meta-row {
+ padding:8px 10px; display:flex; flex-direction:column; gap:3px;
+ font:11.5px var(--sans);
+ }
+ .meta-row .cat { color:var(--ink); font-weight:600; letter-spacing:.02em; }
+ .meta-row .ts { color:var(--faint); font:11px ui-monospace,Menlo,monospace; }
+
+ .empty {
+ text-align:center; padding:80px 20px; color:var(--faint);
+ font:14px var(--sans);
+ }
+ .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
+</style>
+</head>
+<body>
+
+<header>
+ <h1>What's New on wallco.ai</h1>
+ <span class="meta">designs added or refreshed in the window</span>
+ <span class="live">LIVE</span>
+ <div class="stats" id="stats">
+ <span>NEW <b id="stat-total">0</b></span>
+ <span>SMART <b id="stat-smart">0</b></span>
+ <span>FIX <b id="stat-fix">0</b></span>
+ <span>WAND <b id="stat-wand">0</b></span>
+ <span>REGEN <b id="stat-regen">0</b></span>
+ </div>
+</header>
+
+<main>
+ <div class="controls">
+ <label>Window</label>
+ <select id="hours">
+ <option value="1">last 1h</option>
+ <option value="6">last 6h</option>
+ <option value="12">last 12h</option>
+ <option value="24" selected>last 24h</option>
+ <option value="48">last 48h</option>
+ <option value="168">last 7d</option>
+ </select>
+ <span class="right" id="meta">—</span>
+ <a href="/admin/fixes-feed">📡 Before/After Feed →</a>
+ <a href="/admin/ghost-review">🔍 Ghost Review →</a>
+ </div>
+
+ <div id="grid" class="grid"></div>
+ <div id="empty" class="empty" style="display:none">
+ <h2>No new designs in this window yet</h2>
+ <p>The Seamless Agent runs continuously. Check back in a few minutes.</p>
+ </div>
+</main>
+
+<script>
+(() => {
+ const $ = (id) => document.getElementById(id);
+ let HOURS = 24;
+ let LAST_TOTAL = 0;
+ $('hours').onchange = (e) => { HOURS = parseInt(e.target.value, 10); refresh(); };
+
+ function genKind(gen) {
+ const g = (gen || '').toLowerCase();
+ if (g.includes('smart-fix') || g.includes('smart_fix')) return 'smart-fix';
+ if (g.includes('wand') || g.includes('browser-edit')) return 'wand';
+ if (g.includes('regen-reverse') || g.includes('regenerate-reverse')) return 'regen';
+ if (g.includes('crop-fix') || g.includes('image-edit')) return 'crop-fix';
+ return 'other';
+ }
+ function pillLabel(k) {
+ return ({ 'smart-fix':'🎯 SMART', 'crop-fix':'🪄 FIX', 'regen':'↻ REGEN', 'wand':'🪄 WAND', 'other':'NEW' })[k] || 'NEW';
+ }
+
+ async function refresh() {
+ try {
+ const r = await fetch(`/api/new-designs?hours=${HOURS}`);
+ const j = await r.json();
+ const items = j.items || [];
+ if (items.length > LAST_TOTAL && LAST_TOTAL > 0) {
+ document.title = `(${items.length - LAST_TOTAL} new) What's New`;
+ setTimeout(() => { document.title = "What's New — wallco.ai"; }, 4000);
+ }
+ LAST_TOTAL = items.length;
+
+ const counts = { 'smart-fix':0, 'crop-fix':0, 'regen':0, 'wand':0, 'other':0 };
+ items.forEach(it => { counts[genKind(it.generator)]++; });
+ $('stat-total').textContent = items.length;
+ $('stat-smart').textContent = counts['smart-fix'];
+ $('stat-fix').textContent = counts['crop-fix'];
+ $('stat-wand').textContent = counts['wand'];
+ $('stat-regen').textContent = counts['regen'];
+ $('meta').textContent = `${items.length} shown · window=${HOURS}h · auto-refresh 10s`;
+
+ const grid = $('grid');
+ grid.innerHTML = '';
+ if (!items.length) { $('empty').style.display = ''; return; }
+ $('empty').style.display = 'none';
+ const frag = document.createDocumentFragment();
+ for (const it of items) {
+ const k = genKind(it.generator);
+ const card = document.createElement('a');
+ card.className = 'card';
+ card.href = `/design/${it.id}`;
+ card.target = '_blank';
+ const ts = new Date(it.created_at);
+ const ago = humanAgo(ts);
+ card.innerHTML = `
+ <div class="thumb">
+ <img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}">
+ <div class="id">#${it.id}</div>
+ <div class="gen-pill gen-${k}">${pillLabel(k)}</div>
+ </div>
+ <div class="meta-row">
+ <span class="cat">${it.category || '—'}</span>
+ <span class="ts">${ago} · ${it.dominant_hex || ''}</span>
+ </div>
+ `;
+ frag.appendChild(card);
+ }
+ grid.appendChild(frag);
+ } catch (e) {
+ console.warn('refresh failed:', e.message);
+ }
+ }
+
+ function humanAgo(ts) {
+ const sec = Math.max(0, Math.round((Date.now() - ts.getTime()) / 1000));
+ if (sec < 60) return `${sec}s ago`;
+ if (sec < 3600) return `${Math.round(sec/60)}m ago`;
+ if (sec < 86400) return `${Math.round(sec/3600)}h ago`;
+ return `${Math.round(sec/86400)}d ago`;
+ }
+
+ refresh();
+ setInterval(refresh, 10000);
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/seamless-agent.js b/scripts/seamless-agent.js
new file mode 100644
index 0000000..9cef984
--- /dev/null
+++ b/scripts/seamless-agent.js
@@ -0,0 +1,270 @@
+#!/usr/bin/env node
+// seamless-agent — autonomous loop that audits every generated image and
+// ensures it conforms to one of THREE valid formats:
+//
+// 1. seamless_tile → must tile edge-to-edge with no visible seam
+// 2. mural → single full-width-x-height scene, intentional non-tile
+// 3. mural_panel → like mural, but designed to be one panel of a multi-panel set
+//
+// Pipeline per design:
+// 1. Fetch unverified design from PG (no seamless_audit_at timestamp)
+// 2. Inspect kind. If seamless_tile → run a PIL seam-discontinuity check.
+// If mural/mural_panel → verify aspect matches width_in/height_in and
+// image is coherent (not a botched tile).
+// 3. If seamless_tile FAILS check: call /api/design/:id/smart-fix to repair.
+// This also exercises the new verify+retry+seamlessify post-process pass.
+// 4. Mark verified (write to data/seamless-audit.jsonl + UPDATE PG).
+// 5. Log everything to data/seamless-agent.log + appendFixEvent for the
+// /admin/fixes-feed live gallery.
+// 6. Sleep CADENCE_MS, repeat. Runs forever (pm2 / nohup wrapper).
+//
+// Safety:
+// - Skips already-verified designs (resumable)
+// - Skips items whose source PNG is missing
+// - Per-round CAP so the loop doesn't burn an hour on one bad batch
+// - Max attempts per id before giving up
+//
+// Usage:
+// nohup node scripts/seamless-agent.js > logs/seamless-agent.log 2>&1 &
+// node scripts/seamless-agent.js --once --limit 5 # smoke
+// node scripts/seamless-agent.js --dry-run # no smart-fix calls
+
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { psqlQuery, psqlExecLocal } = require('../lib/db.js');
+
+const ROOT = path.join(__dirname, '..');
+const AUDIT_F = path.join(ROOT, 'data', 'seamless-audit.jsonl');
+const SERVER = process.env.WALLCO_SERVER || 'http://127.0.0.1:9877';
+
+const ARGS = process.argv.slice(2);
+const ONCE = ARGS.includes('--once');
+const DRY = ARGS.includes('--dry-run');
+const LIMIT = (() => { const i = ARGS.indexOf('--limit'); return i >= 0 ? parseInt(ARGS[i+1], 10) : 0; })();
+const ROUND = LIMIT > 0 ? LIMIT : parseInt(process.env.SEAMLESS_ROUND_SIZE || '8', 10);
+const CADENCE = parseInt(process.env.SEAMLESS_CADENCE_MS || '30000', 10);
+
+function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); }
+
+// Load already-audited ids so we resume cleanly
+function loadAudited() {
+ if (!fs.existsSync(AUDIT_F)) return new Set();
+ const ids = new Set();
+ for (const line of fs.readFileSync(AUDIT_F, 'utf8').split('\n')) {
+ if (!line.trim()) continue;
+ try { ids.add(JSON.parse(line).id); } catch {}
+ }
+ return ids;
+}
+
+function fetchBatch(excludeIds, n) {
+ // Pick the freshest published wallco.ai designs that haven't been audited.
+ // Skip items already in the ghost-flagged set (they're being handled there).
+ const excludeClause = excludeIds.size
+ ? `AND id NOT IN (${[...excludeIds].slice(0, 50000).join(',')})`
+ : '';
+ const sql = `
+ SELECT id, kind, category, width_in, height_in, local_path, generator
+ FROM spoon_all_designs
+ WHERE is_published = TRUE
+ AND brand = 'wallco.ai'
+ AND local_path IS NOT NULL
+ ${excludeClause}
+ ORDER BY id DESC
+ LIMIT ${n};
+ `;
+ const raw = psqlQuery(sql);
+ const rows = [];
+ for (const line of raw.split('\n')) {
+ if (!line.trim()) continue;
+ const cols = line.split('|');
+ rows.push({
+ id: parseInt(cols[0], 10),
+ kind: cols[1],
+ category: cols[2],
+ width_in: cols[3] ? parseFloat(cols[3]) : null,
+ height_in: cols[4] ? parseFloat(cols[4]) : null,
+ local_path: cols[5],
+ generator: cols[6],
+ });
+ }
+ return rows.filter(r => Number.isFinite(r.id));
+}
+
+// PIL-based seam discontinuity check.
+// Returns { ok: bool, score: float, reason: str }
+// score = mean gradient magnitude at the wraparound seam (low = good)
+function checkSeamlessness(pngPath) {
+ const py = spawnSync('python3', ['-c', `
+import sys
+try:
+ from PIL import Image
+except Exception as e:
+ print('PIL_MISSING', str(e), file=sys.stderr); sys.exit(2)
+img = Image.open(sys.argv[1]).convert('RGB')
+W, H = img.size
+px = img.load()
+def diff(a, b): return abs(a[0]-b[0]) + abs(a[1]-b[1]) + abs(a[2]-b[2])
+# Left edge vs right edge
+left_right = sum(diff(px[0, y], px[W-1, y]) for y in range(0, H, 4)) / (H/4)
+# Top edge vs bottom edge
+top_bot = sum(diff(px[x, 0], px[x, H-1]) for x in range(0, W, 4)) / (W/4)
+# Higher = more seam discontinuity. >40 is bad for flat screenprints.
+import json
+print(json.dumps({
+ 'left_right_diff': round(left_right, 2),
+ 'top_bot_diff': round(top_bot, 2),
+ 'score': round((left_right + top_bot) / 2, 2),
+}))
+`, pngPath], { encoding: 'utf8', timeout: 30000 });
+ if (py.status !== 0) {
+ return { ok: false, score: 999, reason: 'pil error: ' + (py.stderr || '').slice(0, 120) };
+ }
+ let out;
+ try { out = JSON.parse(py.stdout); }
+ catch { return { ok: false, score: 999, reason: 'parse fail: ' + py.stdout.slice(0, 120) }; }
+ // Threshold: score < 30 = passes (low discontinuity at seams)
+ const ok = out.score < 30;
+ return {
+ ok,
+ score: out.score,
+ reason: ok ? `seams_clean (Δ=${out.score})` : `seams_break (Δ=${out.score} lr=${out.left_right_diff} tb=${out.top_bot_diff})`,
+ };
+}
+
+// Mural / mural_panel coherence check: file exists + reasonable size + no cut-off composition.
+// Cut-off = a primary motif visibly clipped at the image edge (Gemini image-edit produces
+// these — observed on id=39317 'recolor_*' files where the design ends mid-element).
+function checkMural(pngPath, widthIn, heightIn) {
+ try {
+ const stat = fs.statSync(pngPath);
+ if (stat.size < 50_000) return { ok: false, reason: 'tiny_png<50kb' };
+ const cut = checkCutoff(pngPath);
+ if (!cut.ok) return cut;
+ return { ok: true, reason: `mural ok (${(stat.size/1024).toFixed(0)}kb)` };
+ } catch (e) { return { ok: false, reason: 'no_file' }; }
+}
+
+// Cut-off detection: look for HIGH gradient density right at the image boundary
+// (top 6 rows, bottom 6 rows, left 6 cols, right 6 cols). A clean composition
+// has low gradient at boundaries (background); a cut-off design has motif edges
+// abruptly truncated → high gradient at boundary. Threshold tuned empirically.
+function checkCutoff(pngPath) {
+ const py = spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+img = Image.open(sys.argv[1]).convert('RGB')
+W, H = img.size
+px = img.load()
+def lum(p): return 0.299*p[0] + 0.587*p[1] + 0.114*p[2]
+band = 6
+def edge_grad(coords):
+ s = 0; n = 0
+ for x, y, x2, y2 in coords:
+ if 0 <= x < W and 0 <= y < H and 0 <= x2 < W and 0 <= y2 < H:
+ s += abs(lum(px[x,y]) - lum(px[x2,y2])); n += 1
+ return s / max(1, n)
+# Sample gradient along each boundary band (just inside)
+top = edge_grad([(x, band-1, x, band) for x in range(0, W, 4)])
+bot = edge_grad([(x, H-band, x, H-band-1) for x in range(0, W, 4)])
+left = edge_grad([(band-1, y, band, y) for y in range(0, H, 4)])
+right = edge_grad([(W-band, y, W-band-1, y) for y in range(0, H, 4)])
+# Mean gradient AT the boundary itself (vs just inside): boundary clip = high jump
+def boundary_jump(coords):
+ s = 0; n = 0
+ for x, y in coords:
+ if 0 <= x < W and 0 <= y < H:
+ s += lum(px[x, y]); n += 1
+ return s / max(1, n)
+print(json.dumps({
+ 'top_grad': round(top, 1), 'bot_grad': round(bot, 1),
+ 'left_grad': round(left, 1), 'right_grad': round(right, 1),
+ 'max_grad': round(max(top, bot, left, right), 1),
+}))
+`, pngPath], { encoding: 'utf8', timeout: 30000 });
+ if (py.status !== 0) return { ok: true, reason: 'cutoff_check_skipped' };
+ let out;
+ try { out = JSON.parse(py.stdout); }
+ catch { return { ok: true, reason: 'cutoff_check_parsefail' }; }
+ // If gradient at any edge > 60, likely cut-off (a clean background bleed is < 20)
+ if (out.max_grad > 60) {
+ return { ok: false, reason: `cut_off_at_edge (max_grad=${out.max_grad} top=${out.top_grad} bot=${out.bot_grad} l=${out.left_grad} r=${out.right_grad})` };
+ }
+ return { ok: true, reason: `composition_clean (max_grad=${out.max_grad})` };
+}
+
+async function callSmartFix(id) {
+ const r = await fetch(`${SERVER}/api/design/${id}/smart-fix`, { method: 'POST' });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || `http ${r.status}`);
+ return j;
+}
+
+async function processOne(d) {
+ const kind = (d.kind || '').toLowerCase();
+ let check;
+ if (!d.local_path || !fs.existsSync(d.local_path)) {
+ return { id: d.id, status: 'skip_no_png', kind };
+ }
+ if (kind === 'seamless_tile') check = checkSeamlessness(d.local_path);
+ else if (kind === 'mural' || kind === 'mural_panel') check = checkMural(d.local_path, d.width_in, d.height_in);
+ else check = { ok: true, score: 0, reason: `unknown_kind=${kind} (passthrough)` };
+
+ if (check.ok) {
+ return { id: d.id, kind, status: 'pass', score: check.score || 0, reason: check.reason };
+ }
+ // Failed seamlessness — repair via Smart Fix (only for seamless_tile)
+ if (kind !== 'seamless_tile') {
+ return { id: d.id, kind, status: 'fail_no_repair', reason: check.reason };
+ }
+ if (DRY) {
+ return { id: d.id, kind, status: 'dry_run_would_fix', reason: check.reason };
+ }
+ try {
+ const fix = await callSmartFix(d.id);
+ return {
+ id: d.id, kind, status: 'fixed',
+ score_before: check.score, reason: check.reason,
+ new_id: fix.new_id, elapsed_s: fix.elapsed_s,
+ motif: (fix.motif || '').slice(0, 200),
+ verify: fix.verify,
+ };
+ } catch (e) {
+ return { id: d.id, kind, status: 'fix_error', reason: check.reason, error: e.message.slice(0, 200) };
+ }
+}
+
+async function round() {
+ const audited = loadAudited();
+ const batch = fetchBatch(audited, ROUND);
+ if (!batch.length) {
+ log(`no fresh designs to audit (audited=${audited.size}) — sleeping`);
+ return { processed: 0, audited: audited.size };
+ }
+ log(`round start · batch=${batch.length} · audited_so_far=${audited.size}`);
+ let pass = 0, fixed = 0, errs = 0;
+ for (const d of batch) {
+ const r = await processOne(d);
+ fs.appendFileSync(AUDIT_F, JSON.stringify({ ts: new Date().toISOString(), ...r }) + '\n');
+ if (r.status === 'pass') pass++;
+ else if (r.status === 'fixed') fixed++;
+ else if (r.status === 'fix_error') errs++;
+ log(` id=${r.id} kind=${r.kind} ${r.status} ${r.reason || ''} ${r.new_id ? '→ #' + r.new_id : ''}`);
+ }
+ log(`round end · pass=${pass} fixed=${fixed} errs=${errs}`);
+ return { processed: batch.length, pass, fixed, errs };
+}
+
+(async () => {
+ log(`seamless-agent start · cadence=${CADENCE}ms round=${ROUND} dry=${DRY} once=${ONCE}`);
+ while (true) {
+ try { await round(); }
+ catch (e) { log(`round error: ${e.message}`); }
+ if (ONCE) break;
+ await new Promise(r => setTimeout(r, CADENCE));
+ }
+ log('seamless-agent done');
+})().catch(e => { log('FATAL ' + e.message); process.exit(1); });
← 67eac74 fix(ghost-detector): vendor-category filter on PG source pat
·
back to Wallco Ai
·
/design/:id → 302 to newest published child via parent_desig f5a6676 →