DataFlowing / processor /web /static /js /hand-overlay.js
quyu666's picture
sync processor from GitLab ac3323f: 深度格式兼容(legacy_mm10+逐批次覆盖)+ UMI Gripper 模块与测试
af470e1
Raw History Blame Contribute Delete
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;
})();