Add a dismissable welcome modal that walks first-time users through the proper annotation sequence (slider to end → check open ROIs → slider to start → arrow-key fine-tune → click). Stays hidden after the first "Got it" via localStorage; the ? button in the header reopens it any time. Picker keyboard shortcuts are inert while the modal is showing. Container exposes 8085 instead of 8000 (8000 was free, but Giorgio's preferred 8082 is already in use on this host; 8085 is the closest free port). Internal port stays 8000 so the FastAPI app is unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
344 lines
14 KiB
HTML
344 lines
14 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; }
|
|
|
|
/* 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Cupido — barrier picker</h1>
|
|
<span id="info">loading…</span>
|
|
<span id="progress"></span>
|
|
<button id="help-btn" title="show the help modal">?</button>
|
|
</header>
|
|
|
|
<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 ·
|
|
<kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> the three buttons ·
|
|
<kbd>s</kbd> skip · <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="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 ±30 s ·
|
|
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');
|
|
|
|
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;
|
|
// Don't react to picker shortcuts while the modal is open.
|
|
if (modalBackdrop.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;
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
// Allow click-on-backdrop to dismiss (but not click-inside-modal)
|
|
modalBackdrop.addEventListener('click', (e) => {
|
|
if (e.target === modalBackdrop) hideModal();
|
|
});
|
|
// Escape closes the modal too.
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modalBackdrop.classList.contains('show')) {
|
|
e.stopPropagation(); hideModal();
|
|
}
|
|
});
|
|
let welcomed = false;
|
|
try { welcomed = localStorage.getItem('cupido.welcomed') === '1'; } catch (e) {}
|
|
if (!welcomed) showModal();
|
|
|
|
fetchQueue();
|
|
</script>
|
|
</body>
|
|
</html>
|