← back to Wallco Ai
public/admin/seam-debug.html
322 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Seam-defect debugger — wallco.ai admin</title>
<style>
:root{--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;--bad:#ff4d4d;--ok:#7bc96f;--warn:#f0b400;}
*{box-sizing:border-box;}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
header{position:sticky;top:0;z-index:30;background:rgba(14,15,16,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:9px 14px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
h1{font-size:15px;margin:0;font-weight:650;}
input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 9px;font:13px ui-monospace,monospace;width:8em;}
button{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 10px;font-size:13px;cursor:pointer;}
button:hover{border-color:var(--ok);}
.verdict{font-weight:700;padding:3px 11px;border-radius:999px;font-size:12px;letter-spacing:.04em;}
.v-PASS{background:#143b1d;color:var(--ok);border:1px solid #1f5a2a;}
.v-WARN{background:#3b2e08;color:var(--warn);border:1px solid #5a4710;}
.v-FAIL{background:#3b0e0e;color:var(--bad);border:1px solid #5a1515;}
main{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;padding:14px;}
@media (max-width:900px){ main{grid-template-columns:1fr;} }
.stage{background:#000;border:1px solid var(--line);border-radius:10px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;min-height:520px;}
.stage img{display:block;max-width:100%;max-height:80vh;height:auto;}
.overlay{position:absolute;pointer-events:none;}
.seam-line{position:absolute;}
.seam-line.mid{background:rgba(123,201,111,.55);}
.seam-line.edge{background:rgba(74,163,255,.55);}
.seam-line.v{width:2px;top:0;bottom:0;}
.seam-line.h{height:2px;left:0;right:0;}
.box{position:absolute;border:2px solid var(--bad);background:rgba(255,77,77,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,77,77,.45);cursor:pointer;pointer-events:auto;}
.box.edge{border-color:#ff9a3c;background:rgba(255,154,60,.18);box-shadow:0 0 0 1px rgba(0,0,0,.65) inset, 0 0 14px rgba(255,154,60,.45);}
.box.sel{border-color:#7bc96f;background:rgba(123,201,111,.32);box-shadow:0 0 0 2px #7bc96f inset, 0 0 18px rgba(123,201,111,.6);}
.box .lbl{position:absolute;top:-18px;left:0;background:var(--bad);color:#fff;font:11px/1 ui-monospace,monospace;padding:2px 5px;border-radius:3px;white-space:nowrap;}
.box.edge .lbl{background:#ff9a3c;color:#1a1a1a;}
.box.sel .lbl{background:#7bc96f;color:#0a0b0c;}
.overlay{pointer-events:none} /* re-enable on .box only */
button.heal{background:#143b1d;color:#7bc96f;border:1px solid #2a6b35;border-radius:7px;padding:5px 11px;font-size:13px;cursor:pointer;font-weight:700}
button.heal:disabled{opacity:.4;cursor:not-allowed}
button.heal:not(:disabled):hover{background:#7bc96f;color:#0a0b0c;}
aside{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;align-self:start;}
aside h2{font-size:13px;margin:0 0 8px;letter-spacing:.05em;text-transform:uppercase;color:var(--mut);font-weight:600;}
aside .row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px dashed var(--line);}
aside .row:last-child{border-bottom:none;}
aside .row b{font-variant-numeric:tabular-nums;}
.summ{padding:10px;background:#0a0b0c;border:1px solid var(--line);border-radius:7px;color:var(--ink);font-size:12.5px;line-height:1.5;margin-top:10px;}
.legend{margin-top:14px;font-size:11.5px;color:var(--mut);}
.legend .sw{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:middle;}
#err{color:#ff9a9a;padding:60px 16px;text-align:center;}
.toggle{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--mut);cursor:pointer;}
.lens-FAIL{color:var(--bad);} .lens-WARN{color:var(--warn);} .lens-PASS{color:var(--ok);}
</style>
</head>
<body>
<header>
<h1>🔍 Seam-defect debugger</h1>
<span>ID</span><input id="idin" type="text" value="">
<button onclick="load()">Scan</button>
<span id="verdict" class="verdict v-PASS" style="display:none"></span>
<span class="pill" id="dims" style="font-size:12px;color:var(--mut)"></span>
<label class="toggle"><input type="checkbox" id="t_box" checked>boxes</label>
<label class="toggle"><input type="checkbox" id="t_seam" checked>seam-lines</label>
<button class="heal" id="healbtn" disabled title="click boxes to select, then heal only those regions">⚕ Heal selected (0)</button>
<button class="heal" id="healall" style="background:#3b2e08;color:#f0b400;border-color:#5a4710" title="heal every detected defect">⚕ Heal all</button>
<button class="heal" id="autofixbtn" style="background:#143b1d;color:#9be7a0;border-color:#2a6b35" title="auto seam-heal the whole tile in place (fix-seam.py — preserves composition, reversible via __preseam backup). No box selection needed.">⚕ Fix (auto seam-heal)</button>
<button class="heal" id="regenbtn" style="background:#1d2c3b;color:#7ec8ff;border-color:#365778" title="REDO — regenerate from the original prompt with a new seed — preview before approve">⟲ Redo (regenerate)</button>
<a id="openraw" style="font-size:12px;color:var(--mut);text-decoration:none" target="_blank" rel="noopener noreferrer">open raw image ↗</a>
</header>
<div id="err" style="display:none"></div>
<main id="main">
<div class="stage" id="stage">
<img id="img" alt="" />
<div class="overlay" id="ovl"></div>
</div>
<aside>
<h2>Lens scores (ΔE76)</h2>
<div id="lenses"></div>
<div class="summ" id="summ"></div>
<h2 style="margin-top:14px">Defect boxes</h2>
<div id="boxlist" style="font-size:12px;font-family:ui-monospace,monospace;max-height:38vh;overflow:auto"></div>
<div class="legend">
<div><span class="sw" style="background:rgba(255,77,77,.6);border:1px solid var(--bad)"></span>internal joint (h_mid / v_mid — latent-grid)</div>
<div><span class="sw" style="background:rgba(255,154,60,.6);border:1px solid #ff9a3c"></span>edge joint (top↔bottom · left↔right wrap)</div>
<div><span class="sw" style="background:rgba(123,201,111,.5)"></span>internal mid-line · <span class="sw" style="background:rgba(74,163,255,.5)"></span>edge-wrap line</div>
<div style="margin-top:6px;color:var(--mut)">PASS ≤ 5 · WARN ≤ 12 · FAIL > 12. Per-row trigger 8, min run 24 px.</div>
</div>
</aside>
</main>
<script>
const ADMIN=new URLSearchParams(location.search).get('admin');
const ID0=new URLSearchParams(location.search).get('id')||location.pathname.match(/\/(\d+)$/)?.[1]||'';
const $=id=>document.getElementById(id);
// q(path) — build a fetchable URL. Anchors '/'-prefixed paths to location.origin
// so URLs are credentials-free; the Fetch spec rejects Request construction
// when the resolved URL contains userinfo (happens when the page was loaded
// via http://admin:pw@host/...). Appends ?admin=<token> when present.
const q = s => {
const path = s.startsWith('/') ? location.origin + s : s;
return ADMIN ? path + (path.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : path;
};
$('idin').value=ID0;
$('t_box').addEventListener('change',redraw);
$('t_seam').addEventListener('change',redraw);
addEventListener('keydown',e=>{ if(e.key==='Enter'&&document.activeElement===$('idin')) load(); });
let CUR=null;
let SEL=new Set(); // selected AUTO-DETECTED box indices for healing
let MANUAL=[]; // user-drawn boxes {x,y,w,h} in fractions (0..1) — always healed
function updHeal(){
const n = SEL.size + MANUAL.length;
const b = $('healbtn'); b.disabled = n === 0;
b.textContent = `⚕ Heal selected (${n})${MANUAL.length ? ' · ' + MANUAL.length + ' manual' : ''}`;
}
function lensRow(name, obj){
const score = obj.score!=null?obj.score:obj;
const v = score>12?'FAIL':score>5?'WARN':'PASS';
return `<div class="row"><span>${name}</span><b class="lens-${v}">${score.toFixed(2)}</b></div>`;
}
function redraw(){
if(!CUR) return;
const ovl=$('ovl'); ovl.innerHTML='';
const img=$('img');
const iw=img.clientWidth, ih=img.clientHeight;
const sx=iw/CUR.width, sy=ih/CUR.height;
ovl.style.left=img.offsetLeft+'px'; ovl.style.top=img.offsetTop+'px';
ovl.style.width=iw+'px'; ovl.style.height=ih+'px';
if($('t_seam').checked){
// green crosshair = internal latent-grid joints; blue lines = edge-wrap joints
const mk=(cls,style)=>{ const e=document.createElement('div'); e.className='seam-line '+cls; Object.assign(e.style,style); ovl.appendChild(e); };
mk('mid v',{left:(CUR.width/2*sx)+'px'});
mk('mid h',{top:(CUR.height/2*sy)+'px'});
mk('edge v',{left:'0px'});
mk('edge v',{left:(iw-2)+'px'});
mk('edge h',{top:'0px'});
mk('edge h',{top:(ih-2)+'px'});
}
if($('t_box').checked){
(CUR.boxes||[]).forEach((b,i)=>{
const isEdge=/_edge$/.test(b.kind);
const d=document.createElement('div'); d.className='box'+(isEdge?' edge':'')+(SEL.has(i)?' sel':'');
d.style.left=(b.x*sx)+'px'; d.style.top=(b.y*sy)+'px';
d.style.width=Math.max(2,b.w*sx)+'px'; d.style.height=Math.max(2,b.h*sy)+'px';
d.innerHTML=`<span class="lbl">${b.kind} · ΔE ${b.peak_de}</span>`;
d.title=`click to ${SEL.has(i)?'deselect':'select for healing'} · ${b.kind}${b.edge_pair?' ['+b.edge_pair+']':''} · x=${b.x} y=${b.y} w=${b.w} h=${b.h} · peak ΔE ${b.peak_de} (mean ${b.mean_de})`;
d.dataset.idx=i;
d.onclick=ev=>{ ev.stopPropagation(); if(SEL.has(i)) SEL.delete(i); else SEL.add(i); redraw(); updHeal(); };
ovl.appendChild(d);
});
}
// Manual user-drawn boxes — always rendered (independent of t_box toggle) so a
// hand-marked region is always visible while the user is curating it.
MANUAL.forEach((m,i)=>{
const d=document.createElement('div'); d.className='box manual';
d.style.cssText=`left:${m.x*iw}px;top:${m.y*ih}px;width:${m.w*iw}px;height:${m.h*ih}px;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);position:absolute;box-sizing:border-box;cursor:pointer;z-index:5`;
d.innerHTML=`<span class="lbl" style="background:#ff3b3b;color:#fff;font:800 10px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;position:absolute;top:-2px;left:-2px">M${i+1}</span>
<span class="x" style="position:absolute;top:-9px;right:-9px;width:17px;height:17px;line-height:15px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif">×</span>`;
d.title=`manual mark M${i+1} — click × to remove · always healed`;
d.querySelector('.x').onclick=ev=>{ ev.stopPropagation(); MANUAL.splice(i,1); redraw(); updHeal(); };
ovl.appendChild(d);
});
}
// Drag-to-draw a manual heal region on the image. Hand-marked boxes are added
// to MANUAL[] as fractions and always sent to the heal endpoint (don't need to
// be in SEL — they're a separate channel).
(function wireManualDraw(){
const ovl=$('ovl');
ovl.addEventListener('mousedown', e => {
if(!CUR) return;
if(e.button!==0) return;
if(e.target.closest('.box')) return; // clicking an existing box = toggle, not draw
const rect=ovl.getBoundingClientRect();
const x0=(e.clientX-rect.left)/rect.width, y0=(e.clientY-rect.top)/rect.height;
if(x0<0||x0>1||y0<0||y0>1) return;
e.preventDefault();
const ghost=document.createElement('div');
ghost.style.cssText='position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box;z-index:6';
ovl.appendChild(ghost);
const W=rect.width, H=rect.height;
const mv=ev=>{
const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.min(x0,x),T=Math.min(y0,y),WW=Math.abs(x-x0),HH=Math.abs(y-y0);
ghost.style.cssText+=`;left:${L*W}px;top:${T*H}px;width:${WW*W}px;height:${HH*H}px`;
};
const up=ev=>{
document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
const L=Math.max(0,Math.min(x0,x)),T=Math.max(0,Math.min(y0,y));
const WW=Math.min(1-L,Math.abs(x-x0)),HH=Math.min(1-T,Math.abs(y-y0));
ghost.remove();
if(WW>0.01 && HH>0.01){
MANUAL.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+WW.toFixed(4),h:+HH.toFixed(4),label:'manual-'+(MANUAL.length+1)});
redraw(); updHeal();
}
};
document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
});
})();
async function load(){
$('err').style.display='none';
const id=$('idin').value.trim(); if(!id) return;
history.replaceState(null,'',q(location.pathname.replace(/\/\d+$/,'')+'/'+id));
try{
const r=await fetch(q(`/api/seam-debug/${id}`));
const j=await r.json();
if(!r.ok||!j.ok){ $('err').style.display='block'; $('err').textContent='Failed: '+(j.error||r.status); return; }
CUR=j;
$('verdict').textContent=j.verdict; $('verdict').className='verdict v-'+j.verdict; $('verdict').style.display='inline-block';
$('dims').textContent=`${j.width}×${j.height}px`;
$('summ').textContent=j.summary;
const order=['h_mid','v_mid','top','bottom','left','right'];
$('lenses').innerHTML=order.map(k=>{
const s=j.scores[k]; const v=s>12?'FAIL':s>5?'WARN':'PASS';
return `<div class="row"><span>${k}</span><b class="lens-${v}">${s.toFixed(2)}</b></div>`;
}).join('') + `<div class="row" style="margin-top:6px;border-top:1px solid var(--line);padding-top:6px"><span>edges_max</span><b>${j.scores.edges_max.toFixed(2)}</b></div>` +
`<div class="row"><span>mids_max</span><b class="lens-${j.scores.mids_max>12?'FAIL':j.scores.mids_max>5?'WARN':'PASS'}">${j.scores.mids_max.toFixed(2)}</b></div>`;
$('boxlist').innerHTML = (j.boxes||[]).length
? j.boxes.map((b,i)=>`<div>${String(i+1).padStart(2,'0')}. <b class="lens-FAIL">${b.kind}</b> · x=${b.x} y=${b.y} ${b.w}×${b.h}px · peak ΔE ${b.peak_de} · mean ${b.mean_de}</div>`).join('')
: '<div style="color:var(--mut)">no localized defects (mid-line ΔE never exceeded trigger 8 for ≥24 px)</div>';
$('img').src=j.image_url; $('openraw').href=j.image_url;
$('img').onload=redraw;
addEventListener('resize',redraw);
}catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
}
async function healSelected(boxIds){
const manual = MANUAL.slice();
if(!boxIds.length && !manual.length){ alert('Click auto-detected boxes OR drag on the image to mark a region, then heal.'); return; }
const id=$('idin').value.trim(); if(!id) return;
const total = boxIds.length + manual.length;
const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${total}…`;
try{
const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds, manual_boxes:manual})});
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; }
showApproveModal({ kind: 'heal', src_id: id, new_id: j.new_id, scan_before: j.scan_before, scan_after: j.scan_after });
SEL.clear(); MANUAL = []; 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; }
}
// === Auto-fix handler (whole-tile seam-heal in place — no box selection) =====
// Hits /api/design/:id/fix (fix-seam.py). Preserves composition, backs the
// original up as __preseam.png, then re-runs the scan so the verdict refreshes.
async function autoFix() {
const id = $('idin').value.trim(); if (!id) return;
const btn = $('autofixbtn'); const orig = btn.textContent;
btn.disabled = true; btn.textContent = '⚕ healing…';
try {
const r = await fetch(q(`/api/design/${id}/fix`), { 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));
btn.textContent = j.fixed > 0 ? '⚕ healed ✓' : (j.already_seamless > 0 ? '⚕ already clean' : '⚕ no change');
if (j.fixed > 0) setTimeout(load, 400); // re-scan to refresh verdict + boxes
setTimeout(() => { btn.textContent = orig; }, 1600);
} catch (e) { alert('Auto-fix failed: ' + e.message); btn.textContent = orig; }
finally { btn.disabled = false; }
}
$('healbtn').addEventListener('click',()=>healSelected([...SEL]));
$('healall').addEventListener('click',()=>healSelected((CUR?.boxes||[]).map((_,i)=>i)));
$('autofixbtn').addEventListener('click',autoFix);
$('regenbtn').addEventListener('click',regenerate);
if(ID0) load();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>