cupido/scripts/barrier_picker_app/static/index.html
Giorgio Gilestro 4ed988a617 Show experimental metadata above the video in the picker
Each video row now carries a `metadata` dict aggregated from the
merged TSV: species, memory (STM/LTM), training_length_hr,
consolidation_length_hr, age, training/testing date-time, and
trained/naive fly counts. The UI renders these as a row of key:value
pills above the video, with the session role (training/testing)
colour-coded so the analyst can see at a glance what they're picking.

The merged TSV currently has duplicate rows per (date, machine, ROI);
the aggregator de-dups on those keys so counts aren't doubled. (The
duplication itself should be cleaned up upstream.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:54:40 +01:00

252 lines
9.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cupido — barrier-opening picker</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
background: #1a1a1a; color: #e8e8e8; }
header { padding: 0.6rem 1rem; background: #111; border-bottom: 1px solid #333;
display: flex; align-items: center; gap: 1.5rem; }
h1 { margin: 0; font-size: 1rem; font-weight: 500; color: #ccc; }
#status { font-family: ui-monospace, "SF Mono", monospace; font-size: 0.85rem;
color: #9aa; }
#info { font-family: ui-monospace, monospace; font-size: 0.85rem; color: #cce; }
#meta { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 0.6rem 0 0.4rem;
font-size: 0.85rem; color: #aab; max-width: 1400px; width: 100%;
justify-content: center; }
#meta .pair { font-family: ui-monospace, monospace; }
#meta .pair .k { color: #678; }
#meta .pair .v { color: #def; margin-left: 0.25rem; }
#meta .role-training { color: #cd6 !important; }
#meta .role-testing { color: #6cd !important; }
main { display: flex; flex-direction: column; align-items: center; padding: 1rem; }
video { width: 100%; max-width: 1400px; height: auto; background: #000;
border-radius: 4px; }
#controls { margin-top: 1rem; display: flex; gap: 1rem; flex-wrap: wrap;
justify-content: center; }
button { padding: 0.7rem 1.4rem; font-size: 0.95rem; border: 1px solid #444;
background: #2a2a2a; color: #eee; border-radius: 4px; cursor: pointer;
transition: all 80ms; }
button:hover { background: #383838; border-color: #666; }
button:active { transform: translateY(1px); }
button.primary { background: #2d5; color: #053; border-color: #1a4;
font-weight: 600; }
button.primary:hover { background: #3e6; }
button.warn { background: #d84; color: #311; border-color: #b62; }
button.warn:hover { background: #ea5; }
button.muted { background: #2a2a2a; color: #888; }
.mute-divider { width: 1px; background: #333; margin: 0 0.3rem; }
#help { margin-top: 1rem; font-size: 0.8rem; color: #889; text-align: center;
font-family: ui-monospace, monospace; }
kbd { background: #222; border: 1px solid #444; border-radius: 3px;
padding: 0.1rem 0.4rem; font-size: 0.8rem; color: #ddd;
font-family: ui-monospace, monospace; }
#progress { font-size: 0.85rem; color: #889; margin-left: auto; }
#flash { position: fixed; top: 0.5rem; right: 0.5rem; padding: 0.5rem 1rem;
border-radius: 4px; opacity: 0; transition: opacity 200ms;
font-family: ui-monospace, monospace; font-size: 0.85rem; }
#flash.show { opacity: 1; }
#flash.ok { background: #2d5; color: #042; }
#flash.err { background: #d44; color: white; }
</style>
</head>
<body>
<header>
<h1>Cupido — barrier picker</h1>
<span id="info">loading…</span>
<span id="progress"></span>
</header>
<main>
<div id="meta"></div>
<video id="player" controls preload="auto"></video>
<div id="controls">
<button class="primary" data-mode="all">All barriers open <kbd>1</kbd></button>
<button class="primary" data-mode="upper">Upper barrier opens <kbd>2</kbd></button>
<button class="primary" data-mode="lower">Lower barrier opens <kbd>3</kbd></button>
<span class="mute-divider"></span>
<button class="muted" data-mode="skip">Skip <kbd>s</kbd></button>
<button class="warn" data-mode="unusable">Unusable <kbd>u</kbd></button>
<span class="mute-divider"></span>
<button class="muted" id="prev">◀ Previous</button>
<button class="muted" id="next">Next ▶</button>
</div>
<div id="help">
<kbd>Space</kbd> play/pause &nbsp;·&nbsp;
<kbd></kbd> / <kbd></kbd> ±5 s &nbsp;·&nbsp;
<kbd>Shift</kbd>+arrows ±1 s &nbsp;·&nbsp;
<kbd>Ctrl</kbd>+arrows ±0.1 s &nbsp;·&nbsp;
<kbd>,</kbd> / <kbd>.</kbd> ±1 frame
</div>
<div id="flash"></div>
</main>
<script>
const player = document.getElementById('player');
const info = document.getElementById('info');
const meta = document.getElementById('meta');
const progress = document.getElementById('progress');
const flash = document.getElementById('flash');
let queue = [];
let cursor = 0;
function showFlash(msg, kind = 'ok') {
flash.textContent = msg;
flash.className = 'show ' + kind;
setTimeout(() => { flash.className = ''; }, 1800);
}
function updateProgress() {
const done = queue.filter(q => q.done).length;
progress.textContent = `${done}/${queue.length} done`;
}
function renderMeta(m) {
meta.innerHTML = '';
if (!m) return;
const fields = [
['role', m.session_role,
m.session_role === 'training' ? 'role-training'
: m.session_role === 'testing' ? 'role-testing' : ''],
['species', m.species],
['memory', m.memory],
['training (hr)', m.training_length_hr],
['consol. (hr)', m.consolidation_length_hr],
['age (d)', m.age],
['flies', (m.n_trained || m.n_naive)
? `${m.n_trained || 0} trained · ${m.n_naive || 0} naive`
: null],
['training time', m.training_date_time],
['testing time', m.testing_date_time],
];
for (const [label, value, cls] of fields) {
if (value === undefined || value === null || value === '') continue;
const span = document.createElement('span');
span.className = 'pair';
span.innerHTML = `<span class="k">${label}:</span><span class="v ${cls||''}">${value}</span>`;
meta.appendChild(span);
}
}
function loadCursor() {
if (queue.length === 0) {
info.textContent = 'queue empty';
return;
}
cursor = ((cursor % queue.length) + queue.length) % queue.length;
const item = queue[cursor];
info.textContent =
`[${cursor + 1}/${queue.length}] ${item.machine_name} ${item.session_date} ${item.session_time} ` +
(item.duration_s ? `(${(item.duration_s/60).toFixed(1)} min)` : '') +
(item.done ? ' — already done' : '');
renderMeta(item.metadata);
player.src = `/api/video/${item.idx}`;
player.load();
}
async function fetchQueue() {
const resp = await fetch('/api/queue');
queue = await resp.json();
// Jump to first not-yet-done
const firstUndone = queue.findIndex(q => !q.done);
cursor = firstUndone === -1 ? 0 : firstUndone;
updateProgress();
loadCursor();
}
async function submit(mode) {
if (queue.length === 0) return;
const item = queue[cursor];
const payload = {
idx: item.idx,
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
mode: mode,
notes: '',
};
try {
const resp = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
showFlash('error: ' + (err.detail || resp.status), 'err');
return;
}
const result = await resp.json();
if (result.status === 'saved') {
item.done = true;
updateProgress();
const t = result.row.opening_s;
const bad = result.row.bad_rois;
showFlash(
`saved ${item.machine_name} ${item.session_date} ${item.session_time}: ` +
(Number.isNaN(t) || t === null ? 'unusable' :
`${t.toFixed(1)}s${bad ? ' (bad ROIs: ' + bad + ')' : ''}`)
);
} else if (result.status === 'skipped') {
showFlash(`skipped ${item.machine_name} ${item.session_date} ${item.session_time}`);
}
cursor = (cursor + 1) % queue.length;
loadCursor();
} catch (e) {
showFlash('network error: ' + e.message, 'err');
}
}
// Button handlers
document.querySelectorAll('button[data-mode]').forEach(btn => {
btn.addEventListener('click', () => submit(btn.dataset.mode));
});
document.getElementById('prev').addEventListener('click', () => {
cursor = (cursor - 1 + queue.length) % queue.length;
loadCursor();
});
document.getElementById('next').addEventListener('click', () => {
cursor = (cursor + 1) % queue.length;
loadCursor();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Prevent the browser default (e.g. video focus side effects on space).
const stop = () => { e.preventDefault(); e.stopPropagation(); };
switch (e.key) {
case ' ':
stop();
if (player.paused) player.play(); else player.pause();
break;
case 'ArrowLeft':
stop();
player.currentTime -= e.ctrlKey ? 0.1 : (e.shiftKey ? 1 : 5);
break;
case 'ArrowRight':
stop();
player.currentTime += e.ctrlKey ? 0.1 : (e.shiftKey ? 1 : 5);
break;
case ',':
stop();
// Step back one frame (assume 25 fps if unknown)
player.currentTime -= 1 / 25;
break;
case '.':
stop();
player.currentTime += 1 / 25;
break;
case '1': stop(); submit('all'); break;
case '2': stop(); submit('upper'); break;
case '3': stop(); submit('lower'); break;
case 's': stop(); submit('skip'); break;
case 'u': stop(); submit('unusable'); break;
}
});
fetchQueue();
</script>
</body>
</html>