DataFlowing / processor /web /static /js /annotations.js
stouchinglhh
Claude
sync processor from GitLab c697b6e: ็‰ˆๆœฌ 1.19.4 / ๅ‰็ซฏ 1.12.4 โ€”โ€” ๆœๅŠก็ซฏ i18n(้ฆ–ๅฑไธๅ†้—ช่‹ฑๆ–‡)ใ€ๆŽจ็†ๆจกๅ—ๅนถๅ…ฅๆจกๅž‹้กตใ€ๅ‰็ซฏๆŠฝๅ‡บ shared/ใ€็ผ“ๅญ˜้”ฎๆ”น็”จๅ‰็ซฏๅท
bb59211
Raw History Blame Contribute Delete
62.6 kB
/* 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 = `
<div class="p-4 text-center text-gray-600 text-xs">
<iconify-icon icon="ant-design:scissor-outlined" class="text-lg mb-1"></iconify-icon>
<div>${t('no_slices')}</div>
${editor ? `<div class="text-[10px] text-gray-700 mt-1">${t('no_slices_hint')}</div>` : `<div class="text-[10px] text-gray-700 mt-1">${t('annotation_readonly')}</div>`}
</div>`;
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 =>
'<div class="text-gray-600 text-xs ml-3 mt-0.5">' +
'<iconify-icon icon="ant-design:aim-outlined" class="icon-sm"></iconify-icon> frame ' + kf.frame_index + ': ' + escHtml(kf.event || '') +
'</div>'
).join('');
const isEditing = editor && seg.id === editingAnnotationId;
const isCandidate = (seg.status || 'confirmed') === 'candidate';
const isAi = seg.source === 'ai';
return `
<div class="annotation-card rounded-lg p-2.5 text-xs cursor-pointer transition-colors border flex gap-2 ${isEditing ? 'is-editing' : (isCurrent ? 'bg-blue-900/30 border-blue-600/60' : 'bg-gray-800/40 border-gray-700/30 hover:bg-gray-800')}${isCandidate ? ' border-cyan-700/50' : ''}"
data-anno-id="${seg.id}"
onclick="onAnnotationClick('${seg.id}')">
<!-- Color strip -->
<div class="w-1 flex-shrink-0 rounded-full" style="background:${color}"></div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start gap-1.5 mb-1">
<span class="text-[10px] text-gray-500 font-mono">${i + 1}</span>
${isAi ? '<iconify-icon icon="ant-design:robot-outlined" class="icon-sm text-cyan-400"></iconify-icon>' : ''}
<span class="annotation-card-label min-w-0 flex-1 font-medium ${isCurrent ? 'text-blue-200' : 'text-gray-200'}" title="${escHtml(seg.label || t('unnamed_slice'))}">${escHtml(seg.label || t('unnamed_slice'))}</span>
${isAi ? '<span class="text-[9px] px-1 rounded bg-cyan-900/50 text-cyan-300 border border-cyan-700/50">AI</span>' : ''}
</div>
<div class="annotation-card-meta text-gray-400 font-mono mb-1">
<span>${seg.start_frame_index} - ${seg.end_frame_index}</span>
<span>${frames} frames ยท ${duration}s</span>
</div>
<div class="legacy-annotation-meta hidden">
${seg.start_frame_index} โ€“ ${seg.end_frame_index}
<span class="text-gray-600 ml-1">(${frames}f ยท ${duration}s)</span>
</div>
${kfHTML}
<div class="flex gap-2 mt-1.5">
<button class="text-gray-500 hover:text-blue-400 transition-colors" title="Play segment"
onclick="event.stopPropagation();playSegment(${seg.start_frame_index}, ${seg.end_frame_index})">
<iconify-icon icon="ant-design:caret-right-outlined" class="icon-sm"></iconify-icon> Play
</button>
${editor ? `<button class="text-gray-500 hover:text-gray-300 transition-colors" title="Edit"
onclick="event.stopPropagation();editAnnotation('${seg.id}')">
<iconify-icon icon="ant-design:edit-outlined" class="icon-sm"></iconify-icon>
</button>
<button class="text-gray-500 hover:text-red-400 transition-colors" title="Delete"
onclick="event.stopPropagation();deleteAnnotation('${seg.id}')">
<iconify-icon icon="ant-design:close-outlined" class="icon-sm"></iconify-icon>
</button>` : ''}
</div>
</div>
</div>`;
}).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 = `<span>${seg.start_frame_index} - ${seg.end_frame_index}</span><span>${frames} frames ยท ${duration}s</span>`;
});
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 = `
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<h3 class="text-sm font-semibold text-gray-200">Select AI API</h3>
<button type="button" class="text-gray-500 hover:text-gray-300 text-base leading-none">โœ•</button>
</div>
<div class="p-3 space-y-2"></div>`;
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 = `<div class="font-medium">${escHtml(p.vendor || '')} ยท ${escHtml(p.model || '')}</div>
<div class="text-[11px] text-gray-500">${escHtml(p.base_url || 'default endpoint')}</div>`;
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('<iconify-icon icon="ant-design:loading-outlined" class="icon-sm animate-spin"></iconify-icon> <span>AIโ€ฆ</span>');
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('<iconify-icon icon="ant-design:loading-outlined" class="icon-sm animate-spin"></iconify-icon> <span>' + t('ai_status_segmenting') + '</span>');
else if (st.status === 'vlm_analyzing') setLabel('<iconify-icon icon="ant-design:loading-outlined" class="icon-sm animate-spin"></iconify-icon> <span>' + t('ai_status_analyzing') + '</span>');
else if (st.status === 'writing') setLabel('<iconify-icon icon="ant-design:loading-outlined" class="icon-sm animate-spin"></iconify-icon> <span>' + t('ai_status_writing') + '</span>');
else if (st.status === 'exporting') setLabel('<iconify-icon icon="ant-design:loading-outlined" class="icon-sm animate-spin"></iconify-icon> <span>' + t('ai_status_exporting') + '</span>');
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('<iconify-icon icon="ant-design:close-circle-outlined" class="icon-sm text-red-400"></iconify-icon> <span>' + (d || 'AI annotation failed') + '</span>');
break;
}
if (st.status === 'interrupted') { console.warn('AI annotation interrupted:', st.detail); break; }
}
} catch (err) {
console.error('AI annotation error:', err);
}
if (!failed) {
setLabel('<iconify-icon icon="ant-design:robot-outlined" class="icon-sm"></iconify-icon> <span data-i18n="ai_annotate">AI Annotate</span>');
}
_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 = `
<span class="annotation-timeline-label">${index + 1} ยท ${escHtml(label)}</span>`;
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 =
'<span class="text-gray-500">Range: </span>' +
'<span class="font-mono text-green-400 font-bold">' + currentStartFrame + '</span>' +
'<span class="text-gray-600"> โ†’ </span>' +
'<span class="font-mono text-green-400 font-bold">' + currentEndFrame + '</span>' +
'<span class="text-gray-500 ml-2">(' + frames + 'f ยท ' + duration + 's)</span>';
preview.className = 'bg-gray-900 rounded-lg px-3 py-2 text-center';
} else if (currentStartFrame != null) {
preview.innerHTML =
'<span class="text-gray-500">Start: </span>' +
'<span class="font-mono text-yellow-400 font-bold">' + currentStartFrame + '</span>' +
'<span class="text-gray-600"> โ€” waiting for End</span>';
preview.className = 'bg-gray-900 rounded-lg px-3 py-2 text-center';
} else {
preview.innerHTML =
'<span class="text-gray-500">Set Start / Set End using the fields or buttons above</span>';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// โ”€โ”€ 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); });
});