cupido/scripts/barrier_picker_app/static/index.html
Giorgio Gilestro 2607fa4a85 Picker: lock video box to fixed aspect ratio so the layout doesn't jump
The <video> element was auto-sizing to its content, which means while a
new video is loading it shrinks to spinner size and pushes everything
below it (annotation bar, buttons) up by ~700 px. Switching to
aspect-ratio: 1920/1088 (the source resolution) keeps the box at full
size throughout, so the buttons stay where the analyst's mouse expects
them between videos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 14:48:15 +01:00

540 lines
23 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; }
/* Reason: lock the video box to the source aspect ratio (1920x1088 is
what the ethoscope cameras emit). Without this, the box collapses
to spinner size while a new video is loading and every other
component on the page jumps around. */
video { width: 100%; max-width: 1400px; aspect-ratio: 1920 / 1088;
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; }
/* Welcome modal */
#help-btn { background: transparent; border: 1px solid #444; color: #aab;
font-size: 0.85rem; padding: 0.3rem 0.7rem; }
#help-btn:hover { background: #2a2a2a; }
#modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 100; padding: 1rem; }
#modal-backdrop.show { display: flex; }
#modal { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 640px; width: 100%;
max-height: 90vh; overflow-y: auto; color: #ddd; line-height: 1.55; }
#modal h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.2rem; }
#modal h3 { margin: 1.2rem 0 0.4rem; color: #cce; font-size: 0.95rem;
font-weight: 600; }
#modal p { margin: 0.5rem 0; }
#modal ol { padding-left: 1.4rem; margin: 0.5rem 0; }
#modal li { margin: 0.6rem 0; }
#modal .button-tag { display: inline-block; padding: 0.05rem 0.4rem;
background: #2d5; color: #053; border-radius: 3px;
font-weight: 600; font-size: 0.85rem; }
#modal-close { margin-top: 1.4rem; width: 100%; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#modal-close:hover { background: #3e6; }
/* User badge in header */
#user-badge { background: #2a3; color: #042; font-weight: 700;
padding: 0.2rem 0.6rem; border-radius: 12px;
font-family: ui-monospace, monospace; font-size: 0.85rem;
cursor: pointer; user-select: none; }
#user-badge:hover { background: #3b4; }
#user-badge.empty { background: #d84; color: #311; }
/* Login modal — narrower than the welcome modal */
#login-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: none; align-items: center; justify-content: center;
z-index: 200; }
#login-backdrop.show { display: flex; }
#login-box { background: #222; border: 1px solid #444; border-radius: 6px;
padding: 1.6rem 2rem; max-width: 380px; width: 90%;
color: #ddd; }
#login-box h2 { margin: 0 0 0.6rem; color: #fff; font-size: 1.1rem; }
#login-box p { margin: 0.4rem 0 1rem; color: #aab; font-size: 0.9rem; }
#login-input { width: 100%; padding: 0.6rem 0.8rem; font-size: 1.1rem;
background: #111; color: #fff; border: 1px solid #444;
border-radius: 4px; text-align: center;
font-family: ui-monospace, monospace; letter-spacing: 0.2em;
text-transform: uppercase; }
#login-input:focus { outline: none; border-color: #6c5; }
#login-submit { width: 100%; margin-top: 1rem; padding: 0.7rem;
background: #2d5; color: #053; font-weight: 600;
border-radius: 4px; cursor: pointer; border: 1px solid #1a4;
font-size: 0.95rem; }
#login-submit:hover { background: #3e6; }
#login-submit:disabled { background: #444; color: #888; cursor: not-allowed; }
/* Annotation bar (existing-mark indicator below the video) */
#annotation-bar { position: relative; width: 100%; max-width: 1400px;
height: 18px; background: #555; border: 1px solid #666;
border-radius: 3px; margin: 0.5rem 0 2rem;
cursor: pointer; }
#annotation-bar.empty { background: transparent; border-color: transparent;
cursor: default; height: 0; margin: 0; }
#annotation-bar .mark-line { position: absolute; top: -2px; bottom: -2px;
width: 3px; background: #f44;
box-shadow: 0 0 4px rgba(255,80,80,0.7); }
#annotation-bar .mark-line:hover { background: #f88; }
#annotation-bar .mark-tooltip { position: absolute; top: 100%;
transform: translateX(-50%);
margin-top: 4px; background: #f44;
color: white; padding: 2px 6px;
font-size: 0.75rem; border-radius: 3px;
font-family: ui-monospace, monospace;
white-space: nowrap; pointer-events: none; }
</style>
</head>
<body>
<header>
<h1>Cupido — barrier picker</h1>
<span id="info">loading…</span>
<span id="progress"></span>
<span id="user-badge" title="click to change initials"></span>
<button id="help-btn" title="show the help modal">?</button>
</header>
<div id="login-backdrop">
<div id="login-box">
<h2>Who are you?</h2>
<p>Enter your initials so we can record who annotated each video.
(Just letters, e.g. <code>GG</code>.)</p>
<input id="login-input" maxlength="4" autocomplete="off" autofocus />
<button id="login-submit" disabled>Continue</button>
</div>
</div>
<div id="modal-backdrop">
<div id="modal">
<h2>Welcome to the Cupido barrier picker</h2>
<p>For each tracked video you'll mark the moment the barrier between
the two halves of the arena is removed and the flies start interacting.
That timestamp is what every downstream "post-opening" analysis needs.</p>
<h3>How to annotate one video</h3>
<ol>
<li><strong>Drag the seekbar to the end of the video</strong> and check
whether <em>all</em> ROIs are open or only the upper or lower half.
This determines which button you'll click later.</li>
<li><strong>Drag back toward the beginning</strong> to roughly find
the moment the barrier(s) lift. The opening can be anywhere from a
few seconds to several minutes in.</li>
<li><strong>Use the arrow keys</strong> to pin down the exact frame:
<kbd></kbd>/<kbd></kbd> jump ±5 s, <kbd>Shift</kbd>+arrow jump ±30 s.
Pause the player on the opening frame.</li>
<li><strong>Click the button</strong> matching what opened — the
playhead time is recorded, ROI inclusion is set accordingly, and
the next video loads automatically.</li>
</ol>
<h3>The three buttons</h3>
<p>
<span class="button-tag">All barriers open</span> — every ROI usable<br>
<span class="button-tag">Upper barrier opens</span> — only ROIs 1, 3, 5 (top row); lower row will be excluded from analysis<br>
<span class="button-tag">Lower barrier opens</span> — only ROIs 2, 4, 6 (bottom row); upper row excluded
</p>
<h3>Other controls</h3>
<p>
<kbd>Space</kbd> play/pause &middot;
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons &middot;
<kbd>s</kbd> skip &middot; <kbd>u</kbd> mark unusable
</p>
<p style="font-size: 0.85rem; color: #889;">Each click saves to
<code>barrier_opening.csv</code> immediately, so closing the tab
and coming back resumes from where you left off.</p>
<button id="modal-close">Got it — let's start</button>
</div>
</div>
<main>
<div id="meta"></div>
<video id="player" controls preload="auto"></video>
<div id="annotation-bar" class="empty"></div>
<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 ±30 s &nbsp;·&nbsp;
drag the seekbar for finer control
</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');
const userBadge = document.getElementById('user-badge');
const loginBackdrop = document.getElementById('login-backdrop');
const loginInput = document.getElementById('login-input');
const loginSubmit = document.getElementById('login-submit');
// ─── Analyst identity (persisted in localStorage) ───────────────────
function getAnalyst() {
try { return localStorage.getItem('cupido.analyst') || ''; }
catch (e) { return ''; }
}
function setAnalyst(s) {
try { localStorage.setItem('cupido.analyst', s); } catch (e) {}
renderUserBadge();
}
function renderUserBadge() {
const a = getAnalyst();
userBadge.textContent = a || 'sign in';
userBadge.classList.toggle('empty', !a);
}
function showLogin() {
loginBackdrop.classList.add('show');
loginInput.value = getAnalyst();
loginSubmit.disabled = !loginInput.value.trim();
setTimeout(() => loginInput.focus(), 50);
}
function hideLogin() { loginBackdrop.classList.remove('show'); }
function submitLogin() {
const v = loginInput.value.trim().toUpperCase();
if (!v) return;
setAnalyst(v);
hideLogin();
// After first login, show the welcome modal if not yet seen.
try {
if (localStorage.getItem('cupido.welcomed') !== '1') {
showModal(); // welcome modal — defined below
}
} catch (e) {}
}
loginInput.addEventListener('input', () => {
loginSubmit.disabled = !loginInput.value.trim();
});
loginInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && loginInput.value.trim()) submitLogin();
});
loginSubmit.addEventListener('click', submitLogin);
userBadge.addEventListener('click', showLogin);
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`;
}
// Reason: the CSV stores `bad_rois` (those that didn't open). The
// user-facing label flips this to the positive sense — "only ROIs X"
// are the usable ones. Empty bad_rois means all ROIs are usable.
function onlyRoisFromBad(badStr) {
if (!badStr) return '';
const bad = new Set(String(badStr).split(',').map(s => parseInt(s.trim())));
return [1,2,3,4,5,6].filter(r => !bad.has(r)).join(',');
}
function renderAnnotationBar(item) {
const bar = document.getElementById('annotation-bar');
bar.innerHTML = '';
const dur = item.duration_s;
const ex = item.existing;
// Always allow click-to-seek if we have a duration; only show as a real
// bar (with marks) when there's an existing annotation to display.
if (!dur) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
if (!ex || ex.opening_s == null || isNaN(ex.opening_s)) {
bar.classList.add('empty');
bar.onclick = null;
return;
}
bar.classList.remove('empty');
// Click anywhere on the bar = seek to that fractional position.
bar.onclick = (e) => {
const rect = bar.getBoundingClientRect();
const t = (e.clientX - rect.left) / rect.width * dur;
player.currentTime = Math.max(0, Math.min(dur, t));
};
// Mark line at opening_s
const pct = (ex.opening_s / dur) * 100;
const line = document.createElement('div');
line.className = 'mark-line';
line.style.left = `calc(${pct}% - 1px)`;
// Click on the line specifically = snap to exact opening_s
line.onclick = (e) => {
e.stopPropagation();
player.currentTime = Math.max(0, Math.min(dur, ex.opening_s));
};
const tooltip = document.createElement('div');
tooltip.className = 'mark-tooltip';
const m = Math.floor(ex.opening_s / 60);
const s = (ex.opening_s % 60).toFixed(1);
const only = onlyRoisFromBad(ex.bad_rois);
const tag = only ? ` · only ROIs ${only}` : '';
const who = ex.analyst ? ` · ${ex.analyst}` : '';
tooltip.textContent = `${m}:${s.padStart(4,'0')}${who}${tag}`;
// Position tooltip; if mark is near right edge, anchor right.
tooltip.style.left = pct < 90 ? `${pct}%` : `${pct}%`;
if (pct < 8) tooltip.style.transform = 'translateX(0)';
else if (pct > 92) tooltip.style.transform = 'translateX(-100%)';
bar.appendChild(line);
bar.appendChild(tooltip);
}
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);
renderAnnotationBar(item);
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;
// Require initials before any picking action (skip is OK without).
if (mode !== 'skip' && !getAnalyst()) {
showLogin();
return;
}
const item = queue[cursor];
const payload = {
idx: item.idx,
time_s: (mode === 'skip' || mode === 'unusable') ? null : player.currentTime,
mode: mode,
analyst: getAnalyst(),
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 only = onlyRoisFromBad(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${only ? ' (only ROIs ' + only + ')' : ''}`)
);
} 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.
// Reason: register on the *capture* phase so we run before the native
// <video> element's built-in handlers, which otherwise also seek by
// their own amount and double the actual jump.
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Don't react to picker shortcuts while either modal is open.
if (modalBackdrop.classList.contains('show')) return;
if (loginBackdrop.classList.contains('show')) 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.shiftKey ? 30 : 5;
break;
case 'ArrowRight':
stop();
player.currentTime += e.shiftKey ? 30 : 5;
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;
}
}, true); // capture phase — beats the native <video> keyboard handler
// After clicking the seekbar (or anywhere on the video), the player
// keeps keyboard focus and intercepts our arrow-key shortcuts. Blur it
// on mouseup so subsequent arrow presses go to our document-level
// handler unambiguously.
player.addEventListener('mouseup', () => player.blur());
// Welcome modal — show first time, dismissable; ? button reopens it.
const modalBackdrop = document.getElementById('modal-backdrop');
const modalClose = document.getElementById('modal-close');
const helpBtn = document.getElementById('help-btn');
function showModal() { modalBackdrop.classList.add('show'); }
function hideModal() {
modalBackdrop.classList.remove('show');
try { localStorage.setItem('cupido.welcomed', '1'); } catch (e) {}
}
modalClose.addEventListener('click', hideModal);
helpBtn.addEventListener('click', showModal);
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) hideModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
e.stopPropagation(); hideModal();
}
});
// On first visit, login first (mandatory) then welcome modal.
renderUserBadge();
if (!getAnalyst()) {
showLogin();
} else {
let welcomed = false;
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
if (!welcomed) showModal();
}
fetchQueue();
</script>
</body>
</html>