| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Model comparison — xj + MJHQ-30K</title> |
| <style> |
| :root { --bg:#111; --panel:#1b1b1f; --fg:#eee; --muted:#9aa0aa; --accent:#4c9ffe; --imgw:300px; } |
| * { box-sizing: border-box; } |
| body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; |
| background: var(--bg); color: var(--fg); } |
| header { position: sticky; top:0; z-index:10; background: var(--panel); |
| padding: 10px 16px; border-bottom:1px solid #2a2a30; } |
| .row { display:flex; gap:14px; align-items:center; flex-wrap:wrap; } |
| input[type="number"], button { background:#23232a; color:var(--fg); |
| border:1px solid #34343c; border-radius:6px; padding:6px 9px; font-size:14px; } |
| button { cursor:pointer; } button:hover { border-color: var(--accent); } |
| button.rand { border-color: var(--accent); color: var(--accent); font-weight:600; } |
| button.save { background: gold; color:#111; border-color: gold; font-weight:600; } |
| button.save:disabled { opacity:.5; cursor:default; } |
| button.filter.active { background: var(--accent); color:#fff; border-color: var(--accent); font-weight:600; } |
| button.filter:disabled { opacity:.45; cursor:default; } |
| #status { color: var(--muted); font-size:12px; } |
| #status.err { color:#ff6b6b; } |
| #status.ok { color:#5ad17a; } |
| label { color: var(--muted); font-size:13px; } |
| input[type="range"] { vertical-align: middle; accent-color: var(--accent); } |
| #counter { font-variant-numeric: tabular-nums; min-width: 140px; display:inline-block; } |
| #prompt { padding:10px 16px; color:#dfe3ea; background:#16161a; border-bottom:1px solid #2a2a30; |
| max-height:120px; overflow:auto; white-space:pre-wrap; line-height:1.4; font-size:14px; } |
| #prompt .pidx { color: var(--accent); font-weight:600; margin-right:8px; } |
| #grid { display:grid; grid-template-columns: repeat(4, var(--imgw)); gap:16px; |
| padding:16px; justify-content:start; overflow-x:auto; } |
| .cell { text-align:center; } |
| .cell .name { color:#cfd3da; font-size:14px; font-weight:600; margin-bottom:6px; } |
| .cell .box { width: var(--imgw); height: var(--imgw); border:1px solid #2a2a30; border-radius:6px; |
| background:#000; display:flex; align-items:center; justify-content:center; overflow:hidden; } |
| .cell img { width:100%; height:100%; object-fit:contain; cursor:zoom-in; } |
| .cell .ph { color:#555; font-size:13px; padding:10px; } |
| .hint { color: var(--muted); font-size:12px; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <div class="row"> |
| <button id="prev">◀ Prev</button> |
| <button id="next">Next ▶</button> |
| <button id="random" class="rand">🎲 Random</button> |
| <button id="save" class="save">★ Save</button> |
| <button id="filter" class="filter" title="Cycle through indices in selected/">Selected only</button> |
| <input type="range" id="slider" min="0" value="0"> |
| <span id="counter"></span> |
| <label>prompt #</label> |
| <input type="number" id="idxInput" min="0" style="width:90px"> |
| <label>size</label> |
| <input type="range" id="size" min="160" max="560" step="20" value="300" style="width:120px"> |
| <span class="hint">← / → step · r = random · s = save</span> |
| <span id="status"></span> |
| </div> |
| </header> |
| <div id="prompt"></div> |
| <div id="grid"></div> |
|
|
| <script> |
| let M = null, pos = 0; |
| let selectedIds = []; |
| let filterActive = false; |
| const $ = id => document.getElementById(id); |
| const pad = (n, w) => String(n).padStart(w, '0'); |
| const active = () => (filterActive && selectedIds.length) ? selectedIds : M.available; |
| function updateFilterBtn() { |
| const b = $('filter'); |
| b.textContent = `Selected only (${selectedIds.length})`; |
| b.disabled = selectedIds.length === 0; |
| b.classList.toggle('active', filterActive && selectedIds.length > 0); |
| } |
| async function refreshSelected() { |
| try { |
| const r = await fetch('/api/selected', {cache:'no-store'}); |
| if (r.ok) selectedIds = (await r.json()).ids || []; |
| } catch (e) { selectedIds = []; } |
| updateFilterBtn(); |
| } |
| |
| async function loadManifest() { |
| |
| for (const url of ['/api/manifest', 'manifest.json']) { |
| try { |
| const r = await fetch(url, {cache: 'no-store'}); |
| if (r.ok) return await r.json(); |
| } catch (e) { } |
| } |
| throw new Error('no manifest'); |
| } |
| |
| async function init() { |
| try { |
| M = await loadManifest(); |
| } catch (e) { |
| $('prompt').textContent = 'Failed to load manifest — run "python test/xj_mjhq30k_inference_outputs/viewer/viewer.py".'; |
| return; |
| } |
| if (!M.available.length) { $('prompt').textContent = 'No generated images found yet.'; return; } |
| await refreshSelected(); |
| syncSlider(); |
| $('prev').onclick = () => go(pos - 1); |
| $('next').onclick = () => go(pos + 1); |
| $('random').onclick = () => go(Math.floor(Math.random() * active().length)); |
| $('save').onclick = saveSelection; |
| $('filter').onclick = toggleFilter; |
| $('slider').oninput = e => go(+e.target.value); |
| $('idxInput').onchange = e => goToPromptIdx(+e.target.value); |
| $('size').oninput = e => document.documentElement.style.setProperty('--imgw', e.target.value + 'px'); |
| document.addEventListener('keydown', e => { |
| if (e.target.tagName === 'INPUT') return; |
| if (e.key === 'ArrowRight') { go(pos + 1); e.preventDefault(); } |
| else if (e.key === 'ArrowLeft') { go(pos - 1); e.preventDefault(); } |
| else if (e.key === 'r' || e.key === 'R') { go(Math.floor(Math.random() * active().length)); } |
| else if (e.key === 's' || e.key === 'S') { saveSelection(); } |
| }); |
| go(0); |
| } |
| |
| function syncSlider() { $('slider').max = Math.max(0, active().length - 1); } |
| |
| function toggleFilter() { |
| if (!selectedIds.length) return; |
| |
| |
| const currentIdx = active()[pos]; |
| filterActive = !filterActive; |
| updateFilterBtn(); |
| syncSlider(); |
| goToPromptIdx(currentIdx); |
| } |
| |
| function setStatus(msg, cls) { const s = $('status'); s.textContent = msg; s.className = cls || ''; } |
| |
| async function saveSelection() { |
| const idx = M.available[pos]; |
| $('save').disabled = true; |
| setStatus('saving…'); |
| try { |
| const r = await fetch('/api/save', { |
| method: 'POST', headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({idx}), |
| }); |
| const j = await r.json(); |
| if (j.ok) { |
| setStatus(`saved #${idx} → ${j.path} (${j.saved_models.join(', ')})`, 'ok'); |
| await refreshSelected(); |
| if (filterActive) syncSlider(); |
| } else setStatus(`save failed: ${j.error}`, 'err'); |
| } catch (e) { |
| setStatus(`save failed — run viewer.py (not http.server): ${e}`, 'err'); |
| } finally { |
| $('save').disabled = false; |
| } |
| } |
| |
| |
| function goToPromptIdx(want) { |
| const list = active(); |
| if (!list.length) return; |
| let lo = 0, hi = list.length - 1, best = 0; |
| while (lo <= hi) { const mid = (lo+hi)>>1; |
| if (list[mid] <= want) { best = mid; lo = mid+1; } else hi = mid-1; } |
| go(best); |
| } |
| |
| function go(p) { |
| const list = active(); |
| if (!list.length) return; |
| pos = Math.max(0, Math.min(p, list.length - 1)); |
| const idx = list[pos]; |
| $('slider').value = pos; |
| $('idxInput').value = idx; |
| const tag = (filterActive && selectedIds.length) ? 'selected' : 'generated'; |
| $('counter').textContent = `#${idx} (${pos + 1}/${list.length} ${tag})`; |
| $('prompt').innerHTML = `<span class="pidx">#${idx}</span>` + |
| (M.prompts[idx] || '<em style="color:#666">(no prompt text)</em>'); |
| |
| const grid = $('grid'); |
| grid.innerHTML = ''; |
| for (const m of M.models) { |
| const src = `${M.rel_prefix}/${m.dir}/${pad(idx, M.pad)}.png`; |
| const cell = document.createElement('div'); |
| cell.className = 'cell'; |
| cell.innerHTML = `<div class="name">${m.label || m.name}</div>`; |
| const box = document.createElement('div'); |
| box.className = 'box'; |
| const img = document.createElement('img'); |
| img.loading = 'lazy'; |
| img.src = src; |
| img.title = `${m.label || m.name} — ${pad(idx, M.pad)}.png`; |
| img.onclick = () => window.open(src, '_blank'); |
| img.onerror = () => { box.innerHTML = '<div class="ph">not generated yet</div>'; }; |
| box.appendChild(img); |
| cell.appendChild(box); |
| grid.appendChild(cell); |
| } |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|