Picker: render existing annotation as a clickable mark below the video

When a video that already has a barrier_opening row is loaded, a thin
bar now appears below the player with a red vertical line at the
marked opening_s. A tooltip shows the timestamp, the analyst's
initials, and the bad_rois flag. Clicking the line snaps the player
to the exact opening; clicking elsewhere on the bar seeks
proportionally. Bar is hidden for un-annotated videos.

API change: each queue item now carries an `existing` field with the
opening_s, bad_rois, analyst, etc. from barrier_opening.csv when
available, so the frontend can paint the mark without extra requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-01 14:27:33 +01:00
parent 2623df4172
commit d0f0e2d443
2 changed files with 83 additions and 5 deletions

View file

@ -107,6 +107,24 @@
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: #181818; border: 1px solid #333;
border-radius: 3px; margin-top: 0.5rem; cursor: pointer; }
#annotation-bar.empty { background: transparent; border-color: transparent;
cursor: default; height: 0.6rem; }
#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>
@ -178,6 +196,7 @@
<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>
@ -268,6 +287,55 @@
progress.textContent = `${done}/${queue.length} done`;
}
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 tag = ex.bad_rois ? ` · bad ROIs ${ex.bad_rois}` : '';
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;
@ -307,6 +375,7 @@
(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();
}