Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: ็ๆฌ 1.19.4 / ๅ็ซฏ 1.12.4 โโ ๆๅก็ซฏ i18n(้ฆๅฑไธๅ้ช่ฑๆ)ใๆจ็ๆจกๅๅนถๅ
ฅๆจกๅ้กตใๅ็ซฏๆฝๅบ shared/ใ็ผๅญ้ฎๆน็จๅ็ซฏๅท
bb59211 Download processor/web/static/js/annotations.js from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 62.6 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/annotations.js
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/web/static/js/annotations.js
-
curl -L -o annotations.js https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/annotations.js
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, '&').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); }); | |
| }); | |