/* Annotation management — inline form, color picker, current-frame highlight */
// All annotation sources use the same cyan visual language. The source is
// still shown by the AI badge; color is no longer used to distinguish source.
const ANNOTATION_COLOR = "#06B6D4";
let annotations = [];
let currentStartFrame = null;
let currentEndFrame = null;
let editingAnnotationId = null;
let _editingBaselineUpdatedAt = null; // updated_at when editing started (conflict check)
let _editingWasCandidate = false; // 兼容历史候选数据
let _annoSocket = null; // per-episode change channel (cross-device sync)
let _annoReconnectTimer = null;
let _annoReloadTimer = null;
// ── Timeline zoom(滚轮缩放 + 拖动平移 + Fit)─────────
// scale 语义 = 可见帧数 = 总帧数 ÷ scale;1× = 全片(强制 [0,total]),
// <1× = 全片压缩留边,>1× 以 centerFrame 为视窗中心;状态按批次保持。
let timelineZoom = { scale: 1.0, centerFrame: 0 };
let _panDragging = false; // 拖拽平移中:暂停"播放跟随",避免视口被拉回
// 固定档位:最小 1×,最大 10×,线性每档 +0.5;滚轮/按钮都只在档位间切换
const ZOOM_STEPS = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5,
5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
function _zoomTotal() {
return Math.max(1, episodeTotalFrames || 1);
}
function _zoomVisibleRange() {
const total = _zoomTotal();
if (timelineZoom.scale <= 1) {
// 1× 及以下:必须完整覆盖 [0, total],否则后半段坐标超 100%
// 跑到轨道外(修复"1 倍看不到最后的切片")
return { start: 0, end: total };
}
const half = total / (2 * timelineZoom.scale);
return { start: timelineZoom.centerFrame - half, end: timelineZoom.centerFrame + half };
}
function _zoomFramePct(frame) {
const r = _zoomVisibleRange();
const span = Math.max(1, r.end - r.start);
return Math.min(100, Math.max(0, ((frame - r.start) / span) * 100));
}
function _clampZoomCenter(scale, centerFrame) {
const total = _zoomTotal();
const half = total / (2 * scale);
if (half >= total / 2) return total / 2; // scale≤1:中心固定全片中心
return Math.max(half, Math.min(total - half, centerFrame || total / 2));
}
function _snapScale(scale) {
// 就近吸附到档位;低于 1 一律回 1×(不允许小于 1 倍)
if (scale <= 1) return 1;
for (const st of ZOOM_STEPS) {
if (scale <= st * 1.001) return st;
}
return ZOOM_STEPS[ZOOM_STEPS.length - 1];
}
function _setZoom(scale, centerFrame) {
const total = _zoomTotal();
timelineZoom.scale = _snapScale(scale);
timelineZoom.centerFrame = _clampZoomCenter(timelineZoom.scale, centerFrame);
_updateZoomUI();
renderAnnotationTimeline(annotations, total);
// 只定位游标,不触发播放跟随(否则放大视口会被拉回蓝线)
_positionCursor(typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0);
}
function _updateZoomUI() {
const ind = document.getElementById('zoom-indicator');
if (ind) ind.textContent = `${timelineZoom.scale.toFixed(1)}×`;
// 到边界时禁用对应按钮(最小 1×,最大 10×),并给视觉反馈
const btnIn = document.getElementById('btn-zoom-in');
const btnOut = document.getElementById('btn-zoom-out');
if (btnIn) {
btnIn.disabled = timelineZoom.scale >= ZOOM_STEPS[ZOOM_STEPS.length - 1];
btnIn.style.opacity = btnIn.disabled ? '0.4' : '1';
}
if (btnOut) {
btnOut.disabled = timelineZoom.scale <= 1;
btnOut.style.opacity = btnOut.disabled ? '0.4' : '1';
}
}
// ── Load ────────────────────────────────────────────
async function loadAnnotations(episodeId) {
const requestEpoch = episodeId; // capture at call time — discard if episode changed
// Clear the old episode immediately while the new request is in flight.
// This prevents Slice Preview and the annotation overlay from showing
// stale data during an episode switch.
annotations = [];
if (typeof resetSlicePreview === 'function') resetSlicePreview(episodeId);
renderAll();
try {
const signal = typeof getMediaLoadSignal === 'function'
? getMediaLoadSignal() : null;
const res = await fetch(`/api/v1/episode/${episodeId}/annotations`,
signal ? { signal } : {});
if (requestEpoch !== currentEpisodeId) return; // stale request — discard
if (!res.ok) { renderAll(); return; }
const data = await res.json();
if (requestEpoch !== currentEpisodeId) return; // stale response — discard
annotations = data.annotations || [];
renderAll();
checkAiAnnotationEnabled(episodeId);
connectAnnotationSocket(episodeId);
if (typeof refreshSlicePreview === 'function') refreshSlicePreview();
} catch (err) {
if (err?.name === 'AbortError') return;
if (requestEpoch !== currentEpisodeId) return; // stale — discard
annotations = [];
renderAll();
}
}
// 切换批次时立即清空标注 UI(时间轴/切片轨道/进度条 overlay/编辑态)。
// 旧批次的切片帧坐标不能残留在新批次视频上 —— 否则点击切片会按
// 旧帧号 seek,直接造成"帧不对齐"。loadAnnotations 里也有同样的清空,
// 但它要等 frames-data 返回后才执行,窗口太晚。
function clearAnnotationsNow() {
annotations = [];
currentStartFrame = null;
currentEndFrame = null;
editingAnnotationId = null;
resetAnnotationTimelineForEpisode(
typeof episodeTotalFrames !== 'undefined' ? episodeTotalFrames : 0,
);
if (typeof resetSlicePreview === 'function') {
resetSlicePreview(typeof currentEpisodeId !== 'undefined' ? currentEpisodeId : '');
}
renderAll();
}
// A new episode must not inherit the previous episode's timeline viewport or
// cursor position. The frame counter is reset by player.js, but the green
// cursor is DOM state and otherwise remains at the old percentage until the
// first successful frame callback.
function resetAnnotationTimelineForEpisode(totalFrames = 0) {
const total = Math.max(0, Math.floor(Number(totalFrames) || 0));
timelineZoom.scale = 1.0;
timelineZoom.centerFrame = total > 0 ? total / 2 : 0;
_panDragging = false;
const cursor = document.getElementById('annotation-timeline-cursor');
if (cursor) cursor.style.left = '0%';
const frameLabel = document.getElementById('annotation-timeline-frame');
if (frameLabel) frameLabel.textContent = total > 0 ? `0 / ${total}` : '0 / 0';
_updateZoomUI();
}
// ── Cross-device sync ────────────────────────────────
// The backend broadcasts "annotations changed" over a per-episode WebSocket.
// We only reload the annotation UI (cards / timeline / overlay) — never the
// video position. REST remains the only write path; the socket is
// notification-only, so a dropped connection loses no data.
function connectAnnotationSocket(episodeId) {
if (!window.WebSocket) return;
if (_annoSocket && _annoSocket.episodeId === episodeId && _annoSocket.readyState <= WebSocket.OPEN) return;
if (_annoSocket) {
try { _annoSocket.close(); } catch (_) { /* no-op */ }
_annoSocket = null;
}
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${proto}://${location.host}/api/v1/annotations/ws?episode=${encodeURIComponent(episodeId)}`);
socket.episodeId = episodeId;
socket.onmessage = event => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'annotations_changed' && msg.episode === currentEpisodeId) {
// Reload unless we are mid-edit of this slice: the save itself
// carries a version check and reloads on conflict anyway.
if (editingAnnotationId !== msg.annotation_id) {
scheduleAnnotationReload(currentEpisodeId);
}
}
} catch (_) { /* no-op */ }
};
socket.onopen = () => {
try { socket.send(JSON.stringify({ type: 'ping' })); } catch (_) { /* no-op */ }
};
socket.onclose = () => {
if (_annoSocket !== socket) return;
_annoSocket = null;
if (currentEpisodeId === episodeId && window.EGODATA_PAGE_MODE === 'annotation') {
clearTimeout(_annoReconnectTimer);
_annoReconnectTimer = setTimeout(() => {
if (currentEpisodeId === episodeId) connectAnnotationSocket(episodeId);
}, 1500);
}
};
socket.onerror = () => { try { socket.close(); } catch (_) { /* no-op */ } };
_annoSocket = socket;
}
function disconnectAnnotationSocket() {
clearTimeout(_annoReconnectTimer);
clearTimeout(_annoReloadTimer);
_annoReconnectTimer = null;
_annoReloadTimer = null;
const socket = _annoSocket;
_annoSocket = null;
if (socket) {
try { socket.close(); } catch (_) { /* no-op */ }
}
}
window.disconnectAnnotationSocket = disconnectAnnotationSocket;
function scheduleAnnotationReload(episodeId) {
clearTimeout(_annoReloadTimer);
_annoReloadTimer = setTimeout(() => {
if (episodeId === currentEpisodeId) loadAnnotations(episodeId);
}, 120);
}
function sortedAnnotations() {
return [...annotations].sort((a, b) => {
const startDiff = (Number(a.start_frame_index) || 0) - (Number(b.start_frame_index) || 0);
if (startDiff) return startDiff;
const endDiff = (Number(a.end_frame_index) || 0) - (Number(b.end_frame_index) || 0);
if (endDiff) return endDiff;
return String(a.created_at || '').localeCompare(String(b.created_at || ''));
});
}
function annotationColor(seg, index) {
return ANNOTATION_COLOR;
}
function isAnnotationEditor() {
return window.EGODATA_PAGE_MODE === 'annotation';
}
function supportsAnnotationDisplay() {
return isAnnotationEditor() || window.EGODATA_PAGE_MODE === 'review';
}
// ── Render ──────────────────────────────────────────
function renderAll() {
renderAnnotationList();
renderAnnotationTimeline(annotations, episodeTotalFrames || 0);
renderAnnotationOverlay(annotations, episodeTotalFrames || 0);
}
function showAnnotationUI() {
const timeline = document.getElementById('annotation-timeline');
if (!timeline) return;
const ready = supportsAnnotationDisplay()
&& typeof episodeTotalFrames !== 'undefined'
&& episodeTotalFrames > 0;
timeline.classList.toggle('hidden', !ready);
}
// ── Color picker ────────────────────────────────────
function buildColorPicker() {
// Colors are assigned automatically by the API.
}
function selectColor(color, el) {
// Kept as a no-op for compatibility with cached markup.
}
// ── Annotation list ─────────────────────────────────
function renderAnnotationList(targetId) {
targetId = targetId || 'episode-detail-annos';
const listEl = document.getElementById(targetId);
if (!listEl) return;
const countEl = document.getElementById('anno-count');
if (countEl) countEl.textContent = annotations.length;
const editor = isAnnotationEditor();
if (annotations.length === 0) {
listEl.innerHTML = `
${t('no_slices')}
${editor ? `
${t('no_slices_hint')}
` : `
${t('annotation_readonly')}
`}
`;
return;
}
const currentFrame = typeof getCurrentFrame === 'function' ? getCurrentFrame() : -1;
// 单一高亮:当前帧只属于一个段(段重叠时取开始帧最近的那段)
const currentAnnoId = _singleCurrentAnnoId(currentFrame, null);
listEl.innerHTML = sortedAnnotations().map((seg, i) => {
const frames = seg.end_frame_index - seg.start_frame_index + 1;
const duration = (frames / (episodeFps || 30)).toFixed(2);
const isCurrent = seg.id === currentAnnoId;
const color = annotationColor(seg, i);
const kfHTML = (seg.keyframes || []).map(kf =>
'' +
' frame ' + kf.frame_index + ': ' + escHtml(kf.event || '') +
'
'
).join('');
const isEditing = editor && seg.id === editingAnnotationId;
const isCandidate = (seg.status || 'confirmed') === 'candidate';
const isAi = seg.source === 'ai';
return `
${i + 1}
${isAi ? '' : ''}
${escHtml(seg.label || t('unnamed_slice'))}
${isAi ? 'AI' : ''}
${seg.start_frame_index} - ${seg.end_frame_index}
${frames} frames · ${duration}s
${seg.start_frame_index} – ${seg.end_frame_index}
(${frames}f · ${duration}s)
${kfHTML}
${editor ? `
` : ''}
`;
}).join('');
// Normalize visible metadata after rendering so legacy stored labels or
// old template separators cannot leak into the current UI.
const ordered = sortedAnnotations();
listEl.querySelectorAll('.annotation-card-meta').forEach((meta, index) => {
const seg = ordered[index];
if (!seg) return;
const frames = Number(seg.end_frame_index) - Number(seg.start_frame_index) + 1;
const duration = (frames / (episodeFps || 30)).toFixed(2);
meta.innerHTML = `${seg.start_frame_index} - ${seg.end_frame_index}${frames} frames · ${duration}s`;
});
updateAiControls();
}
// ═══════════════════════════════════════════════════════════
// AI 辅助标注(多视角同步输入,成功后直接写入 confirmed)
// ═══════════════════════════════════════════════════════════
let _aiEnabled = false;
let _aiConfig = {};
let _aiBusy = false;
function updateAiControls() {
const btn = document.getElementById('btn-ai-annotate');
if (btn) {
btn.classList.toggle('hidden', !_aiEnabled);
btn.disabled = _aiBusy;
}
}
function renderAiAnnotationStatus(data) {
const el = document.getElementById('episode-detail-ai-status');
if (!el) return;
// API/provider progress is available from the workflow/task panel when
// needed, but it should not occupy the episode detail header. The review
// page only needs the annotation markers and the human Approve action.
el.className = 'hidden';
el.textContent = '';
}
async function checkAiAnnotationEnabled(episodeId) {
try {
const signal = typeof getMediaLoadSignal === 'function'
? getMediaLoadSignal() : null;
const res = await fetch(`/api/v1/episode/${episodeId}/ai-annotation-enabled`,
signal ? { signal } : {});
if (episodeId !== currentEpisodeId) return;
if (!res.ok) { _aiEnabled = false; return; }
const data = await res.json();
if (episodeId !== currentEpisodeId) return;
_aiEnabled = !!data.enabled;
_aiConfig = data.config || {};
renderAiAnnotationStatus(data);
} catch (_) {
if (episodeId !== currentEpisodeId) return;
_aiEnabled = false;
renderAiAnnotationStatus(null);
}
updateAiControls();
}
// 卡片配置了多套 API 时,运行前弹窗选一套;单套/未配置直接放行。
// 返回:undefined = 用户取消;null = 不覆盖(走卡片默认);对象 = 用该套覆盖。
function _pickAiProvider() {
const providers = Array.isArray(_aiConfig.api_providers) ? _aiConfig.api_providers : [];
if (providers.length <= 1) return Promise.resolve(providers[0] || null);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 flex items-center justify-center';
overlay.style.cssText = 'position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);';
const box = document.createElement('div');
box.className = 'bg-gray-900 border border-gray-700 rounded-lg shadow-2xl';
box.style.width = '360px';
box.innerHTML = `
Select AI API
`;
const list = box.querySelector('div.p-3');
const done = (provider) => { overlay.remove(); resolve(provider); };
box.querySelector('button').onclick = () => done(undefined);
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(undefined); });
providers.forEach((p) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'w-full text-left px-3 py-2 rounded border border-gray-800 bg-gray-950/60 hover:border-blue-500 hover:bg-gray-800 text-sm text-gray-200';
row.innerHTML = `${escHtml(p.vendor || '')} · ${escHtml(p.model || '')}
${escHtml(p.base_url || 'default endpoint')}
`;
row.onclick = () => done(p);
list.appendChild(row);
});
overlay.appendChild(box);
document.body.appendChild(overlay);
});
}
async function aiAnnotate() {
if (!isAnnotationEditor() || !currentEpisodeId || _aiBusy) return;
const provider = await _pickAiProvider();
if (provider === undefined) return; // 用户取消
const btn = document.getElementById('btn-ai-annotate');
const mode = _aiConfig.mode || 'signal_vlm';
_aiBusy = true;
updateAiControls();
const setLabel = (html) => { if (btn) btn.innerHTML = html; };
setLabel(' AI…');
let failed = false;
try {
const res = await fetch(`/api/v1/episode/${currentEpisodeId}/ai-annotate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, min_confidence: _aiConfig.min_confidence || 0,
lang: _aiConfig.prompt_language || 'zh',
debounce_sec: _aiConfig.debounce_sec || 2.0,
min_seg_sec: _aiConfig.min_seg_sec || 0.8,
max_segments: _aiConfig.max_segments || 0,
// 本次运行选定的 API 整套覆盖卡片默认(单套时不传)
...(provider ? { api_vendor: provider.vendor,
api_model: provider.model,
api_key: provider.key,
api_base_url: provider.base_url } : {}) }),
});
if (!res.ok) throw new Error('trigger failed');
// 轮询任务状态(完成经 WS 广播刷新标注,轮询只驱动按钮文案)
for (let i = 0; i < 240; i++) {
await new Promise(r => setTimeout(r, 1000));
let st = { status: 'running' };
try {
st = await (await fetch(`/api/v1/episode/${currentEpisodeId}/ai-annotate/status`)).json();
} catch (_) { /* keep polling */ }
if (st.status === 'signal_segmenting') setLabel(' ' + t('ai_status_segmenting') + '');
else if (st.status === 'vlm_analyzing') setLabel(' ' + t('ai_status_analyzing') + '');
else if (st.status === 'writing') setLabel(' ' + t('ai_status_writing') + '');
else if (st.status === 'exporting') setLabel(' ' + t('ai_status_exporting') + '');
if (st.status === 'done') break;
if (st.status === 'failed') {
console.warn('AI annotation failed:', st.detail);
failed = true;
const d = String(st.detail || '').replace(/[<>]/g, '').slice(0, 100);
setLabel(' ' + (d || 'AI annotation failed') + '');
break;
}
if (st.status === 'interrupted') { console.warn('AI annotation interrupted:', st.detail); break; }
}
} catch (err) {
console.error('AI annotation error:', err);
}
if (!failed) {
setLabel(' AI Annotate');
}
_aiBusy = false;
updateAiControls();
await loadAnnotations(currentEpisodeId);
}
async function aiConfirmSegment(annoId, silent) {
if (!isAnnotationEditor()) return;
try {
const seg = annotations.find(a => a.id === annoId);
if (seg) _pushUndo('update', seg);
await fetch(`/api/v1/annotation/${annoId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'confirmed', updated_at: seg ? seg.updated_at : undefined }),
});
if (!silent) await loadAnnotations(currentEpisodeId);
} catch (err) {
console.error('confirm failed:', err);
}
}
async function aiDismissSegment(annoId, silent) {
if (!isAnnotationEditor()) return;
// 兼容历史候选段删除;新 AI 结果不会进入 candidate 状态。
try {
const seg = annotations.find(a => a.id === annoId);
if (seg) _pushUndo('delete', seg);
await fetch(`/api/v1/annotation/${annoId}`, { method: 'DELETE' });
if (!silent) await loadAnnotations(currentEpisodeId);
} catch (err) {
console.error('dismiss failed:', err);
}
}
function onAnnotationClick(annoId) {
const seg = annotations.find(a => a.id === annoId);
if (!seg || typeof seekToFrame !== 'function') return;
seekToFrame(seg.start_frame_index);
// 同步更新卡片高亮:preferId = 被点的段,只高亮这一张(其余熄灭),
// 不依赖播放器 seeked 事件往返,也不会因段重叠出现双高亮。
updateCurrentAnnotation(seg.start_frame_index, seg.id);
}
function legacyRenderAnnotationTimeline(items, totalFrames) {
if (!supportsAnnotationDisplay()) return;
const timeline = document.getElementById('annotation-timeline');
const track = document.getElementById('annotation-timeline-track');
const markerTrack = document.getElementById('annotation-timeline-markers');
if (!timeline || !track || !markerTrack) return;
markerTrack.querySelectorAll('.annotation-timeline-segment').forEach(el => el.remove());
if (!totalFrames) {
timeline.classList.add('hidden');
return;
}
timeline.classList.remove('hidden');
bindFrameTimelineSeek(track, totalFrames);
// 切片轨道同样支持放大后按住左右平移
_attachPanDrag(markerTrack);
// Use the same 0..last-frame coordinate system as the playback cursor.
const denominator = Math.max(1, totalFrames - 1);
// 多行排段:重叠的段错行显示(最多 3 行;更多时压进最后一行)。
// 行高 18px + 上下留白,轨道高度按行数动态伸缩(52-92px)。
const LANE_H = 18, LANE_PAD = 6, MAX_LANES = 3;
const laneEnds = []; // 每行最后一个段的 end_frame
const laneItems = sortedAnnotations().map((seg, i) => {
const start = Math.max(0, Number(seg.start_frame_index) || 0);
const end = Math.max(start, Number(seg.end_frame_index) || start);
let lane = laneEnds.findIndex(lastEnd => lastEnd <= start);
if (lane === -1) {
if (laneEnds.length < MAX_LANES) {
lane = laneEnds.length;
laneEnds.push(-1);
} else {
lane = MAX_LANES - 1; // 封顶:压缩进最后一行
}
}
laneEnds[lane] = Math.max(laneEnds[lane], end);
return { seg, i, start, end, lane };
});
markerTrack.style.height = `${LANE_PAD * 2 + Math.max(1, laneEnds.length) * LANE_H}px`;
// 播放工具条紧贴切片轨道上方(随轨道高度自动上移)
_positionPlaybackToolbar(markerTrack.offsetHeight);
laneItems.forEach(({ seg, i, start, end, lane }) => {
const segment = document.createElement('button');
const leftPercent = Math.min(100, (start / denominator) * 100);
const rightPercent = Math.min(100, (end / denominator) * 100);
const width = Math.max(0.7, rightPercent - leftPercent);
segment.type = 'button';
segment.className = 'annotation-timeline-segment' +
((seg.status || 'confirmed') === 'candidate' ? ' candidate' : '');
segment.dataset.annoId = seg.id;
segment.dataset.startFrame = start;
segment.dataset.endFrame = end;
// 边界自检未过 / 标签未命中词表 → 时间轴段黄色边框
if (seg.boundary_ok === false || seg.label_matched === false) {
segment.style.borderColor = '#f59e0b';
}
segment.style.left = `${leftPercent}%`;
segment.style.width = `${Math.min(width, 100 - leftPercent)}%`;
// 行定位(覆盖默认 top:2px/bottom:2px)
segment.style.top = `${LANE_PAD + lane * LANE_H}px`;
segment.style.bottom = 'auto';
segment.style.height = `${LANE_H - 2}px`;
segment.style.lineHeight = `${LANE_H - 2}px`;
segment.style.fontSize = '10px';
segment.style.padding = '0 6px';
segment.style.background = annotationColor(seg, i);
// 段过窄时不显示文字(悬停 tooltip 仍有完整信息)
const label = `${i + 1} · ${seg.label || t('unnamed_slice')}`;
segment.textContent = width >= 2 ? label : '';
segment.title = `${seg.label || t('unnamed_slice')} · frames ${start}-${end}`;
segment.setAttribute('aria-label', segment.title);
segment.addEventListener('click', event => {
event.stopPropagation();
onAnnotationClick(seg.id);
});
markerTrack.appendChild(segment);
});
updateAnnotationTimelineCursor(
typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0,
);
}
// Enhanced marker interaction: move a marker or resize either edge, then
// persist the new frame range through the existing annotation API.
function _clampTimelineFrame(frame, min, max) {
return Math.max(min, Math.min(max, Math.round(Number(frame) || 0)));
}
function _setTimelineSegmentRange(segment, start, end, totalFrames) {
const lastFrame = Math.max(0, totalFrames - 1);
const safeStart = _clampTimelineFrame(start, 0, lastFrame);
const safeEnd = _clampTimelineFrame(Math.max(safeStart, end), safeStart, lastFrame);
const denominator = Math.max(1, lastFrame);
const leftPercent = Math.min(100, (safeStart / denominator) * 100);
const rightPercent = Math.min(100, (safeEnd / denominator) * 100);
segment.style.left = `${leftPercent}%`;
segment.style.width = `${Math.min(Math.max(0.7, rightPercent - leftPercent), 100 - leftPercent)}%`;
segment.dataset.startFrame = safeStart;
segment.dataset.endFrame = safeEnd;
return { start: safeStart, end: safeEnd };
}
function _paintTimelineSegment(segment, index, seg, start, end) {
// 只读显示:无拖拽/拉伸把手(边界修改走编辑表单)
const label = seg.label || t('unnamed_slice');
segment.innerHTML = `
${index + 1} · ${escHtml(label)}`;
segment.title = `${label} · frames ${start}-${end}`;
segment.setAttribute('aria-label', segment.title);
}
// This definition intentionally follows the original renderer so the new
// interaction is used without changing the existing annotation data flow.
/* 播放工具条贴底:紧贴切片轨道/进度条上方,随轨道高度自动上移 */
function _positionPlaybackToolbar(markersHeight) {
const tb = document.getElementById('playback-toolbar');
if (!tb) return;
const controls = document.getElementById('frame-controls');
const controlsH = (controls && !controls.classList.contains('hidden'))
? controls.offsetHeight : 0;
const mH = markersHeight || 0;
tb.style.bottom = `${mH + controlsH + 10}px`;
}
function renderAnnotationTimeline(items, totalFrames) {
if (!supportsAnnotationDisplay()) return;
const timeline = document.getElementById('annotation-timeline');
const track = document.getElementById('annotation-timeline-track');
const markerTrack = document.getElementById('annotation-timeline-markers');
if (!timeline || !track || !markerTrack) return;
markerTrack.querySelectorAll('.annotation-timeline-segment').forEach(el => el.remove());
if (!totalFrames) {
timeline.classList.add('hidden');
_positionPlaybackToolbar(null);
return;
}
timeline.classList.remove('hidden');
bindFrameTimelineSeek(track, totalFrames);
// 多行排段:重叠的段错行显示(最多 3 行,更多压进最后一行);
// 轨道高度随行数动态伸缩(52-92px)
const LANE_H = 18, LANE_PAD = 6, MAX_LANES = 3;
const laneEnds = [];
const laneItems = sortedAnnotations().map((seg, index) => {
const start = Math.max(0, Number(seg.start_frame_index) || 0);
const end = Math.max(start, Number(seg.end_frame_index) || start);
let lane = laneEnds.findIndex(lastEnd => lastEnd <= start);
if (lane === -1) {
if (laneEnds.length < MAX_LANES) {
lane = laneEnds.length;
laneEnds.push(-1);
} else {
lane = MAX_LANES - 1;
}
}
laneEnds[lane] = Math.max(laneEnds[lane], end);
return { seg, index, start, end, lane };
});
markerTrack.style.height = `${LANE_PAD * 2 + Math.max(1, laneEnds.length) * LANE_H}px`;
_positionPlaybackToolbar(markerTrack.offsetHeight);
laneItems.forEach(({ seg, index, start, end, lane }) => {
const segment = document.createElement('button');
segment.type = 'button';
segment.className = 'annotation-timeline-segment' +
((seg.status || 'confirmed') === 'candidate' ? ' candidate' : '');
segment.dataset.annoId = seg.id;
segment.style.background = annotationColor(seg, index);
// 边界自检未过 / 标签未命中词表 → 黄色边框
if (seg.boundary_ok === false || seg.label_matched === false) {
segment.style.borderColor = '#f59e0b';
}
_setTimelineSegmentRange(segment, start, end, totalFrames);
// 行定位(覆盖默认 top:2px/bottom:2px)
segment.style.top = `${LANE_PAD + lane * LANE_H}px`;
segment.style.bottom = 'auto';
segment.style.height = `${LANE_H - 2}px`;
segment.style.fontSize = '10px';
segment.style.padding = '0 6px';
// 缩放变换:段在视窗内的位置;完全在视窗外 → 隐藏
const vrange = _zoomVisibleRange();
const vspan = Math.max(1, vrange.end - vrange.start);
if (end < vrange.start || start > vrange.end) {
segment.style.display = 'none';
} else {
const vStart = Math.max(start, vrange.start);
const vEnd = Math.min(end, vrange.end);
segment.style.left = `${((vStart - vrange.start) / vspan) * 100}%`;
segment.style.width = `${Math.max(0.5, ((vEnd - vStart) / vspan) * 100)}%`;
}
_paintTimelineSegment(segment, index, seg, start, end);
// 段过窄 → 隐藏标签(悬停 tooltip 仍有完整信息)
if (parseFloat(segment.style.width) < 2) {
const lbl = segment.querySelector('.annotation-timeline-label');
if (lbl) lbl.style.display = 'none';
}
segment.addEventListener('click', event => {
event.stopPropagation();
onAnnotationClick(seg.id);
});
markerTrack.appendChild(segment);
});
_positionCursor(typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0);
}
// The playback row is shared by Review and Annotation Studio. Binding it
// from player readiness keeps click/drag seeking available on both pages,
// even when the annotation marker row is hidden.
/* 放大后按住拖动 = 左右平移视窗(1× 时无可平移空间,拖动不生效)。
拖动结束会置 dragJustHappened 标记,click 处理器据此跳过 seek,
区分"拖动"与"点击"。 */
function _attachPanDrag(el) {
if (!el || el.dataset.panBound === 'true') return;
el.dataset.panBound = 'true';
let pan = null;
el.addEventListener('pointerdown', event => {
if (event.button !== 0 || timelineZoom.scale <= 1) return;
// 按在切片段上 → 不启动平移(段只保留点击跳帧,平移走空白处)
if (event.target.closest && event.target.closest('.annotation-timeline-segment')) return;
_panDragging = true; // 拖拽期间暂停播放跟随
const rect = el.getBoundingClientRect();
const r0 = _zoomVisibleRange();
pan = {
pointerId: event.pointerId,
startX: event.clientX,
startCenter: timelineZoom.centerFrame,
span: Math.max(1, r0.end - r0.start),
rect,
moved: false,
};
el.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
el.addEventListener('pointermove', event => {
if (!pan || event.pointerId !== pan.pointerId) return;
event.preventDefault();
const dx = (event.clientX - pan.startX) / Math.max(1, pan.rect.width);
if (Math.abs(event.clientX - pan.startX) > 5) pan.moved = true;
if (!pan.moved) return;
// 中心钳制在 [half, total-half]:视口永不越出视频左右边界
timelineZoom.centerFrame = _clampZoomCenter(
timelineZoom.scale, pan.startCenter - dx * pan.span);
renderAnnotationTimeline(annotations, episodeTotalFrames || 0);
_positionCursor(typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0);
});
const finish = event => {
if (!pan || event.pointerId !== pan.pointerId) return;
const wasDrag = pan.moved;
pan = null;
_panDragging = false; // 恢复播放跟随
// 拖动后紧随的 click 不再当"点击跳帧"
if (wasDrag) {
el.dataset.dragJustHappened = 'true';
setTimeout(() => { delete el.dataset.dragJustHappened; }, 0);
}
};
el.addEventListener('pointerup', finish);
el.addEventListener('pointercancel', finish);
el.addEventListener('lostpointercapture', finish);
}
function bindFrameTimelineSeek(track, totalFrames) {
if (!track || track.dataset.seekBound) return;
const frameFromPointer = event => {
const rect = track.getBoundingClientRect();
if (!rect.width) return 0;
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
// 缩放感知:按当前视窗换算帧号
const r = _zoomVisibleRange();
return Math.round(r.start + ratio * (r.end - r.start));
};
// 放大后按住拖动 = 平移视窗
_attachPanDrag(track);
// 点击 = 跳帧(拖动过则不触发)
track.addEventListener('click', event => {
if (event.button !== 0) return;
if (track.dataset.dragJustHappened) return;
const frame = frameFromPointer(event);
if (typeof seekToFrame === 'function') seekToFrame(frame);
});
// 双击轨道 → 回到 Fit 全片
track.addEventListener('dblclick', () => {
_setZoom(1, _zoomTotal() / 2);
});
track.dataset.seekBound = 'true';
}
/* 滚轮缩放:悬停在切片轨道/进度条上滚动,以鼠标位置为中心缩放 */
function _bindTimelineZoom() {
const markerTrack = document.getElementById('annotation-timeline-markers');
const track = document.getElementById('annotation-timeline-track');
[markerTrack, track].forEach(el => {
if (!el || el.dataset.zoomBound === 'true') return;
el.dataset.zoomBound = 'true';
let wheelRaf = null, wheelSteps = 0, wheelAnchor = null;
el.addEventListener('wheel', event => {
event.preventDefault();
// 缩放锚定 = 鼠标位置(鼠标下的帧保持不动)——剪辑软件标准做法
const rect = el.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1,
(event.clientX - rect.left) / Math.max(1, rect.width)));
const r = _zoomVisibleRange();
wheelAnchor = r.start + ratio * (r.end - r.start);
wheelSteps += event.deltaY < 0 ? 1 : -1;
// rAF 合并:一帧内的多次滚动只渲染一次(标注多时全量重渲染
// 开销大,快速滚动逐格渲染会卡死)
if (wheelRaf) return;
wheelRaf = requestAnimationFrame(() => {
wheelRaf = null;
const steps = wheelSteps;
wheelSteps = 0;
if (!steps) return;
const idx = ZOOM_STEPS.indexOf(timelineZoom.scale);
const cur = idx < 0 ? 0 : idx;
const nextIdx = Math.max(0,
Math.min(ZOOM_STEPS.length - 1, cur + steps));
_setZoom(ZOOM_STEPS[nextIdx], wheelAnchor);
});
}, { passive: false });
});
}
function _positionCursor(frameIndex) {
/* 纯游标定位(无播放跟随)。缩放/渲染/平移内部只走这里,
避免"跟随"把用户缩放/平移的视口拉回蓝线。返回归一化帧号。 */
const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation';
const timeline = document.getElementById('annotation-timeline');
const frameControls = document.getElementById('frame-controls');
const track = document.getElementById('annotation-timeline-track');
const cursor = document.getElementById('annotation-timeline-cursor');
if (!track || !cursor) return null;
if (isAnnotationPage && (!timeline || timeline.classList.contains('hidden'))) return null;
if (!isAnnotationPage && (!frameControls || frameControls.classList.contains('hidden'))) return null;
const total = Math.max(1, episodeTotalFrames || 1);
const frame = Math.max(0, Math.min(total - 1, Number(frameIndex) || 0));
cursor.style.left = `${_zoomFramePct(frame)}%`;
const frameLabel = document.getElementById('annotation-timeline-frame');
// Keep the UI on the source's zero-based frame index, matching the
// parquet/video frame_index and the detail panel.
if (frameLabel) frameLabel.textContent = `${frame} / ${total}`;
return frame;
}
function updateAnnotationTimelineCursor(frameIndex) {
const frame = _positionCursor(frameIndex);
if (frame == null) return;
const isAnnotationPage = window.EGODATA_PAGE_MODE === 'annotation';
const markerTrack = document.getElementById('annotation-timeline-markers');
const total = Math.max(1, episodeTotalFrames || 1);
// 播放跟随(仅帧变化路径):放大时当前帧超出可见范围 → 视窗自动
// 重定位;手动拖拽平移期间暂停 —— 否则视口会被拉回(回弹)。
if (timelineZoom.scale > 1 && !_panDragging) {
const vr = _zoomVisibleRange();
if (frame < vr.start || frame > vr.end) {
timelineZoom.centerFrame = _clampZoomCenter(timelineZoom.scale, frame);
renderAnnotationTimeline(annotations, total);
_positionCursor(frame);
}
}
if (supportsAnnotationDisplay()) {
// 时间轴段与卡片同一套单一高亮规则(段重叠时不双亮)
const currentAnnoId = _singleCurrentAnnoId(frame, null);
markerTrack?.querySelectorAll('.annotation-timeline-segment').forEach(segment => {
segment.classList.toggle('is-current', segment.dataset.annoId === currentAnnoId);
});
}
}
// ── Inline form ─────────────────────────────────────
function showNewAnnotationForm(resetFrames = true) {
if (!isAnnotationEditor()) return;
editingAnnotationId = null;
_editingBaselineUpdatedAt = null;
if (resetFrames) {
currentStartFrame = null;
currentEndFrame = null;
}
const form = document.getElementById('anno-form');
if (!form) return;
const titleEl = document.getElementById('anno-form-title');
if (form) form.classList.remove('hidden');
if (titleEl) titleEl.textContent = t('new_annotation');
if (resetFrames || !document.getElementById('anno-label-input').value) {
document.getElementById('anno-label-input').value = '';
}
syncAnnotationFrameInputs();
updateFormState();
document.getElementById('anno-label-input').focus();
}
function hideAnnotationForm() {
if (window.EGODATA_PAGE_MODE !== 'annotation') return;
const form = document.getElementById('anno-form');
// Annotation Studio keeps the editor mounted. Reset it for the next
// annotation instead of hiding the whole module.
if (form) form.classList.remove('hidden');
editingAnnotationId = null;
_editingBaselineUpdatedAt = null;
_editingWasCandidate = false;
currentStartFrame = null;
currentEndFrame = null;
syncAnnotationFrameInputs();
const titleEl = document.getElementById('anno-form-title');
if (titleEl) titleEl.textContent = t('new_annotation');
const labelInput = document.getElementById('anno-label-input');
if (labelInput) labelInput.value = '';
const saveBtn = document.getElementById('btn-save-annotation');
if (saveBtn) {
const labelSpan = saveBtn.querySelector('[data-i18n]');
if (labelSpan) labelSpan.textContent = t('add_slice');
}
updateFormState();
renderAnnotationList();
// Scroll anno list to top
const listEl = document.getElementById('episode-detail-annos');
if (listEl) listEl.scrollTop = 0;
}
function updateFormState() {
const saveBtn = document.getElementById('btn-save-annotation');
const preview = document.getElementById('anno-frame-preview');
syncAnnotationFrameInputs();
if (preview) {
if (currentStartFrame != null && currentEndFrame != null) {
const frames = currentEndFrame - currentStartFrame + 1;
const duration = (frames / (episodeFps || 30)).toFixed(2);
preview.innerHTML =
'Range: ' +
'' + currentStartFrame + '' +
' → ' +
'' + currentEndFrame + '' +
'(' + frames + 'f · ' + duration + 's)';
preview.className = 'bg-gray-900 rounded-lg px-3 py-2 text-center';
} else if (currentStartFrame != null) {
preview.innerHTML =
'Start: ' +
'' + currentStartFrame + '' +
' — waiting for End';
preview.className = 'bg-gray-900 rounded-lg px-3 py-2 text-center';
} else {
preview.innerHTML =
'Set Start / Set End using the fields or buttons above';
preview.className = 'bg-gray-900 rounded-lg px-3 py-2 text-center';
}
}
if (saveBtn) {
const canSave = currentStartFrame != null && currentEndFrame != null && currentEndFrame >= currentStartFrame;
saveBtn.disabled = !canSave;
saveBtn.className = canSave
? 'w-full bg-blue-600 hover:bg-blue-500 text-white py-2 rounded-lg text-xs font-medium transition-colors'
: 'w-full bg-gray-700 text-gray-500 py-2 rounded-lg text-xs cursor-not-allowed font-medium';
}
}
// ── Frame capture ────────────────────────────────────
function syncAnnotationFrameInputs() {
const startInput = document.getElementById('anno-start-frame-input');
const endInput = document.getElementById('anno-end-frame-input');
if (startInput) startInput.value = currentStartFrame == null ? '' : currentStartFrame;
if (endInput) endInput.value = currentEndFrame == null ? '' : currentEndFrame;
}
function readAnnotationFrameInputs() {
const startInput = document.getElementById('anno-start-frame-input');
const endInput = document.getElementById('anno-end-frame-input');
if (startInput) currentStartFrame = startInput.value === ''
? null : Math.max(0, parseInt(startInput.value, 10) || 0);
if (endInput) currentEndFrame = endInput.value === ''
? null : Math.max(0, parseInt(endInput.value, 10) || 0);
updateFormState();
}
function captureStartFrame() {
const frame = typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0;
currentStartFrame = frame;
syncAnnotationFrameInputs();
updateFormState();
const form = document.getElementById('anno-form');
if (form && form.classList.contains('hidden')) showNewAnnotationForm(false);
}
function captureEndFrame() {
const frame = typeof getCurrentFrame === 'function' ? getCurrentFrame() : 0;
currentEndFrame = frame;
syncAnnotationFrameInputs();
updateFormState();
const form = document.getElementById('anno-form');
if (form && form.classList.contains('hidden')) showNewAnnotationForm(false);
}
window.captureStartFrame = captureStartFrame;
window.captureEndFrame = captureEndFrame;
// ── Save ─────────────────────────────────────────────
async function saveAnnotation() {
if (!isAnnotationEditor()) return;
const labelInput = document.getElementById('anno-label-input');
const label = labelInput?.value?.trim();
if (!label) {
if (labelInput) {
labelInput.classList.add('border-red-500');
labelInput.focus();
const warn = document.getElementById('anno-label-warn');
if (warn) warn.classList.remove('hidden');
setTimeout(() => {
labelInput.classList.remove('border-red-500');
if (warn) warn.classList.add('hidden');
}, 2500);
}
return;
}
const body = {
label: label,
start_frame_index: currentStartFrame,
end_frame_index: currentEndFrame,
};
try {
let res;
if (editingAnnotationId) {
const oldSeg = annotations.find(a => a.id === editingAnnotationId);
if (oldSeg) _pushUndo('update', oldSeg);
body.updated_at = _editingBaselineUpdatedAt || undefined; // optimistic concurrency
// 兼容历史候选段:人工编辑后直接确认为正式段
if (_editingWasCandidate) body.status = 'confirmed';
res = await fetch(`/api/v1/annotation/${editingAnnotationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
res = await fetch(`/api/v1/episode/${currentEpisodeId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
const created = await res.json();
if (created && created.id) _pushUndo('create', { id: created.id });
}
}
if (res.status === 409) {
// Another device saved this slice while we were editing — reload
// the latest version and re-enter edit mode so the user can
// re-apply their changes without creating a duplicate slice.
const conflictId = editingAnnotationId;
alert(t('slice_conflict'));
await loadAnnotations(currentEpisodeId);
editAnnotation(conflictId);
return;
}
if (res.ok) {
hideAnnotationForm();
await loadAnnotations(currentEpisodeId);
} else {
const err = await res.json();
alert(t('slice_save_failed') + (err.detail || 'unknown'));
}
} catch (err) {
alert(t('slice_save_failed') + err.message);
}
}
// ── Edit ─────────────────────────────────────────────
function editAnnotation(annoId) {
if (!isAnnotationEditor()) return;
const seg = annotations.find(a => a.id === annoId);
if (!seg) return;
editingAnnotationId = annoId;
_editingWasCandidate = (seg.status || 'confirmed') === 'candidate';
_editingBaselineUpdatedAt = seg.updated_at || null;
currentStartFrame = seg.start_frame_index;
currentEndFrame = seg.end_frame_index;
syncAnnotationFrameInputs();
const form = document.getElementById('anno-form');
const titleEl = document.getElementById('anno-form-title');
if (form) form.classList.remove('hidden');
if (titleEl) titleEl.textContent = t('edit_annotation');
const labelInput = document.getElementById('anno-label-input');
if (labelInput) labelInput.value = seg.label;
const saveBtn = document.getElementById('btn-save-annotation');
if (saveBtn) {
const labelSpan = saveBtn.querySelector('[data-i18n]');
if (labelSpan) labelSpan.textContent = t('save_changes');
}
updateFormState();
renderAnnotationList();
labelInput?.focus();
}
// ── Delete ───────────────────────────────────────────
async function deleteAnnotation(annoId) {
if (!isAnnotationEditor()) return;
if (!confirm(t('confirm_delete_anno'))) return;
const seg = annotations.find(a => a.id === annoId);
if (seg) _pushUndo('delete', seg);
try {
const res = await fetch(`/api/v1/annotation/${annoId}`, { method: 'DELETE' });
if (res.ok) {
hideAnnotationForm();
await loadAnnotations(currentEpisodeId);
}
} catch (err) {
alert(t('slice_delete_failed') + err.message);
}
}
// ── Undo (Ctrl+Z) ────────────────────────────────────
// 纯前端撤销栈:每次标注修改前压入旧状态(上限 10 步),
// Ctrl+Z 撤销一步(删除→重建 / 新建→删 / 编辑·确认·边界→PUT 恢复)。
let _undoStack = [];
const _UNDO_MAX = 10;
function _pushUndo(op, seg) {
if (!seg) return;
_undoStack.push({ op, seg: JSON.parse(JSON.stringify(seg)) });
if (_undoStack.length > _UNDO_MAX) _undoStack.shift();
}
async function undoAnnotation() {
if (!isAnnotationEditor()) return;
const entry = _undoStack.pop();
if (!entry) return;
const { op, seg } = entry;
try {
if (op === 'delete') {
// 重建(关键帧等附属数据 v1 不恢复;label/区间/来源恢复)
await fetch(`/api/v1/episode/${currentEpisodeId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: seg.label || '—',
start_frame_index: seg.start_frame_index,
end_frame_index: seg.end_frame_index,
}),
});
} else if (op === 'create') {
await fetch(`/api/v1/annotation/${seg.id}`, { method: 'DELETE' });
} else { // update:恢复旧字段
await fetch(`/api/v1/annotation/${seg.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: seg.label,
start_frame_index: seg.start_frame_index,
end_frame_index: seg.end_frame_index,
status: seg.status || 'confirmed',
updated_at: seg.updated_at || undefined,
}),
});
}
await loadAnnotations(currentEpisodeId);
} catch (err) {
console.error('undo failed:', err);
}
}
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
if (!isAnnotationEditor()) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
e.preventDefault();
undoAnnotation();
}
});
// ── Current annotation highlight ────────────────────
function _singleCurrentAnnoId(frameIndex, preferId) {
/* 单一高亮规则:一次只允许一个段处于"当前"状态。
preferId:点击卡片时指定该段(即使其起点落在另一段的区间内,
也只高亮被点的这张);否则取"开始帧 ≤ 当前帧"的段中开始帧
最大的(最近进入的那段)。段重叠时不再出现双高亮。 */
if (preferId && annotations.some(a => a.id === preferId)) return preferId;
let best = null;
for (const a of annotations) {
if (frameIndex >= a.start_frame_index && frameIndex <= a.end_frame_index) {
if (!best || a.start_frame_index > best.start_frame_index) best = a;
}
}
return best ? best.id : null;
}
function updateCurrentAnnotation(frameIndex, preferId) {
updateAnnotationTimelineCursor(frameIndex);
const currentId = _singleCurrentAnnoId(frameIndex, preferId);
if (currentId === lastHighlightedAnnoId) {
lastHighlightedFrame = frameIndex;
return;
}
lastHighlightedAnnoId = currentId;
// 只切换高亮 class,不重建列表 —— 重建 innerHTML 会让用户点击
// 瞬间的 DOM 被替换,导致"点中间的卡片命中上面/下面的卡片"
const listEl = document.getElementById('episode-detail-annos');
if (listEl) {
listEl.querySelectorAll('.annotation-card').forEach(card => {
const active = card.dataset.annoId === currentId;
card.classList.toggle('bg-blue-900/30', active);
card.classList.toggle('border-blue-600/60', active);
card.classList.toggle('bg-gray-800/40', !active);
card.classList.toggle('border-gray-700/30', !active);
card.classList.toggle('hover:bg-gray-800', !active);
const label = card.querySelector('.annotation-card-label');
if (label) label.classList.toggle('text-blue-200', active);
// 点击来源(切片预览/时间轴段)时,当前卡片滚动到可视区,
// 与右侧列表保持同步;播放中不滚动(避免打断人工浏览)
if (active && preferId) {
try { card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch (_) {}
}
});
}
lastHighlightedFrame = frameIndex;
}
let lastHighlightedFrame = -1;
let lastHighlightedAnnoId = null;
if (typeof setOnFrameChange === 'function') {
setOnFrameChange((frameIndex) => {
updateCurrentAnnotation(frameIndex);
});
}
// ── Helpers ──────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
str = String(str);
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
// ── Init ─────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const btnSave = document.getElementById('btn-save-annotation');
if (btnSave) btnSave.addEventListener('click', saveAnnotation);
const btnAi = document.getElementById('btn-ai-annotate');
if (btnAi) btnAi.addEventListener('click', aiAnnotate);
const btnZoomIn = document.getElementById('btn-zoom-in');
const btnZoomOut = document.getElementById('btn-zoom-out');
if (btnZoomIn) btnZoomIn.addEventListener('click', () => {
const idx = ZOOM_STEPS.indexOf(timelineZoom.scale);
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, (idx < 0 ? 0 : idx) + 1)];
_setZoom(next, timelineZoom.centerFrame);
});
if (btnZoomOut) btnZoomOut.addEventListener('click', () => {
const idx = ZOOM_STEPS.indexOf(timelineZoom.scale);
const prev = ZOOM_STEPS[Math.max(0, (idx < 0 ? 0 : idx) - 1)];
_setZoom(prev, timelineZoom.centerFrame);
});
_bindTimelineZoom();
const btnAnnoStart = document.getElementById('btn-anno-set-start');
if (btnAnnoStart) btnAnnoStart.addEventListener('click', captureStartFrame);
const btnAnnoEnd = document.getElementById('btn-anno-set-end');
if (btnAnnoEnd) btnAnnoEnd.addEventListener('click', captureEndFrame);
const startInput = document.getElementById('anno-start-frame-input');
const endInput = document.getElementById('anno-end-frame-input');
if (startInput) startInput.addEventListener('change', readAnnotationFrameInputs);
if (endInput) endInput.addEventListener('change', readAnnotationFrameInputs);
const btnBack10 = document.getElementById('btn-skip-back-10');
const btnBack1 = document.getElementById('btn-step-back');
const btnFwd1 = document.getElementById('btn-step-forward');
const btnFwd10 = document.getElementById('btn-skip-forward-10');
if (btnBack10) btnBack10.addEventListener('click', () => { if (typeof skipFrames === 'function') skipFrames(-10); });
if (btnBack1) btnBack1.addEventListener('click', () => { if (typeof stepFrame === 'function') stepFrame(-1); });
if (btnFwd1) btnFwd1.addEventListener('click', () => { if (typeof stepFrame === 'function') stepFrame(1); });
if (btnFwd10) btnFwd10.addEventListener('click', () => { if (typeof skipFrames === 'function') skipFrames(10); });
});