| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Hallmark — provenance for AI media</title> |
| <style> |
| :root{ |
| --bg:#0d0e12; --panel:#15171d; --line:#24272f; --ink:#e8e9ed; |
| --muted:#8d93a1; --accent:#e2523f; --ok:#3fb27f; --warn:#d9a441; |
| --mono:ui-monospace,SFMono-Regular,Menlo,monospace; |
| } |
| *{box-sizing:border-box} |
| body{margin:0;background:var(--bg);color:var(--ink); |
| font:15px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif} |
| header{border-bottom:1px solid var(--line);padding:22px 24px;display:flex; |
| align-items:baseline;gap:14px;flex-wrap:wrap} |
| h1{font-size:19px;margin:0;letter-spacing:-.01em} |
| h1 span{color:var(--accent)} |
| .sub{color:var(--muted);font-size:13px} |
| .badge{margin-left:auto;font:12px var(--mono);color:var(--muted); |
| border:1px solid var(--line);border-radius:999px;padding:3px 10px} |
| nav{display:flex;gap:2px;padding:0 24px;border-bottom:1px solid var(--line)} |
| nav button{background:none;border:0;border-bottom:2px solid transparent;color:var(--muted); |
| padding:12px 16px;font-size:14px;cursor:pointer} |
| nav button[aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)} |
| main{max-width:960px;margin:0 auto;padding:28px 24px 72px} |
| .row{display:flex;gap:10px;flex-wrap:wrap} |
| textarea,input[type=text]{width:100%;background:var(--panel);border:1px solid var(--line); |
| border-radius:8px;color:var(--ink);padding:11px 13px;font:inherit;resize:vertical} |
| textarea:focus,input:focus{outline:1px solid var(--accent);border-color:var(--accent)} |
| button.go{background:var(--accent);border:0;border-radius:8px;color:#fff; |
| padding:11px 20px;font-size:14px;font-weight:600;cursor:pointer} |
| button.go[disabled]{opacity:.5;cursor:default} |
| select{background:var(--panel);border:1px solid var(--line);border-radius:8px; |
| color:var(--ink);padding:11px;font:inherit} |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:12px; |
| padding:18px;margin-top:20px} |
| .kv{display:grid;grid-template-columns:150px 1fr;gap:7px 16px;font:12.5px var(--mono); |
| word-break:break-all;margin-top:14px} |
| .kv dt{color:var(--muted)} |
| .kv dd{margin:0} |
| img.out{max-width:100%;border-radius:10px;display:block;border:1px solid var(--line)} |
| .pill{display:inline-block;font:11px var(--mono);border-radius:999px;padding:3px 10px; |
| text-transform:uppercase;letter-spacing:.06em} |
| .pill.ok{background:rgba(63,178,127,.14);color:var(--ok)} |
| .pill.warn{background:rgba(217,164,65,.14);color:var(--warn)} |
| .pill.no{background:rgba(226,82,63,.14);color:var(--accent)} |
| .muted{color:var(--muted)} |
| .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;margin-top:20px} |
| .grid figure{margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden} |
| .grid img{width:100%;display:block;aspect-ratio:1;object-fit:cover} |
| .grid figcaption{padding:9px 11px;font-size:12px;color:var(--muted); |
| overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .drop{border:1.5px dashed var(--line);border-radius:12px;padding:38px;text-align:center; |
| color:var(--muted);cursor:pointer} |
| .drop.hot{border-color:var(--accent);color:var(--ink)} |
| .err{border-color:var(--accent);color:#ffb4a8} |
| .hide{display:none} |
| .lede{color:var(--muted);max-width:64ch} |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>Hall<span>mark</span></h1> |
| <span class="sub">provenance-first AI media, on Genblaze + Backblaze B2</span> |
| <span class="badge" id="health">checking…</span> |
| </header> |
|
|
| <nav> |
| <button id="t-studio" aria-selected="true" onclick="tab('studio')">Studio</button> |
| <button id="t-verify" aria-selected="false" onclick="tab('verify')">Verify</button> |
| <button id="t-library" aria-selected="false" onclick="tab('library')">Library</button> |
| </nav> |
|
|
| <main> |
| |
| <section id="studio"> |
| <p class="lede">Generate an image. Hallmark stores the bytes and a canonical, |
| hash-verified provenance manifest in Backblaze B2, so the origin of the |
| file stays checkable long after it leaves this page.</p> |
| <div class="card"> |
| <textarea id="prompt" rows="3" placeholder="a lighthouse in fog at dawn, 35mm film"></textarea> |
| <div class="row" style="margin-top:12px"> |
| <select id="model"><option value="flux">flux — quality</option><option value="turbo">turbo — fast</option></select> |
| <select id="size"> |
| <option value="1024">1024 × 1024</option> |
| <option value="768">768 × 768</option> |
| <option value="1280">1280 × 1280</option> |
| </select> |
| <button class="go" id="mintBtn" onclick="mint()">Generate & hallmark</button> |
| </div> |
| </div> |
| <div id="mintOut"></div> |
| </section> |
|
|
| |
| <section id="verify" class="hide"> |
| <p class="lede">Drop any image here. Hallmark hashes the bytes and looks that hash |
| up in the B2 registry, and separately reads any manifest embedded in the file |
| itself. The two answers are reported separately — a registry hit proves origin, |
| an embedded manifest alone does not.</p> |
| <div class="drop" id="drop" onclick="document.getElementById('file').click()"> |
| Drop a file, or click to choose |
| </div> |
| <input type="file" id="file" class="hide" accept="image/*" onchange="verifyFile(this.files[0])"> |
| <div id="verifyOut"></div> |
| </section> |
|
|
| |
| <section id="library" class="hide"> |
| <p class="lede">Everything this instance has minted, read back out of B2.</p> |
| <div id="libOut" class="grid"></div> |
| </section> |
| </main> |
|
|
| <script> |
| const $ = (id) => document.getElementById(id); |
| const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); |
| const short = (s, n = 20) => !s ? '—' : (s.length > n * 2 ? s.slice(0, n) + '…' + s.slice(-8) : s); |
| |
| function tab(name){ |
| for (const t of ['studio','verify','library']) { |
| $(t).classList.toggle('hide', t !== name); |
| $('t-' + t).setAttribute('aria-selected', String(t === name)); |
| } |
| if (name === 'library') loadLibrary(); |
| } |
| |
| async function health(){ |
| try{ |
| const h = await (await fetch('/api/health')).json(); |
| $('health').textContent = h.b2_configured |
| ? `B2 ${esc(h.bucket)} · ${esc(h.region)}` |
| : 'B2 not configured'; |
| $('health').style.color = h.b2_configured ? '' : 'var(--accent)'; |
| }catch{ $('health').textContent = 'offline'; } |
| } |
| health(); |
| |
| async function mint(){ |
| const prompt = $('prompt').value.trim(); |
| if (prompt.length < 3) return; |
| const size = Number($('size').value); |
| $('mintBtn').disabled = true; |
| $('mintOut').innerHTML = '<div class="card muted">Generating, hashing, uploading to B2…</div>'; |
| try{ |
| const r = await fetch('/api/mint', { |
| method:'POST', headers:{'Content-Type':'application/json'}, |
| body: JSON.stringify({prompt, model:$('model').value, width:size, height:size}) |
| }); |
| const d = await r.json(); |
| if(!r.ok) throw new Error(d.detail || ('HTTP ' + r.status)); |
| $('mintOut').innerHTML = ` |
| <div class="card"> |
| <img class="out" src="${esc(d.asset_url)}" alt=""> |
| <div style="margin-top:14px"> |
| <span class="pill ${d.verified?'ok':'warn'}">${d.verified?'manifest verified':'unverified'}</span> |
| </div> |
| <dl class="kv"> |
| <dt>prompt</dt><dd>${esc(d.prompt)}</dd> |
| <dt>model</dt><dd>flux-open / ${esc(d.model)}</dd> |
| <dt>asset sha-256</dt><dd>${esc(d.sha256)}</dd> |
| <dt>manifest hash</dt><dd>${esc(d.canonical_hash)}</dd> |
| <dt>manifest (B2)</dt><dd>${esc(d.manifest_uri || '—')}</dd> |
| <dt>run id</dt><dd>${esc(d.run_id)}</dd> |
| </dl> |
| </div>`; |
| }catch(e){ |
| $('mintOut').innerHTML = `<div class="card err">${esc(e.message)}</div>`; |
| }finally{ $('mintBtn').disabled = false; } |
| } |
| |
| const drop = $('drop'); |
| ['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { |
| e.preventDefault(); drop.classList.add('hot'); |
| })); |
| ['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { |
| e.preventDefault(); drop.classList.remove('hot'); |
| })); |
| drop.addEventListener('drop', e => { if (e.dataTransfer.files[0]) verifyFile(e.dataTransfer.files[0]); }); |
| |
| async function verifyFile(f){ |
| if(!f) return; |
| $('verifyOut').innerHTML = '<div class="card muted">Hashing and checking the registry…</div>'; |
| const fd = new FormData(); fd.append('file', f); |
| try{ |
| const r = await fetch('/api/verify', {method:'POST', body: fd}); |
| const d = await r.json(); |
| if(!r.ok) throw new Error(d.detail || ('HTTP ' + r.status)); |
| const verdict = { |
| authentic: ['ok', 'Authentic — minted by this pipeline'], |
| embedded_manifest_only:['warn','Carries a manifest, but not in this registry'], |
| unknown: ['no', 'Unknown — no record of this file'] |
| }[d.verdict] || ['no','Unknown']; |
| const rec = d.record || {}; |
| $('verifyOut').innerHTML = ` |
| <div class="card"> |
| <span class="pill ${verdict[0]}">${esc(verdict[1])}</span> |
| <dl class="kv"> |
| <dt>file</dt><dd>${esc(d.filename)} · ${d.size_bytes.toLocaleString()} bytes</dd> |
| <dt>sha-256</dt><dd>${esc(d.sha256)}</dd> |
| <dt>registry hit</dt><dd>${d.registry_hit ? 'yes' : 'no'}</dd> |
| <dt>embedded manifest</dt><dd>${d.embedded_manifest ? 'present' : 'none'}${ |
| d.embedded_hash_valid === null ? '' : ' · hash ' + (d.embedded_hash_valid ? 'valid' : 'INVALID')}</dd> |
| ${rec.prompt ? `<dt>original prompt</dt><dd>${esc(rec.prompt)}</dd>` : ''} |
| ${rec.model ? `<dt>model</dt><dd>flux-open / ${esc(rec.model)}</dd>` : ''} |
| ${rec.created_at ? `<dt>minted</dt><dd>${new Date(rec.created_at*1000).toISOString()}</dd>` : ''} |
| </dl> |
| </div>`; |
| }catch(e){ |
| $('verifyOut').innerHTML = `<div class="card err">${esc(e.message)}</div>`; |
| } |
| } |
| |
| async function loadLibrary(){ |
| $('libOut').innerHTML = '<div class="muted">Loading…</div>'; |
| try{ |
| const d = await (await fetch('/api/library')).json(); |
| if(!d.items || !d.items.length){ $('libOut').innerHTML = '<div class="muted">Nothing minted yet.</div>'; return; } |
| $('libOut').innerHTML = d.items.map(i => ` |
| <figure> |
| <img src="${esc(i.asset_url)}" alt="" loading="lazy"> |
| <figcaption title="${esc(i.prompt)}">${esc(i.prompt)}</figcaption> |
| </figure>`).join(''); |
| }catch(e){ |
| $('libOut').innerHTML = `<div class="muted">${esc(e.message)}</div>`; |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|