Spaces:
Running
Running
Download processor/web/static/js/hand-overlay.js from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 32.6 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/hand-overlay.js
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/web/static/js/hand-overlay.js
-
curl -L -o hand-overlay.js https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/static/js/hand-overlay.js
32.6 kB
| /* Hand Skeleton SVG 叠加层 —— 在原始视频画面上实时绘制手部骨骼(第一版)。 | |
| * | |
| * 控制方式:独立的 ☷ Preview Options 菜单(素材栏右侧,点击展开): | |
| * - Hand Skeleton 开关(总开关,默认 ON;所有视频 tile 的骨骼一起显隐) | |
| * - 数据状态行:Loading… / Available / No tracking data(无数据时开关禁用, | |
| * 点击菜单不会报错 —— 对应 Project_Test10_000002 这类只有手套数据的批次) | |
| * | |
| * 数据:GET /api/v1/video/{episode}/{camera}/hand-keypoints?start_frame&end_frame | |
| * (stereo_left/right 各自的 2D 归一化关键点,分段拉取,窗口缓存)。 | |
| * 同步:统一帧回调(多回调注册,与标注高亮互不覆盖)+ 播放期 rAF 循环。 | |
| * 绘制:与渲染视频完全一致的 demo 风格(五指分色/掌心灰线/白色腕点, | |
| * 尺寸按显示比例缩放);SVG pointer-events:none,不影响视频拖动/播放。 | |
| */ | |
| (function () { | |
| 'use strict'; | |
| /* 与渲染视频(_draw_demo_style)完全一致的 demo 风格: | |
| 五指分色、掌心灰线、白色腕点;尺寸按视频显示比例缩放。 */ | |
| const FINGERS = { | |
| Thumb: { ids: [1, 2, 3, 4], color: '#0080ff' }, | |
| Index: { ids: [5, 6, 7, 8], color: '#00ff00' }, | |
| Middle: { ids: [9, 10, 11, 12], color: '#00ffff' }, | |
| Ring: { ids: [13, 14, 15, 16], color: '#ff00ff' }, | |
| Pinky: { ids: [17, 18, 19, 20], color: '#ff8000' }, | |
| }; | |
| const PALM_EDGES = [[0, 1], [0, 5], [5, 9], [9, 13], [13, 17], [0, 17]]; | |
| const WINDOW_HALF = 250; // 每次拉取 ±250 帧 | |
| const PREFETCH_MARGIN = 60; // 到窗口边界前提前拉取下一窗口 | |
| const PREFETCH_WINDOWS = 3; // 播放头前至少保持三个关键点窗口 | |
| const INITIAL_BUFFER_FRAMES = 450; // 15s at the default 30 FPS | |
| const FULL_PRELOAD_MAX_FRAMES = 3000; | |
| const CACHE_HALF = 1500; // 缓存保留 ±1500 帧,超出剔除 | |
| const overlays = new Map(); // key → state | |
| let masterOn = true; | |
| /* ── 控制菜单(☷ Preview Options,漂浮可拖动)── */ | |
| function bindMenu() { | |
| const box = document.getElementById('preview-options'); | |
| const btn = document.getElementById('btn-preview-options'); | |
| const menu = document.getElementById('preview-options-menu'); | |
| const chk = document.getElementById('toggle-hand-skeleton'); | |
| // The menu button must remain usable even if a newer/older template | |
| // temporarily lacks one optional toggle. Previously this early return | |
| // made Preview Options completely inert when any child was missing. | |
| if (!box || !btn || !menu) return; | |
| if (box.dataset.previewMenuBound === '1') return; | |
| box.dataset.previewMenuBound = '1'; | |
| // 拖动:按住移动超过 5px 视为拖动,否则视为点击(展开/收起菜单)。 | |
| // 坐标用 offsetLeft/offsetTop(相对视频工作区,与 style.left/top | |
| // 同坐标系)—— 之前用 getBoundingClientRect(视口坐标)导致 | |
| // 拖动瞬间面板跳位。 | |
| let dragStart = null; | |
| btn.addEventListener('pointerdown', (e) => { | |
| dragStart = { x: e.clientX, y: e.clientY, | |
| bx: box.offsetLeft, by: box.offsetTop, moved: false }; | |
| btn.setPointerCapture(e.pointerId); | |
| }); | |
| btn.addEventListener('pointermove', (e) => { | |
| if (!dragStart) return; | |
| const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y; | |
| if (Math.abs(dx) + Math.abs(dy) > 5) dragStart.moved = true; | |
| if (dragStart.moved) { | |
| box.style.left = Math.max(0, dragStart.bx + dx) + 'px'; | |
| box.style.top = Math.max(0, dragStart.by + dy) + 'px'; | |
| box.style.right = 'auto'; | |
| } | |
| }); | |
| btn.addEventListener('pointerup', (e) => { | |
| const wasDrag = dragStart && dragStart.moved; | |
| dragStart = null; | |
| if (!wasDrag) { | |
| e.stopPropagation(); | |
| menu.classList.toggle('hidden'); | |
| btn.setAttribute('aria-expanded', String(!menu.classList.contains('hidden'))); | |
| } | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if (!menu.classList.contains('hidden') && !menu.contains(e.target) && !btn.contains(e.target)) { | |
| menu.classList.add('hidden'); | |
| btn.setAttribute('aria-expanded', 'false'); | |
| } | |
| }); | |
| if (chk) chk.addEventListener('change', () => { | |
| masterOn = chk.checked; | |
| overlays.forEach(st => _render(st)); | |
| }); | |
| const chkTrail = document.getElementById('toggle-hand-trail'); | |
| if (chkTrail) chkTrail.addEventListener('change', () => { | |
| trailOn = chkTrail.checked; | |
| overlays.forEach(st => _render(st)); | |
| }); | |
| const chkGlove = document.getElementById('toggle-glove-sensor'); | |
| const chkHeatmap = document.getElementById('toggle-heatmap'); | |
| const chkHand3dWorld = document.getElementById('toggle-hand3d-world'); | |
| if (chkGlove) chkGlove.addEventListener('change', () => { | |
| gloveOn = chkGlove.checked; | |
| _applyTileVisibility(); | |
| }); | |
| if (chkHeatmap) chkHeatmap.addEventListener('change', () => { | |
| // 深度显示的唯一开关:开 = 直接挂载采集端深度视频 tile; | |
| // 关 = 卸除 source(布局随 workspaceSources 回收,不留空位) | |
| depthOn = chkHeatmap.checked; | |
| _applyTileVisibility(); | |
| }); | |
| if (chkHand3dWorld) chkHand3dWorld.addEventListener('change', () => { | |
| hand3dWorldOn = chkHand3dWorld.checked; | |
| // 开 = 自动挂载 3D 世界窗口;关 = 卸除(主区空单元格随之回收) | |
| _applyTileVisibility(); | |
| }); | |
| updateMenuStatus(); | |
| updatePreviewMenuData(); | |
| } | |
| /* 手套/深度/3D 世界窗口 tile 显隐开关(按素材类型前缀,只影响画布显示) */ | |
| let gloveOn = true, depthOn = true, hand3dWorldOn = true; | |
| function _tilesByPrefix(prefix) { | |
| const grid = document.getElementById('video-grid'); | |
| if (!grid) return []; | |
| return [...grid.querySelectorAll('[data-source-key]')] | |
| .filter(t => (t.dataset.sourceKey || '').startsWith(prefix)); | |
| } | |
| function _applyTileVisibility() { | |
| // 关闭某个预览 tile 不能改变 Preview Options 容器本身的页面状态。 | |
| // removeGroupedSource/renderGroupedWorkspace 会重排工作区,这里在 | |
| // 前后各做一次轻量兜底,但返回批次列表时仍由 __episodeOpen=false | |
| // 正常隐藏。 | |
| _restorePreviewOptions(); | |
| ['hand:', 'glove:'].forEach(p => | |
| _tilesByPrefix(p).forEach(t => { t.style.display = gloveOn ? '' : 'none'; })); | |
| // 深度 / 3D 世界窗口:直接挂/卸 source(布局随 workspaceSources | |
| // 回收,不留 display:none 空位;挂载幂等,重复调用无害) | |
| if (typeof window.setDepthTileVisible === 'function') { | |
| window.setDepthTileVisible(depthOn); | |
| } | |
| if (typeof window.setHand3dWorldVisible === 'function') { | |
| window.setHand3dWorldVisible(hand3dWorldOn); | |
| } | |
| _restorePreviewOptions(); | |
| } | |
| function _restorePreviewOptions() { | |
| if (window.__episodeOpen !== true) return; | |
| const box = document.getElementById('preview-options'); | |
| if (box) box.classList.remove('hidden'); | |
| } | |
| /* 数据驱动:当前批次有什么数据,菜单才显示对应的开关行 | |
| (media-groups 素材清单为准;批次切换时由 player.js 调用)。 | |
| 同时把 checkbox 勾选同步到真实状态 —— 修复"显示开启实际 | |
| 关闭"的 UI 与状态脱节(批次切换后 checkbox 残留旧勾选)。 */ | |
| function updatePreviewMenuData() { | |
| if (typeof renderPreviewVideoSources === 'function') renderPreviewVideoSources(); | |
| const rowGlove = document.getElementById('po-glove-row'); | |
| const rowHeatmap = document.getElementById('po-heatmap-row'); | |
| const rowHand3dWorld = document.getElementById('po-hand3d-world-row'); | |
| const kinds = new Set(((typeof currentMediaGroups !== 'undefined' && currentMediaGroups) | |
| ? (currentMediaGroups.sources || []) : []) | |
| .map(s => s.kind)); | |
| if (rowGlove) { | |
| const has = kinds.has('glove') || kinds.has('hand'); | |
| rowGlove.classList.toggle('hidden', !has); | |
| rowGlove.classList.toggle('flex', has); | |
| } | |
| if (rowHeatmap) { | |
| // 深度显示的唯一开关(无独立 Depth 行):有深度数据才显示 | |
| const has = kinds.has('depth'); | |
| rowHeatmap.classList.toggle('hidden', !has); | |
| rowHeatmap.classList.toggle('flex', has); | |
| } | |
| if (rowHand3dWorld) { | |
| // 任何可读取的 hand_3d 产物都提供 3D 预览;D435 深度抬升 | |
| // 仍会在 player.js 中标记为 world mode。 | |
| const has = (typeof hand3dData !== 'undefined' && hand3dData | |
| && hand3dData.hasPreview) || false; | |
| const label = rowHand3dWorld.querySelector('[data-i18n="hand3d_world"]'); | |
| if (label && typeof t === 'function') { | |
| label.textContent = hand3dData && hand3dData.worldMode | |
| ? t('hand3d_world') : t('3d_skeleton'); | |
| } | |
| rowHand3dWorld.classList.toggle('hidden', !has); | |
| rowHand3dWorld.classList.toggle('flex', has); | |
| } | |
| // UI 勾选 = 真实状态(单一事实源:gloveOn/depthOn/hand3dWorldOn) | |
| const chkGlove = document.getElementById('toggle-glove-sensor'); | |
| const chkHeatmap = document.getElementById('toggle-heatmap'); | |
| const chkH3d = document.getElementById('toggle-hand3d-world'); | |
| if (chkGlove) chkGlove.checked = gloveOn; | |
| if (chkHeatmap) chkHeatmap.checked = depthOn; | |
| if (chkH3d) chkH3d.checked = hand3dWorldOn; | |
| _applyTileVisibility(); | |
| _restorePreviewOptions(); | |
| } | |
| function updateMenuStatus() { | |
| const chk = document.getElementById('toggle-hand-skeleton'); | |
| const statusEl = document.getElementById('preview-options-status'); | |
| if (!chk) return; | |
| const states = [...overlays.values()]; | |
| let text = ''; | |
| if (states.length === 0) { | |
| text = 'No video loaded'; | |
| chk.disabled = true; | |
| } else if (states.some(s => s.status === 'loading')) { | |
| text = 'Loading…'; | |
| chk.disabled = true; | |
| } else if (states.some(s => s.status === 'ready')) { | |
| text = 'Available'; | |
| chk.disabled = false; | |
| } else { | |
| text = 'No tracking data — run the workflow first'; | |
| chk.disabled = true; | |
| // Keep the control's batch default consistent even when this | |
| // episode has no overlay artifact. It is disabled because there | |
| // is nothing to draw, but it must not turn the global default off | |
| // and leak that state into the next episode. | |
| chk.checked = true; | |
| masterOn = true; | |
| } | |
| if (statusEl) statusEl.textContent = text; | |
| if (!chk.disabled && !chk.checked) chk.checked = masterOn; | |
| } | |
| /* ── 叠加层生命周期 ── */ | |
| function initHandOverlay(source, tile, holder) { | |
| if (source.kind !== 'video') return Promise.resolve(false); // no overlay | |
| // 不再限制具体相机:双目(stereo_left/right)与单目(mediapipe_hand) | |
| // 都由 /hand-keypoints 接口按相机提供数据 —— 无数据 404 → | |
| // 该 tile 显示 No Data,不做叠加(探测驱动,兼容两种手部模块) | |
| const key = (typeof groupedSourceKey === 'function') | |
| ? groupedSourceKey(source) : String(source.source_key || ''); | |
| if (overlays.has(key)) return Promise.resolve(true); | |
| // 透明 SVG 层(pointer-events:none —— 不影响视频拖动和播放) | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('class', 'hand-overlay-svg'); | |
| svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:5;'; | |
| svg.setAttribute('width', '100%'); | |
| svg.setAttribute('height', '100%'); | |
| holder.appendChild(svg); | |
| const state = { | |
| key, source, tile, holder, svg, | |
| frames: new Map(), // frameIndex → {h0, h1} | |
| fetched: [], // 已拉取的窗口 [[start,end],...]:窗口内 | |
| // 缓存没有的帧 = 该帧真无手(不再重拉) | |
| frameCount: 0, // 手部骨骼数据实际帧数(接口 count) | |
| inflight: false, | |
| prefetchPending: false, | |
| prefetchEnd: -1, | |
| fullPreloaded: false, | |
| fullCacheKey: '', | |
| status: 'loading', // loading | ready | nodata | |
| }; | |
| overlays.set(key, state); | |
| updateMenuStatus(); | |
| // The keypoint request and video metadata arrive independently. If | |
| // keypoints win the race, _render() sees videoWidth === 0 and exits; | |
| // without a later metadata event the overlay only appears after the | |
| // central clock advances a few frames. Re-render on the first usable | |
| // video layout while keeping the exact current frame. | |
| const video = holder.querySelector('video'); | |
| const renderWhenVideoReady = () => { | |
| if (overlays.get(key) !== state || state.status !== 'ready') return; | |
| _render(state); | |
| }; | |
| state.video = video; | |
| state.renderWhenVideoReady = renderWhenVideoReady; | |
| if (video) { | |
| ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'resize'] | |
| .forEach(eventName => video.addEventListener( | |
| eventName, renderWhenVideoReady)); | |
| } | |
| // 首帧探测:数据存在性 + 第一窗口 | |
| const initialReady = (async () => { | |
| const count = Number(source.frame_count) || 0; | |
| const full = count > 0 && count <= FULL_PRELOAD_MAX_FRAMES; | |
| let ok = false; | |
| if (full) { | |
| ok = await _fetchWindow(state, 0, 0, count - 1); | |
| } else { | |
| // Long clips get 10–15 seconds of keypoints before the | |
| // playback barrier opens. Three further windows are queued | |
| // asynchronously while RGB is playing. | |
| const initialEnd = Math.min(count - 1, INITIAL_BUFFER_FRAMES - 1); | |
| ok = await _fetchWindow(state, 0, 0, initialEnd); | |
| if (ok) _prefetchWindows(state, initialEnd + 1, PREFETCH_WINDOWS); | |
| } | |
| state.status = ok ? 'ready' : 'nodata'; | |
| updateMenuStatus(); | |
| _render(state); | |
| return ok; | |
| })().catch(() => { | |
| state.status = 'nodata'; | |
| updateMenuStatus(); | |
| return false; | |
| }); | |
| // player.js awaits this promise before removing the batch loading | |
| // barrier, so RGB and spatial keypoints become visible together. | |
| return initialReady; | |
| } | |
| function destroyHandOverlay(key) { | |
| const st = overlays.get(key); | |
| if (!st) return; | |
| overlays.delete(key); | |
| if (st.video && st.renderWhenVideoReady) { | |
| ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'resize'] | |
| .forEach(eventName => st.video.removeEventListener( | |
| eventName, st.renderWhenVideoReady)); | |
| } | |
| st.svg.remove(); | |
| updateMenuStatus(); | |
| } | |
| function destroyAllHandOverlays() { | |
| overlays.forEach((st, key) => destroyHandOverlay(key)); | |
| // Overlay visibility is a per-episode default, not a global session | |
| // preference. A batch without tracking data may set masterOn=false; | |
| // reset it before the next batch so valid 2D data is shown directly. | |
| masterOn = true; | |
| const chk = document.getElementById('toggle-hand-skeleton'); | |
| if (chk) chk.checked = true; | |
| updateMenuStatus(); | |
| } | |
| function _inFetched(state, frame) { | |
| return state.fetched.some(([s, e]) => frame >= s && frame <= e); | |
| } | |
| async function _fetchWindow(state, center, requestedStart = null, | |
| requestedEnd = null) { | |
| if (state.inflight) return false; | |
| const start = requestedStart == null | |
| ? Math.max(0, center - WINDOW_HALF) : Math.max(0, requestedStart); | |
| const end = requestedEnd == null | |
| ? center + WINDOW_HALF : Math.max(start, requestedEnd); | |
| const isFull = start === 0 && requestedEnd != null && | |
| (Number(state.source.frame_count) || 0) <= end + 1; | |
| if (state.fetched.some(([s, e]) => start >= s && end <= e)) return true; | |
| state.inflight = true; | |
| try { | |
| const epoch = currentEpisodeId; | |
| const sessionToken = typeof getPlaybackSessionToken === 'function' | |
| ? getPlaybackSessionToken() : null; | |
| const isCurrent = () => typeof isCurrentPlaybackSession !== 'function' | |
| ? currentEpisodeId === epoch | |
| : isCurrentPlaybackSession(epoch, sessionToken); | |
| const cacheKey = `${epoch}:hand2d:${state.source.source_key}:${start}:${end}`; | |
| if (window.EgoMediaCache) { | |
| const cached = await window.EgoMediaCache.get(cacheKey); | |
| if (!isCurrent()) return false; | |
| const value = cached && cached.value; | |
| if (value && Array.isArray(value.frames)) { | |
| value.frames.forEach(fr => state.frames.set(fr.f, fr)); | |
| state.fetched.push([start, end]); | |
| state.prefetchEnd = end; | |
| state.frameCount = value.frameCount || end + 1; | |
| if (isFull) { | |
| state.fullPreloaded = true; | |
| state.fullCacheKey = cacheKey; | |
| } | |
| _evict(state, center); | |
| return true; | |
| } | |
| } | |
| const signal = typeof getMediaLoadSignal === 'function' | |
| ? getMediaLoadSignal() : null; | |
| const res = await fetch( | |
| `/api/v1/video/${epoch}/${state.source.source_key}/hand-keypoints?start_frame=${start}&end_frame=${end}`, | |
| signal ? { signal } : {}); | |
| if (!isCurrent()) return false; // 丢弃过期响应 | |
| if (!res.ok) return false; | |
| const data = await res.json(); | |
| if (!isCurrent()) return false; | |
| (data.frames || []).forEach(fr => state.frames.set(fr.f, fr)); | |
| if (data.count) state.frameCount = data.count; | |
| // 记录已拉取窗口:窗口内缓存没有的帧 = 该帧真无手, | |
| // 之后直接清空骨架显示,不再反复请求(接口只返回有手的帧)。 | |
| state.fetched.push([start, end]); | |
| state.prefetchEnd = Math.max(state.prefetchEnd, end); | |
| if (isFull) { | |
| state.fullPreloaded = true; | |
| state.fullCacheKey = cacheKey; | |
| } | |
| if (window.EgoMediaCache) { | |
| window.EgoMediaCache.put(cacheKey, { | |
| frames: data.frames || [], frameCount: state.frameCount, | |
| }); | |
| } | |
| _evict(state, center); | |
| return true; | |
| } catch (_) { | |
| return false; | |
| } finally { | |
| state.inflight = false; | |
| } | |
| } | |
| async function _prefetchWindows(state, frame, count = PREFETCH_WINDOWS) { | |
| if (state.prefetchPending) return; | |
| state.prefetchPending = true; | |
| try { | |
| let start = Math.max(frame, state.prefetchEnd + 1); | |
| for (let i = 0; i < count; i++) { | |
| const center = start + WINDOW_HALF; | |
| const ok = await _fetchWindow(state, center); | |
| if (!ok) break; | |
| start = state.prefetchEnd + 1; | |
| } | |
| } finally { | |
| state.prefetchPending = false; | |
| } | |
| } | |
| function _prefetchWindow(state, frame) { | |
| // 预取只负责填充缓存,不触碰当前 SVG。这样播放到窗口边界时, | |
| // 当前帧仍由已提交画面保持,下一窗口完整返回后才进行一次切换。 | |
| if (state.inflight || state.prefetchPending | |
| || state.prefetchEnd < 0 | |
| || frame < state.prefetchEnd - PREFETCH_MARGIN) return; | |
| _prefetchWindows(state, frame, PREFETCH_WINDOWS); | |
| } | |
| function _evict(state, center) { | |
| if (state.fullPreloaded) return; | |
| for (const f of [...state.frames.keys()]) { | |
| if (Math.abs(f - center) > CACHE_HALF) state.frames.delete(f); | |
| } | |
| } | |
| function _clearSvg(state) { | |
| while (state.svg.firstChild) state.svg.removeChild(state.svg.firstChild); | |
| } | |
| /* ── 手部轨迹:最近帧的骨骼残影,越旧越淡 ── | |
| 版本备忘: | |
| 第 1 版 = 连续式、每 1 帧一个残影(TRAIL_STEP=1,最密); | |
| 当前版 = 连续式、每 4 帧一个残影、往前取 20 帧(5 个残影) | |
| + 完整骨骼(连线+关键点)。 | |
| 切换密度只需改 TRAIL_STEP/TRAIL_FRAMES。 */ | |
| let trailOn = false; | |
| const TRAIL_FRAMES = 5; // 残影个数(20 帧 / 4 帧间隔 = 5 个) | |
| const TRAIL_STEP = 4; // 残影间隔帧数(第 1 版 = 1) | |
| function _svgLine(state, pts, a, b, color, w, opacity, target) { | |
| if (!pts[a] || !pts[b]) return; | |
| const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| line.setAttribute('x1', pts[a][0]); line.setAttribute('y1', pts[a][1]); | |
| line.setAttribute('x2', pts[b][0]); line.setAttribute('y2', pts[b][1]); | |
| line.setAttribute('stroke', color); | |
| line.setAttribute('stroke-width', String(Math.max(0.5, w))); | |
| line.setAttribute('stroke-linecap', 'round'); | |
| line.setAttribute('opacity', String(opacity)); | |
| (target || state.svg).appendChild(line); | |
| } | |
| /* 与后端 hand_render.py 对齐:按手部在当前画布中的实际像素范围 | |
| 缩放骨骼样式。固定 5/7/9px 在 640x480 的远景手部上会显得过粗。 */ | |
| function _handStyleScale(pts, displayScale) { | |
| const valid = (pts || []).filter(p => p && Number.isFinite(p[0]) | |
| && Number.isFinite(p[1])); | |
| if (valid.length < 2) return 1; | |
| const xs = valid.map(p => p[0]), ys = valid.map(p => p[1]); | |
| const span = Math.max(Math.max(...xs) - Math.min(...xs), | |
| Math.max(...ys) - Math.min(...ys)); | |
| // span is in displayed pixels; 160 is the reference source-pixel | |
| // hand size used by the Python renderer. | |
| return Math.max(0.45, Math.min(1, | |
| span / Math.max(1, 160 * (displayScale || 1)))); | |
| } | |
| function _drawTrail(state, frame, lb) { | |
| /* 连续式残影(无隐藏期):每隔 TRAIL_STEP 帧一个,共 | |
| TRAIL_FRAMES 个,完整骨骼 = 连线 + 关键点圆点。 | |
| 透明度线性衰减:最近最明显,越旧越淡;当前帧骨架覆盖其上。 */ | |
| const { s, vw, vh, dx, dy } = lb; | |
| // 批量构建:所有节点先挂到 Fragment,一次 append(减少 DOM 变更次数, | |
| // 降低浏览器样式/布局抖动) | |
| const frag = document.createDocumentFragment(); | |
| for (let k = 1; k <= TRAIL_FRAMES; k++) { | |
| const age = k * TRAIL_STEP; | |
| const entry = state.frames.get(frame - age); | |
| if (!entry) continue; | |
| const opacity = (1 - k / (TRAIL_FRAMES + 1)) * 0.55; // 线性消失 | |
| // 颜色与实际手部骨骼完全一致:五指分色 + 掌心灰 + 白腕点 | |
| const ptColor = new Array(21).fill('#9ca3af'); | |
| ptColor[0] = '#ffffff'; | |
| for (const [finger, { ids, color }] of Object.entries(FINGERS)) { | |
| for (const idx of ids) ptColor[idx] = color; | |
| } | |
| for (const h of [entry.h0, entry.h1]) { | |
| if (!h || !h.k) continue; | |
| const pts = h.k.map(([x, y]) => (x == null || y == null) | |
| ? null : [dx + x * vw * s, dy + y * vh * s]); | |
| const hs = _handStyleScale(pts, s); | |
| for (const [a, b] of PALM_EDGES) _svgLine(state, pts, a, b, '#c8c8c8', 1.2 * s * hs, opacity, frag); | |
| for (const [finger, { ids, color }] of Object.entries(FINGERS)) { | |
| const chain = finger === 'Thumb' ? ids : [0, ...ids]; | |
| for (let i = 0; i < chain.length - 1; i++) { | |
| _svgLine(state, pts, chain[i], chain[i + 1], color, 1.2 * s * hs, opacity, frag); | |
| } | |
| } | |
| // 残影关键点(按关节对应手指色,透明度与连线一致) | |
| pts.forEach((p, i) => { | |
| if (!p) return; | |
| const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
| c.setAttribute('cx', p[0]); c.setAttribute('cy', p[1]); | |
| c.setAttribute('r', String(Math.max(1, 2.2 * s * hs))); | |
| c.setAttribute('fill', ptColor[i] || '#9ca3af'); | |
| c.setAttribute('opacity', String(opacity)); | |
| frag.appendChild(c); | |
| }); | |
| } | |
| } | |
| state.svg.appendChild(frag); | |
| } | |
| /* Plyr 以 object-contain 显示视频:归一化坐标 → letterbox 内的像素 */ | |
| function _letterbox(state) { | |
| const video = state.holder.querySelector('video'); | |
| if (!video || !video.videoWidth) return null; | |
| const cw = state.holder.clientWidth, ch = state.holder.clientHeight; | |
| const vw = video.videoWidth, vh = video.videoHeight; | |
| const s = Math.min(cw / vw, ch / vh); | |
| return { s, vw, vh, dx: (cw - vw * s) / 2, dy: (ch - vh * s) / 2 }; | |
| } | |
| function _render(state) { | |
| _renderAt(state, (typeof getCurrentFrame === 'function') ? getCurrentFrame() : 0); | |
| } | |
| function _renderAt(state, frame) { | |
| if (!masterOn || state.status !== 'ready') { _clearSvg(state); return; } | |
| const entry = state.frames.get(frame); | |
| if (entry) _prefetchWindow(state, frame); | |
| if (!entry) { | |
| if (_inFetched(state, frame)) { | |
| // 已拉取过的窗口里没有这帧 = 该帧真无手:清空骨架, | |
| // 不要继续显示上一帧的旧骨架(看起来会"错位/卡住") | |
| _clearSvg(state); | |
| return; | |
| } | |
| // 本帧尚未缓存:保持当前已提交画面,异步补窗口。 | |
| // 窗口成功返回后,按最新播放帧一次性提交。 | |
| _fetchWindow(state, frame).then(ok => { | |
| if (!ok) return; | |
| const latest = (typeof getCurrentFrame === 'function') | |
| ? getCurrentFrame() : frame; | |
| _renderAt(state, latest); | |
| }); | |
| return; | |
| } | |
| _clearSvg(state); | |
| const lb = _letterbox(state); | |
| if (!lb) return; | |
| const { s, vw, vh, dx, dy } = lb; | |
| // 批量构建:当前帧骨架也先挂 Fragment,一次 append | |
| const frag = document.createDocumentFragment(); | |
| // 手部轨迹(先画旧帧残影,当前帧骨架覆盖其上) | |
| if (trailOn) _drawTrail(state, frame, lb); | |
| for (const h of [entry.h0, entry.h1]) { | |
| if (!h || !h.k) continue; | |
| const pts = h.k.map(([x, y]) => (x == null || y == null) | |
| ? null : [dx + x * vw * s, dy + y * vh * s]); | |
| const hs = _handStyleScale(pts, s); | |
| const addLine = (a, b, color, w) => { | |
| if (!pts[a] || !pts[b]) return; | |
| const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| line.setAttribute('x1', pts[a][0]); line.setAttribute('y1', pts[a][1]); | |
| line.setAttribute('x2', pts[b][0]); line.setAttribute('y2', pts[b][1]); | |
| line.setAttribute('stroke', color); | |
| line.setAttribute('stroke-width', String(Math.max(0.75, w * s * hs))); | |
| line.setAttribute('stroke-linecap', 'round'); | |
| line.setAttribute('opacity', '0.95'); | |
| frag.appendChild(line); | |
| }; | |
| const addDot = (i, r, fill, outline, ow) => { | |
| if (!pts[i]) return; | |
| const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
| c.setAttribute('cx', pts[i][0]); c.setAttribute('cy', pts[i][1]); | |
| c.setAttribute('r', String(Math.max(1, r * s * hs))); | |
| c.setAttribute('fill', fill); | |
| c.setAttribute('stroke', outline); | |
| c.setAttribute('stroke-width', String(Math.max(0.5, ow * s * hs))); | |
| frag.appendChild(c); | |
| }; | |
| // 掌心灰线(与渲染视频一致) | |
| for (const [a, b] of PALM_EDGES) addLine(a, b, '#c8c8c8', 2); | |
| // 五指分色骨骼线 + 关节圆点(thumb 从 1 起,其余从腕 0 连到指根) | |
| for (const [finger, { ids, color }] of Object.entries(FINGERS)) { | |
| const chain = finger === 'Thumb' ? ids : [0, ...ids]; | |
| for (let i = 0; i < chain.length - 1; i++) addLine(chain[i], chain[i + 1], color, 3); | |
| for (const idx of ids) { | |
| const isTip = idx === ids[ids.length - 1]; | |
| addDot(idx, isTip ? 7 : 5, color, 'rgb(30,30,30)', 1); | |
| } | |
| } | |
| // 腕部白圆 | |
| addDot(0, 9, '#ffffff', 'rgb(40,40,40)', 2); | |
| } | |
| state.svg.appendChild(frag); | |
| } | |
| /* 统一帧回调:与标注高亮共用 setOnFrameChange(多回调) */ | |
| function onFrame(frameIndex) { | |
| overlays.forEach(state => { | |
| if (!masterOn || state.status !== 'ready') return; | |
| _renderAt(state, frameIndex); | |
| }); | |
| } | |
| /* 播放期间:rAF 循环按显示刷新率重画骨骼(60Hz)。 | |
| timeupdate 只有 ~4Hz,直接用它驱动会在 25fps 播放时落后 6-7 帧, | |
| 表现为"正常播放时骨骼没对齐";逐帧步进走精确回调,不受影响。 */ | |
| let _rafLastFrame = -1; | |
| function _rafLoop() { | |
| requestAnimationFrame(_rafLoop); | |
| // player.js owns the central frame clock. Keeping this legacy loop | |
| // active would redraw 2D independently of the 3D canvas and could | |
| // make one layer appear one refresh behind the other. | |
| if (window.__egodataCentralFrameClock) return; | |
| if (!masterOn || overlays.size === 0) return; | |
| let playing = false; | |
| for (const st of overlays.values()) { | |
| const v = st.holder.querySelector('video'); | |
| if (v && !v.paused && !v.ended && v.readyState >= 2) { playing = true; break; } | |
| } | |
| if (!playing) return; | |
| const t = (typeof getCurrentTime === 'function') ? getCurrentTime() : 0; | |
| const fps = (typeof getEpisodeFps === 'function') ? getEpisodeFps() : 25; | |
| const frame = Math.floor(t * fps + 0.002); | |
| if (frame === _rafLastFrame) return; | |
| _rafLastFrame = frame; | |
| overlays.forEach(st => { | |
| if (st.status !== 'ready') return; | |
| _renderAt(st, frame); | |
| }); | |
| } | |
| requestAnimationFrame(_rafLoop); | |
| window.addEventListener('resize', () => overlays.forEach(_render)); | |
| if (typeof setOnFrameChange === 'function') setOnFrameChange(onFrame); | |
| if (document.readyState !== 'loading') bindMenu(); | |
| else document.addEventListener('DOMContentLoaded', bindMenu); | |
| window.initHandOverlay = initHandOverlay; | |
| window.destroyHandOverlay = destroyHandOverlay; | |
| window.destroyAllHandOverlays = destroyAllHandOverlays; | |
| window.updateHandOverlayMenu = updateMenuStatus; | |
| window.updatePreviewMenuData = updatePreviewMenuData; | |
| // 3D 世界窗口开关的真实状态(单一事实源):player.js 的自动挂载 | |
| // 判断必须读这里,不能 `typeof hand3dWorldOn`(IIFE 内 let 在外 | |
| // 不可见,永远 undefined → 自动挂载无视开关,造成"显示开实际关") | |
| window.isHand3dWorldOn = () => hand3dWorldOn; | |
| // 深度开关同样导出(深度 tile 自动挂载判断用) | |
| window.isDepthOn = () => depthOn; | |
| })(); | |