SynthScope / interface /static /index.html
SMSD75's picture
Update SynthScope Space from the GitHub repository
26a7256 verified
Raw History Blame Contribute Delete
80.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SynthScope — AI-Generated Media Detector</title>
<style>
/* ============================================================================
SynthScope web interface — visual theme
Modern dark "glass dashboard": layered navy gradient, frosted panels, an
indigo→cyan accent, soft shadows, and smooth motion. Class names are kept
stable so App.jsx is untouched.
========================================================================== */
:root {
--bg: #070b16;
--bg2: #0c1326;
--panel: rgba(22, 29, 51, 0.72);
--panel-solid: #161d33;
--panel2: rgba(31, 41, 71, 0.55);
--line: rgba(120, 140, 190, 0.16);
--line-strong: rgba(120, 140, 190, 0.30);
--text: #eef2fb;
--muted: #9aa7c7;
--muted2: #6b7796;
--accent: #7c8cff;
--accent2: #46d6ff;
--accent-grad: linear-gradient(135deg, #7c8cff 0%, #46d6ff 100%);
--real: #34d399;
--fake: #ff6b7d;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.65);
--shadow-lg: 0 24px 60px -20px rgba(0, 0, 0, 0.75);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
background:
radial-gradient(1100px 600px at 85% -10%, rgba(70, 214, 255, 0.10), transparent 60%),
radial-gradient(1000px 700px at -5% 0%, rgba(124, 140, 255, 0.14), transparent 55%),
linear-gradient(180deg, var(--bg2) 0%, var(--bg) 100%);
background-attachment: fixed;
min-height: 100vh;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 40px 22px 80px; }
/* ---- header ---------------------------------------------------------------*/
header { margin-bottom: 6px; }
header h1 {
margin: 0 0 6px; font-size: 34px; font-weight: 800; letter-spacing: -0.02em;
background: var(--accent-grad); -webkit-background-clip: text;
background-clip: text; -webkit-text-fill-color: transparent;
}
header p { margin: 0; color: var(--muted); font-size: 15px; max-width: 720px; line-height: 1.5; }
/* ---- layout ---------------------------------------------------------------*/
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 26px; }
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(14px) saturate(120%);
-webkit-backdrop-filter: blur(14px) saturate(120%);
}
.card h2 {
margin: 0 0 14px; font-size: 12px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700;
}
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 7px; font-weight: 500; }
select, input[type=text], input[type=number] {
width: 100%; padding: 11px 12px; border-radius: var(--radius-sm);
background: var(--panel2); color: var(--text); border: 1px solid var(--line-strong);
font-size: 14px; transition: border-color .15s, box-shadow .15s; outline: none;
}
select:focus, input[type=text]:focus, input[type=number]:focus {
border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 140, 255, 0.22);
}
option { background: var(--panel-solid); }
option:disabled { color: #5a647e; }
/* ---- upload / drop --------------------------------------------------------*/
.drop {
border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
padding: 30px; text-align: center; cursor: pointer; color: var(--muted);
transition: border-color .18s, background .18s, color .18s, transform .18s;
}
.drop:hover { border-color: var(--accent); color: var(--text); }
.drop.drag { border-color: var(--accent2); background: rgba(70, 214, 255, 0.08); color: var(--text); transform: scale(1.01); }
.preview {
margin-top: 16px; border-radius: var(--radius-sm); overflow: hidden; background: #000;
max-height: 320px; display: flex; justify-content: center; border: 1px solid var(--line);
}
.preview img, .preview video { max-width: 100%; max-height: 320px; display: block; }
/* ---- buttons --------------------------------------------------------------*/
button.go, button.sm, button.primary {
border: 0; border-radius: var(--radius-sm); background: var(--accent-grad);
color: #06122b; font-weight: 700; cursor: pointer;
box-shadow: 0 8px 20px -8px rgba(124, 140, 255, 0.6);
transition: transform .12s ease, box-shadow .12s ease, opacity .12s;
}
button.go { margin-top: 18px; width: 100%; padding: 13px; font-size: 15px; }
button.sm { padding: 11px 16px; white-space: nowrap; }
button.primary { padding: 9px 18px; }
button.go:hover:not(:disabled), button.sm:hover:not(:disabled), button.primary:hover:not(:disabled) {
transform: translateY(-1px); box-shadow: 0 12px 26px -8px rgba(70, 214, 255, 0.55);
}
button.go:active, button.sm:active { transform: translateY(0); }
button.go:disabled, button.sm:disabled, button.primary:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; }
.meta { font-size: 12px; color: var(--muted); margin-top: 12px; line-height: 1.6; }
/* ---- verdict / probability ------------------------------------------------*/
.verdict { font-size: 38px; font-weight: 800; text-align: center; margin: 6px 0 2px; letter-spacing: -0.02em; white-space: nowrap; }
.explore .verdict { font-size: 28px; }
.verdict.real { color: var(--real); text-shadow: 0 0 28px rgba(52, 211, 153, 0.35); }
.verdict.fake { color: var(--fake); text-shadow: 0 0 28px rgba(255, 107, 125, 0.35); }
.bar {
position: relative; height: 28px; border-radius: 9px; overflow: hidden; display: flex;
background: var(--panel2); border: 1px solid var(--line); margin: 16px 0 6px;
}
.bar .fake { background: linear-gradient(90deg, #ff6b7d, #ff8a5d); }
.bar .real { background: linear-gradient(90deg, #34d399, #46d6ff); }
.bar span {
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: #07101f; white-space: nowrap; transition: flex-basis .4s ease;
}
.bar .fake { justify-content: flex-start; padding-left: 12px; }
.bar .real { justify-content: flex-end; padding-right: 12px; }
.bar .thrline { position: absolute; top: -3px; bottom: -3px; width: 2px; background: #fff; box-shadow: 0 0 6px rgba(255,255,255,.6); }
.row {
display: flex; justify-content: space-between; font-size: 13px;
padding: 8px 0; border-bottom: 1px solid var(--line); color: var(--muted);
}
.row:last-child { border-bottom: 0; }
.row b { color: var(--text); }
/* ---- per-frame sparkline --------------------------------------------------*/
.frames { margin-top: 14px; }
.spark {
position: relative; display: flex; gap: 2px; align-items: flex-end; height: 64px; margin-top: 8px;
padding: 4px 4px 0; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.spark i { flex: 1; background: var(--accent-grad); border-radius: 3px 3px 0 0; min-height: 3px; opacity: .9; }
.spark .thr50 { position: absolute; left: 6px; right: 6px; top: 50%; height: 0; border-top: 1px dashed #4a5572; pointer-events: none; }
.frame-stats { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 5px; }
.frame-stats b { color: var(--text); }
.err { color: var(--fake); font-size: 13px; margin-top: 12px; white-space: pre-wrap; }
.pill {
display: inline-block; font-size: 11px; padding: 2px 9px; border-radius: 999px;
background: var(--panel2); border: 1px solid var(--line-strong); color: var(--muted); margin-left: 8px;
}
.empty { color: var(--muted2); text-align: center; padding: 48px 0; font-size: 14px; }
.notice { border-left: 3px solid var(--accent, #7c9cff); }
.notice h3 { margin: 0 0 8px; font-size: 16px; }
.notice p { margin: 8px 0; line-height: 1.55; color: var(--muted); }
.notice b, .notice i { color: inherit; }
.notice.slim { padding: 10px 14px; font-size: 13px; color: var(--muted); margin-bottom: 12px; }
.notice a { color: var(--accent, #7c9cff); }
/* ---- tabs -----------------------------------------------------------------*/
.tabs { display: flex; gap: 4px; margin-top: 26px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.tab {
background: transparent; border: 0; color: var(--muted); padding: 11px 18px;
cursor: pointer; font-size: 14px; font-weight: 600; border-bottom: 2px solid transparent;
transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: transparent;
background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
border-bottom: 2px solid var(--accent); }
/* ---- data explorer --------------------------------------------------------*/
.toolbar { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.toolbar > div { flex: 1; min-width: 150px; }
.badge {
display: inline-block; font-size: 10px; padding: 1px 8px; border-radius: 7px;
background: var(--accent-grad); color: #06122b; font-weight: 700; margin-left: 6px; vertical-align: middle;
}
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 11px; margin-top: 18px; }
.thumb {
position: relative; border-radius: var(--radius-sm); overflow: hidden; cursor: pointer;
border: 2px solid transparent; background: #000; aspect-ratio: 1 / 1; transition: transform .15s, border-color .15s;
}
.thumb:hover { transform: translateY(-2px); }
.thumb.sel { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 140, 255, 0.25); }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.thumb .tag { position: absolute; top: 5px; left: 5px; font-size: 10px; padding: 1px 7px; border-radius: 6px; font-weight: 700; letter-spacing: .03em; }
.thumb .tag.real { background: var(--real); color: #06210f; }
.thumb .tag.fake { background: var(--fake); color: #2a0707; }
.thumb .pred { position: absolute; bottom: 0; left: 0; right: 0; font-size: 10px; text-align: center; padding: 2px 0; font-weight: 700; }
.thumb .pred.ok { background: rgba(52, 211, 153, .92); color: #06210f; }
.thumb .pred.no { background: rgba(255, 107, 125, .92); color: #fff; }
.thumb .play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; color: rgba(255,255,255,.85); pointer-events: none; text-shadow: 0 2px 8px rgba(0,0,0,.6); }
/* ---- threshold + calibration ----------------------------------------------*/
.thr { margin-top: 16px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--panel2); }
.thr input[type=range] { width: 100%; accent-color: var(--accent); }
.thr .lab { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; }
.thr .lab b { color: var(--text); }
.calib { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
.calib button { padding: 8px 12px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel2); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer; transition: border-color .15s; }
.calib button:hover:not(:disabled) { border-color: var(--accent); }
.calib button:disabled { opacity: .45; cursor: not-allowed; }
.calib input[type=number] { width: 64px; padding: 6px; }
.stat { font-size: 12px; color: var(--muted); margin-top: 8px; }
.stat b { color: var(--text); }
/* ---- per-model paper-vs-reproduced table ----------------------------------*/
table.repro { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; }
table.repro td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
table.repro td:first-child { color: var(--muted); width: 92px; white-space: nowrap; }
table.repro td.match-match { color: var(--real); }
table.repro td.match-gap-closed { color: var(--real); font-weight: 600; }
table.repro td.match-below, table.repro td.match-partial, table.repro td.match-ranking-only { color: #ffcf6e; }
table.repro td.match-pending { color: var(--muted); font-style: italic; }
table.repro td.rnote { color: var(--muted); font-size: 11px; font-style: italic; }
/* ---- full per-benchmark results table -------------------------------------*/
.repro-full { width: 100%; margin-top: 8px; border-collapse: collapse; font-size: 12px; }
.repro-full th { text-align: left; border-bottom: 1px solid var(--line-strong); padding: 4px 7px; color: var(--muted); }
.repro-full td { padding: 4px 7px; border-bottom: 1px solid var(--line); vertical-align: top; }
.repro-full td .variant { font-size: 11px; color: var(--muted); margin-top: 2px; max-width: 320px; }
.status-reproduced { color: var(--real); font-weight: 600; }
.status-official { color: var(--accent); font-weight: 600; }
.status-below { color: #ffb74d; }
.status-missing { color: var(--muted2); }
.status-reference { color: var(--muted2); font-style: italic; }
.repro-wrap { margin: 10px 0 4px; }
.repro-details { border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--panel2); overflow: hidden; }
.repro-details > summary { cursor: pointer; list-style: none; padding: 10px 13px; font-size: 12px; color: var(--accent); font-weight: 600; user-select: none; display: flex; align-items: center; gap: 7px; }
.repro-details > summary::-webkit-details-marker { display: none; }
.repro-details > summary::before { content: "▸"; font-size: 10px; transition: transform .15s; }
.repro-details[open] > summary::before { transform: rotate(90deg); }
.repro-scroll { max-height: 360px; overflow: auto; border-top: 1px solid var(--line); }
.repro-full thead th { position: sticky; top: 0; z-index: 1; background: var(--panel-solid); white-space: nowrap; }
.repro-full td:first-child { min-width: 170px; }
.repro-full tbody tr:nth-child(even) { background: rgba(255,255,255,.02); }
.repro-full tbody tr:hover { background: rgba(124, 140, 255, .07); }
.repro-legend { color: var(--muted); font-size: 11px; margin: 8px 12px 10px; line-height: 1.55; }
.lead-head { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; justify-content: space-between; margin: 6px 0 16px; }
.lead-head .muted { flex: 1; min-width: 280px; color: var(--muted); }
.lead-head label { color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; }
.lead-head select { width: auto; min-width: 220px; }
.lead-card { margin-bottom: 18px; }
.card.lead-card h2, .topgen .card h2 { display: flex; align-items: center; gap: 8px; text-transform: none; letter-spacing: 0; font-size: 17px; color: var(--text); }
.lead-bn { color: var(--muted); font-size: 10px; margin-top: 2px; }
.lb-best { color: var(--accent2); font-weight: 700; }
table.repro-full.lb-gen td { white-space: nowrap; }
table.repro-full.lb-gen td:nth-child(2) { white-space: normal; }
.lb-top td { background: rgba(124, 140, 255, .08); }
.topgen .card { margin-bottom: 18px; }
/* ---- SOTA generator vs detector comparison --------------------------------*/
.sota .sota-intro { margin: 18px 0 4px; max-width: 760px; line-height: 1.5; }
.sota-layout { display: grid; grid-template-columns: 340px 1fr; gap: 22px; margin-top: 14px; align-items: start; }
@media (max-width: 900px) { .sota-layout { grid-template-columns: 1fr; } }
.sota-right { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
.sota .step { display: flex; align-items: center; gap: 9px; margin: 20px 0 9px; font-size: 14px; }
.sota .step:first-of-type { margin-top: 2px; }
.sota .stepno { width: 22px; height: 22px; border-radius: 50%; background: var(--accent-grad); color: #06122b;
font-size: 12px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; }
.sota .srcbox { height: 190px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: rgba(0,0,0,.28);
display: flex; align-items: center; justify-content: center; overflow: hidden; }
.sota .srcbox img { max-width: 100%; max-height: 100%; display: block; }
.sota .srcempty { color: var(--muted2); font-size: 13px; }
.sota .row2 { display: flex; gap: 10px; margin-top: 10px; }
.sota .row2 .sm { flex: 1; text-align: center; margin: 0; font-size: 13px; padding: 10px 12px; }
.sota label.ghost { background: var(--panel2); color: var(--text); border: 1px solid var(--line-strong); box-shadow: none;
cursor: pointer; border-radius: var(--radius-sm); font-weight: 600; }
.sota textarea { width: 100%; resize: vertical; padding: 11px 12px; border-radius: var(--radius-sm); background: var(--panel2);
color: var(--text); border: 1px solid var(--line-strong); font: inherit; font-size: 14px; outline: none; }
.sota textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,140,255,.22); }
.sota .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.sota .pair figure { margin: 0; min-width: 0; }
.sota .imgbox { height: 300px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: rgba(0,0,0,.28);
display: flex; align-items: center; justify-content: center; overflow: hidden; color: var(--muted2); font-size: 13px; text-align: center; padding: 8px; }
.sota .imgbox img { max-width: 100%; max-height: 100%; display: block; }
.sota figcaption { text-align: center; font-size: 12px; color: var(--muted); margin-top: 8px; }
.sota h2 .muted { text-transform: none; letter-spacing: 0; font-weight: 500; margin-left: 8px; }
.sota .detbar { display: flex; gap: 8px; margin: -4px 0 6px; }
.sota .chipbtn { background: transparent; border: 1px solid var(--line-strong); color: var(--muted); border-radius: 999px;
padding: 3px 12px; font-size: 12px; cursor: pointer; }
.sota .chipbtn:hover { color: var(--text); border-color: var(--accent); }
.sota .detgroup { margin-top: 14px; }
.sota .detlabel { font-size: 11px; color: var(--muted2); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }
.sota .chips { display: flex; flex-wrap: wrap; gap: 8px; }
.sota .chip { background: var(--panel2); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 999px;
padding: 6px 14px; font-size: 13px; cursor: pointer; transition: all .12s; }
.sota .chip:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.sota .chip.on { background: rgba(124,140,255,.20); color: var(--text); border-color: var(--accent); font-weight: 600; }
.sota .chip:disabled { opacity: .35; cursor: not-allowed; text-decoration: line-through; }
.sota .results { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 14px; }
.sota .sumline { font-size: 14px; margin-bottom: 10px; min-height: 20px; }
.sota .resrow { display: grid; grid-template-columns: 150px 1fr 150px 84px; gap: 12px; align-items: center; padding: 8px 0;
border-bottom: 1px solid var(--line); font-size: 13px; }
@media (max-width: 700px) { .sota .resrow { grid-template-columns: 1fr 1fr; } }
.sota .rname { font-weight: 600; }
.sota .rbar { position: relative; height: 9px; background: rgba(255,255,255,.07); border-radius: 5px; overflow: visible; }
.sota .rfill { height: 100%; border-radius: 5px; background: var(--real); }
.sota .resrow.hit .rfill { background: var(--fake); }
.sota .rthr { position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--muted); opacity: .7; }
.sota .rnum { font-variant-numeric: tabular-nums; }
.sota .rnum .muted { font-size: 11px; margin-left: 6px; }
.sota .rbadge { text-align: right; font-weight: 700; }
.sota .resrow.hit .rbadge { color: var(--fake); }
.sota .resrow.miss .rbadge { color: var(--real); }
.sota .rerr { grid-column: 2 / -1; color: var(--fake); font-size: 12px; }
/* ---- weight-source badge --------------------------------------------------*/
.wtag { font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 8px; border: 1px solid var(--line-strong); color: var(--muted); white-space: nowrap; vertical-align: middle; }
.wtag-official { background: rgba(46, 125, 82, .22); border-color: #2e7d52; color: #8ee0b0; }
.wtag-synthscope_trained { background: rgba(46, 93, 125, .22); border-color: #2e5d7d; color: #8ec6e0; }
.wtag-training_free { background: rgba(125, 102, 46, .22); border-color: #7d662e; color: #e0cf8e; }
/* links + scrollbars --------------------------------------------------------*/
a { color: var(--accent); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); background-clip: padding-box; }
/* ---- interpretability map -------------------------------------------------*/
.interp { margin-top: 16px; }
.interp-row { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
.interp-cell { margin: 0; }
.interp-cell figcaption { font-size: 11px; color: #8b93a7; margin-top: 4px; text-align: center; }
.interp-map { display: block; width: 100%; max-width: 240px; border-radius: var(--radius-sm);
border: 1px solid var(--line-strong); box-shadow: var(--shadow); }
/* ---- polish: header, collapsible details, badges, page heads ---------------*/
header.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
header.top h1 { font-size: 38px; }
header.top .pill { margin-top: 10px; white-space: nowrap; }
details.adv { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: rgba(31,41,71,.35); }
details.adv > summary { cursor: pointer; padding: 10px 14px; font-size: 13px; color: var(--muted); font-weight: 600; list-style: none; user-select: none; }
details.adv > summary::-webkit-details-marker { display: none; }
details.adv > summary::before { content: "▸"; display: inline-block; width: 16px; }
details.adv[open] > summary::before { content: "▾"; }
details.adv > summary:hover { color: var(--text); }
details.adv > .adv-body { padding: 0 14px 14px; }
details.adv .thr { margin-top: 6px; border: 0; padding: 0; background: none; }
.pagehead { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin: 18px 0 14px; flex-wrap: wrap; }
.pagehead p { margin: 0; max-width: 720px; line-height: 1.5; }
select.inline { width: auto; min-width: 240px; }
.sbadge { font-size: 12px; font-weight: 600; white-space: nowrap; }
details.lead-card { margin-bottom: 14px; padding: 0; }
details.lead-card > summary { cursor: pointer; list-style: none; padding: 16px 22px; display: flex; align-items: center; gap: 10px; user-select: none; }
details.lead-card > summary::-webkit-details-marker { display: none; }
details.lead-card > summary::before { content: "▸"; color: var(--muted); }
details.lead-card[open] > summary::before { content: "▾"; }
details.lead-card .sumtitle { font-size: 16px; font-weight: 700; }
details.lead-card .repro-scroll { padding: 0 22px 18px; }
.explore .detbar2 { padding-bottom: 16px; }
.explore .detsel label { margin-top: 0; }
.explore .thumbs { margin-top: 16px; }
.explore .stat { margin-top: 14px; font-size: 13px; }
/* ---- input note, centered image boxes, heatmap legend --------------------------------------------------------*/
.inote { margin-top: 8px; font-size: 12.5px; color: var(--muted); line-height: 1.5; }
.itag { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 9px; border-radius: 999px; margin-right: 6px;
background: rgba(124,140,255,.18); border: 1px solid var(--accent); color: var(--text); white-space: nowrap; }
.preview { align-items: center; min-height: 200px; }
.interp-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.interp-row { justify-content: center; flex-wrap: nowrap; gap: 12px; }
.interp-row .interp-cell { flex: 1 1 0; min-width: 0; }
.interp-cell { text-align: center; }
.imgcell { display: flex; align-items: center; justify-content: center; min-height: 140px; min-width: 0;
background: rgba(0,0,0,.28); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 6px; }
.imgcell .interp-map { max-width: 100%; max-height: 220px; width: auto; border: 0; box-shadow: none; }
.legend { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.legbar { flex: 0 0 120px; height: 9px; border-radius: 5px;
background: linear-gradient(90deg, #00007f, #0000ff, #00ffff, #7fff7f, #ffff00, #ff0000, #7f0000); }
.seen { margin-top: 18px; }
.seen-title { font-size: 14px; font-weight: 700; margin-bottom: 8px; text-align: center; }
.seenbox { min-height: 200px; width: 100%; }
.seenbox img { max-width: 100%; max-height: 300px; image-rendering: auto; }
.seen-cap { text-align: center; line-height: 1.55; margin-top: 8px; }
/* ---- generator modes + similarity -----------------------------------------------------------------------------*/
.sota .modes { display: flex; gap: 8px; flex-wrap: wrap; }
.sota .modebtn { flex: 1 1 140px; background: var(--panel2); color: var(--muted); border: 1px solid var(--line-strong);
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 13px; font-weight: 600; cursor: pointer; }
.sota .modebtn.on { background: rgba(124,140,255,.20); color: var(--text); border-color: var(--accent); }
.sota .modebtn:disabled { opacity: .35; cursor: not-allowed; }
.sota .lab2 { margin: 12px 0 6px; }
.sota .presets { margin-top: 8px; }
.sota .sim { margin-top: 12px; padding: 9px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; border: 1px solid var(--line-strong); }
.sota .sim.warn { border-color: #ffcf6e; background: rgba(255,207,110,.08); color: #ffe0a0; }
.sota .sim.ok { border-color: rgba(52,211,153,.5); background: rgba(52,211,153,.07); }
/* ---- benchmark pages ------------------------------------------------------------------------------------------*/
.coverage { margin: 8px 0 16px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: rgba(31,41,71,.35); font-size: 14px; }
.coverage .covchips { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 6px; }
.coverage .covchips .sbadge { padding: 3px 12px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--panel2); }
.coverage a, .topgen a { color: var(--accent2); }
.topgen .ovtable .ovcell { text-align: center; font-variant-numeric: tabular-nums; }
.topgen h2 .muted { text-transform: none; letter-spacing: 0; font-weight: 500; margin-left: 8px; }
pre.cmd { margin: 10px 0 0; padding: 10px 12px; background: rgba(0,0,0,.35); border: 1px solid var(--line); border-radius: var(--radius-sm);
font-size: 12px; overflow-x: auto; color: var(--text); }
</style>
<!-- Vendored locally (served by the backend at /static/vendor) so the UI needs no external CDN. -->
<script src="/static/vendor/react.production.min.js"></script>
<script src="/static/vendor/react-dom.production.min.js"></script>
<script src="/static/vendor/htm.umd.js"></script>
</head>
<body>
<div id="root"></div>
<script>
const { useState, useEffect, useRef } = React;
const { createRoot } = ReactDOM;
const html = htm.bind(React.createElement);
const API = (p) => (window.SYNTHSCOPE_API || "") + p;
const optLabel = (m) => `${m.name} — ${m.paper}` + (m.available ? "" : " (no checkpoint)");
const verdictAt = (p, thr) => (p >= thr ? "fake" : "real");
// --- threshold calibration helpers (client-side, from scored {p, gt} items) ---
function balancedAccAt(scored, thr) {
const reals = scored.filter(s => s.gt === "real"), fakes = scored.filter(s => s.gt === "fake");
const tnr = reals.length ? reals.filter(s => s.p < thr).length / reals.length : 0;
const tpr = fakes.length ? fakes.filter(s => s.p >= thr).length / fakes.length : 0;
return { acc: (tpr + tnr) / 2, tpr, tnr };
}
function autoCalibrate(scored) {
let best = 0.5, bestAcc = -1;
for (let t = 0; t <= 1.0001; t += 0.01) {
const a = balancedAccAt(scored, t).acc;
if (a > bestAcc) { bestAcc = a; best = t; }
}
return Math.round(best * 100) / 100;
}
function targetFprThreshold(scored, fpr) {
const reals = scored.filter(s => s.gt === "real").map(s => s.p).sort((a, b) => a - b);
if (!reals.length) return null;
// flag fake when p >= thr; FPR = fraction of reals >= thr. Pick the (1-fpr) quantile of reals.
const idx = Math.min(reals.length - 1, Math.max(0, Math.ceil((1 - fpr) * reals.length) - 1));
return Math.round(reals[idx] * 100) / 100;
}
function batchStats(scored, thr) {
if (!scored.length) return null;
const reals = scored.filter(s => s.gt === "real"), fakes = scored.filter(s => s.gt === "fake");
const racc = reals.length ? reals.filter(s => s.p < thr).length / reals.length : null;
const facc = fakes.length ? fakes.filter(s => s.p >= thr).length / fakes.length : null;
const acc = scored.filter(s => verdictAt(s.p, thr) === s.gt).length / scored.length;
return { n: scored.length, acc, racc, facc };
}
// Short label + plain-language explanation of what a detector does to the image before scoring it.
function InputNote({ m }) {
if (!m || !m.input) return null;
return html`<div class="inote"><span class="itag">${m.input.label}</span> ${m.input.explanation}</div>`;
}
function ModelSelect({ models, model, setModel, onChange }) {
const imageModels = models.filter(m => m.modality === "image");
const videoModels = models.filter(m => m.modality === "video");
return html`
<select value=${model} onChange=${e => { setModel(e.target.value); onChange && onChange(); }}>
<optgroup label="Image detectors">
${imageModels.map(m => html`<option key=${m.key} value=${m.key} disabled=${!m.available}>${optLabel(m)}</option>`)}
</optgroup>
<optgroup label="Video detectors">
${videoModels.map(m => html`<option key=${m.key} value=${m.key} disabled=${!m.available}>${optLabel(m)}</option>`)}
</optgroup>
</select>`;
}
function ReproTable({ m }) {
const rep = m && m.repro;
const full = m && m.repro_full;
if (!rep && !(full && full.length)) return null;
return html`<div class="repro-wrap">
${rep && html`<table class="repro"><tbody>
<tr><td>Headline</td><td>${rep.benchmark}</td></tr>
<tr><td>Paper</td><td>${rep.paper}</td></tr>
<tr><td>Reproduced</td><td class=${"match-" + (rep.match || "")}>${rep.ours}</td></tr>
</tbody></table>`}
${full && full.length > 0 && html`<details class="repro-details">
<summary>All ${full.length} reported benchmarks — paper vs SynthScope vs official</summary>
<div class="repro-scroll">
<table class="repro-full">
<thead><tr><th>Benchmark</th><th>Paper</th><th>SynthScope-trained</th><th>Official weights</th><th>Status</th></tr></thead>
<tbody>
${full.map((r, i) => html`<tr key=${i}>
<td>${r.benchmark}</td>
<td>${r.paper}</td>
<td>${r.ours_synthscope || "—"}</td>
<td>${r.ours_official || "—"}</td>
<td><${StatusBadge} s=${r.status} /></td>
</tr>`)}
</tbody>
</table>
</div>
<div class="repro-legend">
<b>reproduced</b> = SynthScope-trained checkpoint matches the paper ·
<b>official</b> = official weights loaded into the SynthScope class match (SynthScope-trained number shown where we trained from scratch) ·
<b>below</b> = gap remains · <b>reference</b> = paper reports no comparable number · <b>missing</b> = not yet evaluated.
</div>
</details>`}
</div>`;
}
function b64ToFile(b64, name) {
const bin = atob(b64), arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new File([arr], name, { type: "image/png" });
}
// Detector families for the picker (keys not listed fall into "Other").
const DET_GROUPS = [
["Low-level artifact detectors", ["npr", "safe", "aide", "spai", "pgc", "cnn"]],
["Semantic / foundation-model detectors", ["clip", "dinov2", "dinov3", "adapted_dino", "cure", "co_spy", "fat_former", "vib_net"]],
];
const DET_DEFAULT = ["safe", "npr", "clip", "cure"];
// Edit-mode starting points: the generator edits the photo itself, so the instruction must ask for a big change.
const EDIT_PRESETS = [
["Different place and light", "Keep the same kind of subject but place it in a completely different location, with different lighting and time of day, as a new photograph"],
["New camera angle", "Re-photograph this scene from a very different camera angle and distance"],
["Reimagine it", "Reimagine this as a completely new photograph with a different composition; keep only the type of subject"],
];
function pct(p) { return (100 * p).toFixed(1) + "%"; }
// SOTA generator vs detector: pick/upload a real image -> a generator makes a new image ONCE -> any number of
// detectors score the pair. Ticking another detector only scores it (no regeneration).
const SETUP_URL = "https://github.com/SMSD75/SynthScope#readme";
const listNames = xs => xs.length < 2 ? (xs[0] || "") : xs.slice(0, -1).join(", ") + " and " + xs[xs.length - 1];
// Shown instead of "Test a generator" when no generator can run on this server (no GPU and no API key).
function GeneratorOff({ demo }) {
return html`<div class="card notice">
<h3>${demo ? "Switched off in this online demo" : "No image generator can run here"}</h3>
<p>Testing a generator means creating new images. That needs a GPU, for the open models (FLUX.2 [klein],
FLUX.1 Kontext, Qwen-Image), or your own API key, for the hosted ones (Nano Banana, GPT Image, Grok, FLUX via
fal.ai). ${demo ? "This online demo has neither, so the tab is off here." : "This machine has neither."}</p>
<p><b>Run SynthScope on your own computer to use it</b>: install it, add a GPU or an API key, and this tab
generates an image from yours and scores it with every detector. See the <a href=${SETUP_URL} target="_blank"
rel="noopener">setup guide</a>. Results we already ran for FLUX.2 [klein] and Qwen-Image are in the
<i>Generator benchmarks</i> tab.</p>
</div>`;
}
function SotaView({ models }) {
const [gens, setGens] = useState([]);
const [genDevice, setGenDevice] = useState("");
const [gen, setGen] = useState("");
const [prompt, setPrompt] = useState("");
const [mode, setMode] = useState("recreate"); // "recreate" = new image from a description, "edit" = edit the photo
const [touched, setTouched] = useState(false); // the user edited the prompt, so do not overwrite it
const [describing, setDescribing] = useState(false);
const [realB64, setRealB64] = useState("");
const [realSrc, setRealSrc] = useState(null);
const [genB64, setGenB64] = useState("");
const [genInfo, setGenInfo] = useState(null);
const [picked, setPicked] = useState(null); // {key: bool}; null until the model list arrives
const [scores, setScores] = useState({}); // key -> result for the CURRENT real/generated pair
const [genBusy, setGenBusy] = useState(false);
const [scoreBusy, setScoreBusy] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [err, setErr] = useState("");
const asked = useRef(new Set()); // detector keys already requested for the current pair
const pairId = useRef(0); // bumped whenever the pair changes; late responses are dropped
const imgDetectors = (models || []).filter(m => m.modality === "image");
useEffect(() => {
fetch(API("/api/sota/generators")).then(r => r.json()).then(d => {
setGens(d.generators || []);
setGenDevice(d.device || "");
const a = (d.generators || []).find(g => g.available);
if (a) setGen(a.key);
}).catch(e => setErr(String(e.message || e)));
}, []);
// Fill the prompt: a caption of the photo (re-create) or a preset edit instruction (edit), unless the user typed.
useEffect(() => {
if (touched) { setDescribing(false); return; }
if (mode === "edit") { setDescribing(false); setPrompt(EDIT_PRESETS[2][1]); return; }
if (!realB64) { setDescribing(false); setPrompt(""); return; }
let live = true;
setDescribing(true);
const fd = new FormData(); fd.append("file", b64ToFile(realB64, "real.png"));
fetch(API("/api/sota/describe"), { method: "POST", body: fd }).then(r => r.json())
.then(d => { if (live && d.prompt) setPrompt(d.prompt); })
.catch(() => {}).finally(() => { if (live) setDescribing(false); });
return () => { live = false; };
}, [realB64, mode, touched]);
// A generator that supports only one mode forces that mode.
useEffect(() => {
const g = gens.find(x => x.key === gen);
if (g && g.modes && !g.modes.includes(mode)) { setMode(g.modes[0]); setTouched(false); }
}, [gen, gens]);
useEffect(() => {
if (picked !== null || !imgDetectors.length) return;
const init = {};
DET_DEFAULT.forEach(k => { if (imgDetectors.some(m => m.key === k && m.available)) init[k] = true; });
setPicked(init);
}, [models]);
useEffect(() => {
if (!genBusy) return;
setElapsed(0);
const t = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(t);
}, [genBusy]);
// Score every ticked detector that has not been scored for this pair yet (no generation involved).
useEffect(() => {
if (!realB64 || !genB64 || !picked) return;
const todo = Object.keys(picked).filter(k => picked[k] && !asked.current.has(k));
if (!todo.length) return;
todo.forEach(k => asked.current.add(k));
const myPair = pairId.current;
(async () => {
setScoreBusy(true); setErr("");
try {
const fd = new FormData();
fd.append("real", b64ToFile(realB64, "real.png")); fd.append("generated", b64ToFile(genB64, "gen.png"));
fd.append("detectors", todo.join(","));
const r = await fetch(API("/api/sota/score"), { method: "POST", body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
if (myPair !== pairId.current) return; // the image changed while scoring: these scores are stale
setScores(s => { const n = { ...s }; d.results.forEach(x => { n[x.detector] = x; }); return n; });
} catch (e) { if (myPair === pairId.current) { todo.forEach(k => asked.current.delete(k)); setErr(String(e.message || e)); } }
finally { setScoreBusy(false); }
})();
}, [picked, genB64, realB64]);
function resetPair() { pairId.current += 1; setGenB64(""); setGenInfo(null); setScores({}); asked.current = new Set(); }
async function rollImage() {
setErr(""); resetPair(); setTouched(false);
try {
const r = await fetch(API("/api/sota/real_image")); const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
setRealB64(d.image_b64); setRealSrc(d.source);
} catch (e) { setErr(String(e.message || e)); }
}
function onUpload(f) {
if (!f) return;
const rd = new FileReader();
rd.onload = () => { resetPair(); setTouched(false); setRealB64(rd.result.split(",")[1]); setRealSrc(["upload", f.name]); };
rd.readAsDataURL(f);
}
async function generate() {
setErr(""); resetPair(); setGenBusy(true);
try {
if (!realB64) throw new Error("Pick or upload a real image first.");
if (!gen) throw new Error("Select a generator.");
const fd = new FormData();
fd.append("file", b64ToFile(realB64, "real.png")); fd.append("generator", gen); fd.append("prompt", prompt); fd.append("mode", mode);
const r = await fetch(API("/api/sota/generate"), { method: "POST", body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
setGenInfo({ generator: d.generator, prompt: d.prompt, mode: d.mode, similarity: d.similarity, near_copy: d.near_copy, real_size: d.real_size, gen_size: d.gen_size });
setGenB64(d.generated_b64);
} catch (e) { setErr(String(e.message || e)); } finally { setGenBusy(false); }
}
function setMany(keys, on) {
const n = { ...(picked || {}) };
keys.forEach(k => { n[k] = on; });
setPicked(n);
}
const curGen = gens.find(g => g.key === gen);
const listed = new Set(DET_GROUPS.flatMap(g => g[1]));
const groups = [...DET_GROUPS.map(([label, keys]) => [label, imgDetectors.filter(m => keys.includes(m.key))]),
["Other", imgDetectors.filter(m => !listed.has(m.key))]].filter(g => g[1].length);
const availKeys = imgDetectors.filter(m => m.available).map(m => m.key);
const rows = Object.keys(picked || {}).filter(k => picked[k] && scores[k]).map(k => scores[k]);
const waiting = Object.keys(picked || {}).filter(k => picked[k] && !scores[k]).length;
const caught = rows.filter(r => r.caught).length;
const mins = Math.floor(elapsed / 60), secs = String(elapsed % 60).padStart(2, "0");
return html`
<div class="sota">
<p class="muted sota-intro">Take a real photo, let a current generator make a new image from it, then check which
detectors flag the result. Generate once, then add or remove detectors freely - they score the same image.</p>
<div class="sota-layout">
<div class="card sota-create">
<h2>Create</h2>
<div class="step"><span class="stepno">1</span><b>Source image</b></div>
<div class="srcbox">
${realB64 ? html`<img src=${"data:image/png;base64," + realB64} />`
: html`<div class="srcempty">No image yet</div>`}
</div>
<div class="row2">
<button class="sm" onClick=${rollImage}>🎲 Random real image</button>
<label class="sm ghost">Upload…<input type="file" accept="image/*" style=${{display:"none"}}
onChange=${e => onUpload(e.target.files[0])} /></label>
</div>
${realSrc && html`<div class="meta">${realSrc[0] === "upload" ? "uploaded: " + realSrc[1] : realSrc.join(" / ")}</div>`}
<div class="step"><span class="stepno">2</span><b>Generator</b></div>
<select value=${gen} onChange=${e => setGen(e.target.value)}>
${gens.map(g => html`<option key=${g.key} value=${g.key} disabled=${!g.available}>
${g.name}${g.available ? ""
: g.needs_gpu ? " (needs a GPU)" : g.local ? " (not set up here)" : " (needs an API key)"}</option>`)}
</select>
${curGen && html`<div class="meta">${curGen.note}</div>`}
<div class="step"><span class="stepno">3</span><b>How to create it</b></div>
<div class="modes">
<button class=${"modebtn" + (mode === "recreate" ? " on" : "")} disabled=${!curGen || !curGen.modes.includes("recreate")}
onClick=${() => { setMode("recreate"); setTouched(false); }}>Re-create from description</button>
<button class=${"modebtn" + (mode === "edit" ? " on" : "")} disabled=${!curGen || !curGen.modes.includes("edit")}
onClick=${() => { setMode("edit"); setTouched(false); }}>Edit this photo</button>
</div>
<div class="meta">${mode === "recreate"
? "The photo is described in words and the generator makes a brand-new image from that description alone, so every pixel is synthesized."
: "The generator edits your photo itself, so the result keeps its layout and much of its content. Detectors then score an edited real photo."}</div>
<label class="lab2">${mode === "recreate" ? (describing ? "Description (writing it…)" : "Description (edit it if you like)") : "Edit instruction"}</label>
<textarea rows="4" value=${prompt} onInput=${e => { setPrompt(e.target.value); setTouched(true); }}></textarea>
${mode === "edit" && html`<div class="chips presets">${EDIT_PRESETS.map(([label, text]) => html`<button key=${label} class="chip"
onClick=${() => { setPrompt(text); setTouched(true); }}>${label}</button>`)}</div>`}
<button class="go" disabled=${genBusy || describing || !realB64 || !gen || !prompt.trim()} onClick=${generate}>
${genBusy ? `Generating… ${mins}:${secs}` : (genB64 ? "↻ Regenerate" : "Generate")}</button>
${genBusy && html`<div class="meta">Local open-weight models take 1–4 min (first run also downloads weights).</div>`}
${err && html`<div class="err">${err}</div>`}
</div>
<div class="sota-right">
<div class="card">
<h2>Result</h2>
<div class="pair">
<figure>
<div class="imgbox">${realB64 ? html`<img src=${"data:image/png;base64," + realB64} />` : html`<span>Real</span>`}</div>
<figcaption>Real</figcaption>
</figure>
<figure>
<div class="imgbox">${genB64 ? html`<img src=${"data:image/png;base64," + genB64} />`
: html`<span>${genBusy ? "Generating…" : "Generated image appears here"}</span>`}</div>
<figcaption>${genInfo ? "Generated · " + (curGen ? curGen.name : genInfo.generator) : "Generated"}</figcaption>
</figure>
</div>
${genInfo && genInfo.similarity != null && html`<div class=${"sim " + (genInfo.near_copy ? "warn" : "ok")}>
Layout similarity to your photo: <b>${genInfo.similarity.toFixed(2)}</b>${" · "}
${genInfo.near_copy ? "very close: this looks like an edit of your photo, so the detectors are scoring an edited real photo rather than a new image."
: "different enough to count as a newly generated image."}</div>`}
${genInfo && genInfo.gen_size && html`<div class="meta">Sizes: your photo ${genInfo.real_size[0]}×${genInfo.real_size[1]}, generated ${genInfo.gen_size[0]}×${genInfo.gen_size[1]}. Each detector scores each image at its own size, after the detector's own preprocessing.</div>`}
${genInfo && genInfo.prompt && html`<div class="meta">prompt used: ${genInfo.prompt}</div>`}
</div>
<div class="card">
<h2>Detectors <span class="muted">${Object.values(picked || {}).filter(Boolean).length} selected</span></h2>
<div class="detbar">
<button class="chipbtn" onClick=${() => setMany(availKeys, true)}>Select all</button>
<button class="chipbtn" onClick=${() => setMany(availKeys, false)}>Clear</button>
</div>
${groups.map(([label, ms]) => html`<div class="detgroup" key=${label}>
<div class="detlabel">${label}</div>
<div class="chips">
${ms.map(m => html`<button key=${m.key} disabled=${!m.available}
title=${m.available ? m.paper + (m.input ? " · input: " + m.input.label : "") : "checkpoint not available on this server"}
class=${"chip" + (picked && picked[m.key] ? " on" : "")}
onClick=${() => setPicked({ ...(picked || {}), [m.key]: !(picked && picked[m.key]) })}>${m.name}</button>`)}
</div>
</div>`)}
${(rows.length > 0 || waiting > 0) && html`<div class="results">
<div class="sumline">${genB64 ? html`<b>${caught}</b> of <b>${rows.length}</b> detectors flag the generated image as fake${(scoreBusy || waiting) ? " · scoring…" : ""}` : ""}</div>
${rows.map(r => html`<div class=${"resrow " + (r.error ? "err2" : (r.caught ? "hit" : "miss"))} key=${r.detector}>
<div class="rname">${r.name || r.detector}</div>
${r.error ? html`<div class="rerr">${r.error}</div>` : html`
<div class="rbar"><div class="rfill" style=${{width: pct(r.gen_fake_prob)}}></div><div class="rthr"></div></div>
<div class="rnum">${pct(r.gen_fake_prob)}<span class="muted"> real ${pct(r.real_fake_prob)}</span></div>
<div class="rbadge">${r.caught ? "✓ caught" : "✗ missed"}</div>`}
</div>`)}
</div>`}
${!genB64 && html`<div class="meta">Scores appear here after you generate an image.</div>`}
</div>
</div>
</div>
</div>`;
}
const DOCS_URL = "https://github.com/SMSD75/SynthScope/blob/main/docs/BENCHMARKS.md";
const STATUS_LABEL = { reproduced: "✓ Reproduced", official: "✓ Official weights match", below: "Below paper",
reference: "Reference only", missing: "Not evaluated" };
function StatusBadge({ s }) {
return html`<span class=${"sbadge status-" + (s || "")}>${STATUS_LABEL[s] || s || "—"}</span>`;
}
// Collapsed-by-default section for details most users do not need.
function Adv({ title, open, children }) {
return html`<details class="adv" open=${!!open}><summary>${title}</summary><div class="adv-body">${children}</div></details>`;
}
function ThresholdSlider({ threshold, setThreshold, children }) {
return html`<div class="thr">
<div class="lab"><span>Decision threshold</span><span>P(fake) ≥ <b>${threshold.toFixed(2)}</b> → fake</span></div>
<input type="range" min="0" max="1" step="0.01" value=${threshold}
onInput=${e => setThreshold(parseFloat(e.target.value))} />
${children}
</div>`;
}
const LEGEND = html`<div class="repro-legend">
<b>Reproduced</b>: our own trained checkpoint matches the paper ·
<b>Official weights match</b>: the authors' released weights, run through SynthScope, match the paper ·
<b>Below paper</b>: a gap remains · <b>Reference only</b>: the paper reports no comparable number ·
<b>Not evaluated</b>: not run yet.</div>`;
// Leaderboard: every method x benchmark (paper / SynthScope-trained / official weights), grouped by benchmark.
function LeaderboardView() {
const [groups, setGroups] = useState([]);
const [summary, setSummary] = useState(null);
const [sel, setSel] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
fetch(API("/api/leaderboard")).then(r => r.json()).then(d => { setGroups(d.groups || []); setSummary(d.summary || null); })
.catch(e => setErr(String(e.message || e)));
}, []);
const shown = sel ? groups.filter(g => g.benchmark === sel) : groups;
return html`<div class="lead">
<div class="pagehead">
<p class="muted">How each detector scores on each benchmark: the number in its paper, our own re-trained
checkpoint, and the authors' official weights. Methods that share a benchmark are ranked best-first.</p>
<select class="inline" value=${sel} onChange=${e => setSel(e.target.value)}>
<option value="">All benchmarks (${groups.length})</option>
${groups.map(g => html`<option key=${g.benchmark} value=${g.benchmark}>${g.benchmark} (${g.n_methods})</option>`)}
</select>
</div>
${summary && html`<div class="coverage">
<div><b>${summary.detectors_listed}</b> of ${summary.detectors_total} detectors · <b>${summary.benchmarks}</b> benchmarks · <b>${summary.results}</b> results</div>
<div class="covchips">
<span class="sbadge status-reproduced">${summary.by_status.reproduced + summary.by_status.official} match the paper</span>
<span class="sbadge status-below">${summary.by_status.below} below the paper</span>
<span class="sbadge status-reference">${summary.by_status.reference} reference only</span>
<span class="sbadge status-missing">${summary.by_status.missing} not evaluated yet</span>
</div>
${summary.not_listed.length > 0 && html`<div class="meta">Not listed: ${summary.not_listed.join(", ")} (our own baselines; there is no paper to reproduce).</div>`}
<div class="meta">Missing a number, or want to add one? <a href=${DOCS_URL} target="_blank" rel="noopener">How to set up and run the benchmarks</a>.</div>
</div>`}
${err && html`<div class="err">${err}</div>`}
${shown.map((g, gi) => html`<details key=${g.benchmark} class="card lead-card" open=${!!sel || gi < 2}>
<summary><span class="sumtitle">${g.benchmark}</span>
<span class="pill">${g.n_methods} method${g.n_methods > 1 ? "s" : ""}</span></summary>
<div class="repro-scroll">
<table class="repro-full">
<thead><tr><th>Method</th><th>Paper</th><th>SynthScope-trained</th><th>Official weights</th><th>Status</th></tr></thead>
<tbody>
${g.rows.map((r, i) => html`<tr key=${i}>
<td><b>${r.method}</b>${r.variant && html`<div class="variant">${r.variant}</div>`}</td>
<td>${r.paper}</td>
<td>${r.synthscope || "—"}</td>
<td>${r.official || "—"}</td>
<td><${StatusBadge} s=${r.status} /></td>
</tr>`)}
</tbody>
</table>
</div>
</details>`)}
<${Adv} title="What do the statuses mean?">${LEGEND}<//>
</div>`;
}
// Generator benchmarks: how detectors hold up against today's SOTA generators.
function TopGeneratorsView() {
const [bm, setBm] = useState([]);
const [ov, setOv] = useState(null);
const [err, setErr] = useState("");
useEffect(() => {
fetch(API("/api/generator_benchmarks")).then(r => r.json()).then(d => { setBm(d.benchmarks || []); setOv(d.overview || null); })
.catch(e => setErr(String(e.message || e)));
}, []);
return html`<div class="topgen">
<div class="pagehead"><p class="muted">How well each detector spots images from today's best generators. None of the
detectors were trained on these generators, so this measures how well they generalize. Ranked by AUROC (higher is better).</p></div>
${err && html`<div class="err">${err}</div>`}
${!bm.length && !err && html`<div class="empty">No generator benchmarks yet.</div>`}
${ov && ov.generators.length > 0 && html`<div class="card">
<h2>Overview <span class="muted">AUROC, higher is better</span></h2>
<div class="repro-scroll"><table class="repro-full ovtable">
<thead><tr><th>Detector</th>${ov.generators.map(g => html`<th key=${g}>${g}</th>`)}<th>Mean</th></tr></thead>
<tbody>${ov.rows.map(r => html`<tr key=${r.name}><td><b>${r.name}</b></td>
${r.auroc.map((v, i) => html`<td key=${i} class="ovcell" style=${v == null ? {} : {background: "hsla(" + Math.max(0, Math.min(120, (v - 50) * 2.4)) + ",65%,45%,.35)"}}>${v == null ? "n/a" : v}</td>`)}
<td class="ovcell"><b>${r.mean == null ? "n/a" : r.mean}</b></td></tr>`)}</tbody>
</table></div>
<div class="meta">Colour runs from red (about 50, chance level) to green (100). One row per detector, one column per generator.</div>
</div>`}
<${Adv} title="Add a generator to this page">
<div class="meta">Each column comes from one run of <code>benchmark/generator_bench.py</code>: it makes the images, scores every detector and writes the
results this page shows. Local open-weight generators and hosted ones (with your API key) both work.
<a href=${DOCS_URL} target="_blank" rel="noopener">Step-by-step guide</a>.</div>
<pre class="cmd">python benchmark/generator_bench.py --generator flux2_klein --label "FLUX.2 [klein] 4B" \
--reuse benchmark/generator_runs/reference/manifest.json --out benchmark/generator_runs/flux2_klein_4b</pre>
<//>
${bm.map(b => html`<div key=${b.key} class="card">
<h2>${b.generator} <span class="pill">${b.n_real + b.n_fake} images</span></h2>
<div class="repro-scroll">
<table class="repro-full lb-gen">
<thead><tr><th>#</th><th>Detector</th><th>AUROC</th><th>AP</th><th>Accuracy</th><th>Fakes caught</th><th>Reals flagged</th></tr></thead>
<tbody>
${b.rows.map((r, i) => html`<tr key=${i} class=${i < 3 ? "lb-top" : ""}>
<td>${i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : (i + 1)}</td>
<td><b>${r.name || r.detector}</b></td>
<td class="lb-best">${r.auroc}</td>
<td>${r.ap}</td><td>${r.acc}</td><td>${r.fake_detect_rate}%</td><td>${r.real_false_pos}%</td>
</tr>`)}
</tbody>
</table>
</div>
<${Adv} title="Setup and notes"><div class="meta">${b.settings}. ${b.note}</div><//>
</div>`)}
</div>`;
}
const TABS = [["detect", "Detect"], ["sota", "Test a generator"], ["explore", "Browse datasets"],
["leaderboard", "Leaderboard"], ["topgen", "Generator benchmarks"]];
function App() {
const [models, setModels] = useState([]);
const [device, setDevice] = useState("");
const [model, setModel] = useState("");
const [tab, setTab] = useState("detect");
const [threshold, setThreshold] = useState(0.5);
const [error, setError] = useState("");
const [hasData, setHasData] = useState(true); // hide "Browse datasets" when no dataset is installed (e.g. a public Space)
const [hasGen, setHasGen] = useState(true); // "Test a generator" shows a notice when no generator can run here
const [demo, setDemo] = useState(false); // hosted online demo (SYNTHSCOPE_DEMO=1, e.g. the Hugging Face Space)
useEffect(() => {
fetch(API("/api/datasets")).then(r => r.json()).then(d => setHasData((d.datasets || []).length > 0)).catch(() => {});
fetch(API("/api/sota/generators")).then(r => r.json()).then(d => {
setHasGen((d.generators || []).some(g => g.available)); setDemo(!!d.demo);
}).catch(() => {});
fetch(API("/api/models")).then(r => r.json()).then(d => {
setModels(d.models || []);
setDevice(d.device || "");
const firstAvail = (d.models || []).find(m => m.available);
if (firstAvail) setModel(firstAvail.key);
}).catch(e => setError("Could not reach backend: " + e));
}, []);
const shared = { models, model, setModel, threshold, setThreshold };
return html`
<div class="wrap">
<header class="top">
<div>
<h1>SynthScope</h1>
<p>Detect AI-generated images and videos, and see how today's detectors hold up against today's generators.</p>
</div>
${device && html`<span class="pill" title="Where the detectors run">${device}</span>`}
</header>
<div class="tabs">
${TABS.filter(([k]) => k !== "explore" || hasData).map(([k, label]) => html`<button key=${k} class=${"tab" + (tab === k ? " active" : "")} onClick=${() => setTab(k)}>${label}</button>`)}
</div>
${error && html`<div class="err">${error}</div>`}
${tab === "detect" ? html`<${DetectView} ...${shared} />`
: tab === "explore" ? html`<${ExploreView} ...${shared} />`
: tab === "sota" ? (hasGen ? html`<${SotaView} models=${models} />` : html`<${GeneratorOff} demo=${demo} />`)
: tab === "leaderboard" ? html`<${LeaderboardView} />`
: html`<${TopGeneratorsView} />`}
</div>`;
}
function DetectView({ models, model, setModel, threshold, setThreshold }) {
const [file, setFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState("");
const [isVideo, setIsVideo] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const [drag, setDrag] = useState(false);
const inputRef = useRef();
const selected = models.find(m => m.key === model);
function onPick(f) {
if (!f) return;
setFile(f); setResult(null); setError("");
setIsVideo((f.type || "").startsWith("video") || /\.(mp4|mov|avi|mkv|webm|m4v)$/i.test(f.name));
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(URL.createObjectURL(f));
}
async function run() {
if (!file || !model) return;
setBusy(true); setResult(null); setError("");
try {
const fd = new FormData(); fd.append("file", file); fd.append("model", model);
const r = await fetch(API("/api/predict"), { method: "POST", body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
setResult(d);
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(false); }
}
return html`
<div class="grid">
<div class="card">
<h2>Your file</h2>
<div class=${"drop" + (drag ? " drag" : "")}
onClick=${() => inputRef.current.click()}
onDragOver=${e => { e.preventDefault(); setDrag(true); }}
onDragLeave=${() => setDrag(false)}
onDrop=${e => { e.preventDefault(); setDrag(false); onPick(e.dataTransfer.files[0]); }}>
${file ? html`<b>${file.name}</b> <span class="meta">(${(file.size/1048576).toFixed(2)} MB) · click to change</span>`
: "Click or drop an image or video here"}
</div>
<input ref=${inputRef} type="file" accept="image/*,video/*" style=${{display:"none"}}
onChange=${e => onPick(e.target.files[0])} />
${previewUrl && html`<div class="preview">
${isVideo ? html`<video src=${previewUrl} controls/>` : html`<img src=${previewUrl}/>`}
</div>`}
<label>Detector</label>
<${ModelSelect} models=${models} model=${model} setModel=${setModel} onChange=${() => setResult(null)} />
${selected && selected.modality === "image" && isVideo && html`<div class="meta">This image detector scores sampled video frames and averages them.</div>`}
<button class="go" disabled=${!file || !model || busy} onClick=${run}>
${busy ? "Analyzing…" : "Analyze"}
</button>
${error && html`<div class="err">${error}</div>`}
${selected && html`<${Adv} title="About this detector">
<div class="meta">${selected.paper}${selected.notes ? " · " + selected.notes : ""}</div>
<${InputNote} m=${selected} />
<${ReproTable} m=${selected} />
<//>`}
</div>
<div class="card">
<h2>Result</h2>
${!result && !busy && html`<div class="empty">Upload a file and press Analyze.</div>`}
${busy && html`<div class="empty">Running ${selected ? selected.name : "the detector"}… the first run loads the model and may take a while.</div>`}
${result && html`<${Result} r=${result} threshold=${threshold} />`}
${result && html`<${Adv} title="Advanced: decision threshold"><${ThresholdSlider} threshold=${threshold} setThreshold=${setThreshold} /><//>`}
</div>
</div>`;
}
function ExploreView({ models, model, setModel, threshold, setThreshold }) {
const [datasets, setDatasets] = useState([]);
const [trainMap, setTrainMap] = useState({});
const [dataset, setDataset] = useState("");
const [split, setSplit] = useState("");
const [seed, setSeed] = useState(1);
const [items, setItems] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [sel, setSel] = useState(null);
const [preds, setPreds] = useState({}); // rel -> {fake_probability, ground_truth, ...}
const [scoring, setScoring] = useState(false);
const [fpr, setFpr] = useState(5);
const [demo, setDemo] = useState(false);
const [supported, setSupported] = useState(null); // {image: n, video: n} datasets a local install can fetch
const batchN = 24;
const curDs = datasets.find(d => d.key === dataset);
const trainDsKey = (trainMap[model] || {}).dataset;
useEffect(() => {
fetch(API("/api/datasets")).then(r => r.json()).then(d => {
setDatasets(d.datasets || []); setTrainMap(d.model_train || {});
setDemo(!!d.demo); setSupported(d.supported || null);
if ((d.datasets || []).length) {
const first = d.datasets[0];
setDataset(first.key); setSplit(first.splits[first.splits.length - 1] || ".");
}
}).catch(e => setError("Could not load datasets: " + e));
}, []);
useEffect(() => {
if (!curDs) return;
const s = curDs.splits.includes(split) ? split : (curDs.splits[curDs.splits.length - 1] || ".");
if (s !== split) setSplit(s);
loadBatch(dataset, s, seed);
}, [dataset]);
async function loadBatch(dsKey, sp, sd) {
if (!dsKey) return;
setBusy(true); setError(""); setItems([]); setSel(null); setPreds({});
try {
const r = await fetch(API(`/api/datasets/${dsKey}/sample?split=${encodeURIComponent(sp)}&n=${batchN}&seed=${sd}`));
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
setItems(d.items || []);
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(false); }
}
function newBatch() { const sd = seed + 1; setSeed(sd); loadBatch(dataset, split, sd); }
async function pickItem(it) {
setSel(it.rel);
if (!model || preds[it.rel]) return;
try {
const fd = new FormData();
fd.append("dataset", dataset); fd.append("rel", it.rel); fd.append("model", model);
const r = await fetch(API("/api/predict_dataset"), { method: "POST", body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
setPreds(p => ({ ...p, [it.rel]: d }));
} catch (e) { setPreds(p => ({ ...p, [it.rel]: { error: String(e.message || e) } })); }
}
async function scoreAll() {
if (!model || !items.length) return;
setScoring(true); setError("");
try {
const r = await fetch(API("/api/score_batch"), {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dataset, model, rels: items.map(i => i.rel) }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status));
const m = {};
for (const it of d.items) m[it.rel] = it;
setPreds(p => ({ ...p, ...m }));
return d.items.filter(x => x.ground_truth && typeof x.fake_probability === "number")
.map(x => ({ p: x.fake_probability, gt: x.ground_truth }));
} catch (e) { setError(String(e.message || e)); return []; }
finally { setScoring(false); }
}
const scored = items.map(i => preds[i.rel]).filter(x => x && typeof x.fake_probability === "number" && x.ground_truth)
.map(x => ({ p: x.fake_probability, gt: x.ground_truth }));
async function calibrate() { const s = scored.length ? scored : await scoreAll(); if (s.length) setThreshold(autoCalibrate(s)); }
async function applyFpr() {
const s = scored.length ? scored : await scoreAll();
const t = targetFprThreshold(s, fpr / 100);
if (t != null) setThreshold(t); else setError("Need scored real videos for target-FPR.");
}
const mediaUrl = (it, thumb) =>
API(`/api/datasets/${dataset}/media?rel=${encodeURIComponent(it.rel)}${thumb ? "&thumb=1" : ""}`);
const selItem = items.find(i => i.rel === sel);
const selRes = sel ? preds[sel] : null;
const stats = batchStats(scored, threshold);
return html`
<div class="explore">
${demo && html`<div class="card notice slim">
<b>Demo subset.</b> To keep this online demo light it includes a few files per generator from
${" "}${listNames(datasets.map(d => d.name.split(" (")[0]))}.
${" "}Run SynthScope on your own computer and it downloads and browses every supported benchmark
${supported ? ` (${supported.image} image and ${supported.video} video datasets)` : ""}, each with one command;
see the <a href=${SETUP_URL} target="_blank" rel="noopener">setup guide</a>.
</div>`}
<div class="card detbar2">
<div class="detsel">
<label>Detector</label>
<${ModelSelect} models=${models} model=${model} setModel=${setModel} />
</div>
${trainDsKey && html`<div class="meta">Trained on${" "}
<b>${(datasets.find(d=>d.key===trainDsKey)||{}).name || trainDsKey}</b>${" "}
${(trainMap[model]||{}).official ? html`<span class="badge">official weights</span>` : ""}</div>`}
<${Adv} title="About this detector"><${InputNote} m=${models.find(m => m.key === model)} /><${ReproTable} m=${models.find(m => m.key === model)} /><//>
</div>
<div class="grid" style=${{gridTemplateColumns: "2fr 1fr", marginTop: 22}}>
<div class="card">
<h2>Samples</h2>
<div class="toolbar">
<div><label>Dataset</label>
<select value=${dataset} onChange=${e => setDataset(e.target.value)}>
${datasets.map(d => html`<option key=${d.key} value=${d.key}>
${d.name}${d.key === trainDsKey ? " ★ training set" : ""} · ${d.modality}</option>`)}
</select></div>
<div style=${{maxWidth: 130}}><label>Split</label>
<select value=${split} onChange=${e => { setSplit(e.target.value); loadBatch(dataset, e.target.value, seed); }}>
${(curDs ? curDs.splits : []).map(s => html`<option key=${s} value=${s}>${s === "." ? "(all)" : s}</option>`)}
</select></div>
<div style=${{maxWidth: 120, flex: "0 0 auto"}}><button class="sm" disabled=${busy} onClick=${newBatch}>↻ New batch</button></div>
</div>
${error && html`<div class="err">${error}</div>`}
${busy && html`<div class="empty">Sampling…</div>`}
<div class="thumbs">
${items.map(it => {
const pr = preds[it.rel];
const has = pr && typeof pr.fake_probability === "number";
const v = has ? verdictAt(pr.fake_probability, threshold) : null;
const ok = has && v === it.label;
return html`<div key=${it.rel} class=${"thumb" + (it.rel === sel ? " sel" : "")} onClick=${() => pickItem(it)} title=${it.name}>
<img loading="lazy" src=${mediaUrl(it, true)} />
<span class=${"tag " + it.label}>${it.label}</span>
${it.modality === "video" && html`<span class="play">▶</span>`}
${has && html`<span class=${"pred " + (ok ? "ok" : "no")}>${(pr.fake_probability*100).toFixed(0)}% ${ok ? "✓" : "✗"}</span>`}
</div>`;
})}
</div>
${stats && html`<div class="stat">Batch accuracy (${stats.n} scored, threshold ${threshold.toFixed(2)}):
<b>${(stats.acc*100).toFixed(0)}%</b>
${stats.racc != null ? html` · real images ${(stats.racc*100).toFixed(0)}% right` : ""}
${stats.facc != null ? html` · fake images ${(stats.facc*100).toFixed(0)}% right` : ""}</div>`}
<${Adv} title="Score the whole batch / tune the threshold">
<div class="calib">
<button disabled=${scoring || !model} onClick=${scoreAll}>${scoring ? "Scoring…" : "Score batch"}</button>
<button disabled=${scoring || !model} onClick=${calibrate} title="Threshold that maximizes balanced accuracy on this labeled batch">Auto-calibrate</button>
<button disabled=${scoring || !model} onClick=${applyFpr} title="Threshold that keeps real images wrongly flagged below this rate">Limit false alarms to</button>
<input type="number" min="0" max="50" step="1" value=${fpr} onChange=${e => setFpr(parseFloat(e.target.value) || 0)} />%
</div>
<${ThresholdSlider} threshold=${threshold} setThreshold=${setThreshold} />
<//>
</div>
<div class="card">
<h2>Detector output</h2>
${!selItem && html`<div class="empty">Click a sample to run ${(models.find(m=>m.key===model)||{}).name || "the detector"} on it.</div>`}
${selItem && html`<div>
<div class="preview">
${selItem.modality === "video" ? html`<video src=${mediaUrl(selItem, false)} controls/>` : html`<img src=${mediaUrl(selItem, false)}/>`}
</div>
<div class="meta">${selItem.name} · ground truth: <b style=${{color: selItem.label==="fake"?"var(--fake)":"var(--real)"}}>${selItem.label}</b></div>
${selRes && selRes.error && html`<div class="err">${selRes.error}</div>`}
${selRes && !selRes.error && typeof selRes.fake_probability === "number" && html`<${Result} r=${selRes} threshold=${threshold} gt=${selItem.label} />`}
</div>`}
</div>
</div>
</div>`;
}
function Result({ r, threshold, gt }) {
const fakePct = r.fake_probability * 100, realPct = (1 - r.fake_probability) * 100;
const verdict = verdictAt(r.fake_probability, threshold);
const correct = gt ? (verdict === gt) : null;
return html`
<div class=${"verdict " + verdict}>${verdict === "fake" ? "AI-GENERATED" : "REAL"}</div>
<div style=${{textAlign:"center", color:"var(--muted)", fontSize:13}}>
${fakePct.toFixed(1)}% likely AI-generated
${correct != null ? html` · <b style=${{color: correct ? "var(--real)" : "var(--fake)"}}>${correct ? "correct" : "wrong"}</b>` : ""}</div>
<div class="bar">
<span class="fake" style=${{width: fakePct + "%"}}>${fakePct >= 12 ? "fake " + fakePct.toFixed(0) + "%" : ""}</span>
<span class="real" style=${{width: realPct + "%"}}>${realPct >= 12 ? "real " + realPct.toFixed(0) + "%" : ""}</span>
<span class="thrline" style=${{left: (threshold*100) + "%"}}></span>
</div>
${r.input_image && html`<div class="seen">
<div class="seen-title">What ${r.model_name || "the detector"} sees</div>
<div class="imgcell seenbox"><img src=${r.input_image} alt="the detector's input" /></div>
<div class="meta seen-cap">
${r.input_note && html`<span class="itag">${r.input_note.label}</span>`}
${r.input_size && r.orig_size && html`<b>${r.input_size[0]}×${r.input_size[1]} px</b>${(r.input_size[0] === r.orig_size[0] && r.input_size[1] === r.orig_size[1]) ? " (your image's own size)" : " (from your " + r.orig_size[0] + "×" + r.orig_size[1] + " image)"}. `}
${r.input_note && r.input_note.explanation}
</div>
</div>`}
<div class="meta" style=${{textAlign:"center"}}>${[r.media_type ? r.media_type + (r.num_frames ? " · " + r.num_frames + " frames" : "") : "", r.elapsed_ms ? r.elapsed_ms + " ms" : ""].filter(Boolean).join(" · ")}</div>
${r.per_frame && r.per_frame.length > 1 && html`<div class="frames">
<div class="meta">Per-frame P(fake) — ${r.per_frame.length} frames sampled (dashed line = 50% threshold):</div>
<div class="spark">
<span class="thr50"></span>
${r.per_frame.map((v,i) => html`<i key=${i} title=${"frame " + (i+1) + ": " + (v*100).toFixed(1) + "%"}
style=${{height: Math.max(3, v*100) + "%", background: v >= 0.5 ? "var(--fake)" : "var(--accent)"}}/>`)}
</div>
<div class="frame-stats">
<span>min <b>${(Math.min(...r.per_frame)*100).toFixed(0)}%</b></span>
<span>mean <b>${(r.per_frame.reduce((a,b)=>a+b,0)/r.per_frame.length*100).toFixed(0)}%</b></span>
<span>max <b>${(Math.max(...r.per_frame)*100).toFixed(0)}%</b></span>
</div>
</div>`}
${r.interpret_map && html`<${Heatmap} r=${r} />`}`;
}
// What each heatmap shows. "evidence" maps point at regions that look generated; "signal" maps show the low-level
// pattern the detector reads, which is not by itself a verdict.
const HEAT_INFO = {
spai_patch: ["evidence", "Spectral evidence (SPAI)", "Each patch is scored on its frequency spectrum. Red patches look most like AI-generated content."],
pgc_peak: ["evidence", "Peak-guided evidence (PGC)", "Highlights the image regions whose fine residual pattern contributes most to the 'fake' score."],
safe_hf: ["signal", "High-frequency detail (SAFE)", "Shows the fine texture band SAFE inspects. Bright areas have stronger fine-grained structure; this is the signal it reads, not a verdict."],
npr_residual: ["signal", "Neighbouring-pixel residual (NPR)", "Shows how each pixel differs from its neighbours, the up-sampling trace NPR looks for. Brighter means a stronger trace."],
aide_dct: ["signal", "Frequency-patch energy (AIDE)", "Shows where the image has the most high-frequency (DCT) energy; AIDE picks its patches from these areas."],
};
function Heatmap({ r }) {
const [kind, title, expl] = HEAT_INFO[r.interpret_kind] || ["signal", "Artifact map", "Highlights image regions the detector relies on."];
return html`<div class="interp">
<div class="interp-title">Where the detector looked <span class="itag">${title}</span></div>
<div class="meta">${expl}</div>
<div class="legend">
<span>${kind === "evidence" ? "weaker evidence" : "weaker signal"}</span><i class="legbar"></i><span>${kind === "evidence" ? "stronger evidence of AI generation" : "stronger signal"}</span>
</div>
<div class="interp-row">
${r.interpret_overlay && html`<figure class="interp-cell"><div class="imgcell"><img class="interp-map" src=${r.interpret_overlay} alt="heatmap on your image"/></div><figcaption>On your image</figcaption></figure>`}
<figure class="interp-cell"><div class="imgcell"><img class="interp-map" src=${r.interpret_map} alt="heatmap"/></div><figcaption>Heatmap only</figcaption></figure>
</div>
<div class="meta">${kind === "signal" ? "A strong signal is not proof of AI generation: real photos can have it too." : "Evidence maps are an aid to interpretation; the verdict above is the detector's actual score."}</div>
</div>`;
}
createRoot(document.getElementById("root")).render(html`<${App}/>`);
</script>
</body>
</html>