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>
252 lines
9.8 KiB
HTML
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 ·
|
|
<kbd>←</kbd> / <kbd>→</kbd> ±5 s ·
|
|
<kbd>Shift</kbd>+arrows ±1 s ·
|
|
<kbd>Ctrl</kbd>+arrows ±0.1 s ·
|
|
<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>
|