Spaces:
Running
Running
| // Main page glue: static-deployment build. | |
| // - episode list comes from <ASSET_BASE>/index.json | |
| // - per-sample assets live at <ASSET_BASE>/<id>/{depth.mp4, recording.viser, ...} | |
| // - the 3D viewer is the viser-build-client iframe loading recording.viser | |
| import { SignalPanel, StateTimelinePanel } from './timeseries.js'; | |
| // Where to fetch episode data from. Resolution order: | |
| // 1. ?base=<url> query param (manual override) | |
| // 2. localhost: ./samples (local dev via symlink to _dist) | |
| // 3. otherwise: HF Dataset resolve URL (Private dataset uses cookie auth | |
| // from the same huggingface.co origin - user must be logged in) | |
| const ASSET_BASE = (() => { | |
| const u = new URL(window.location.href); | |
| const explicit = u.searchParams.get('base'); | |
| if (explicit) return explicit; | |
| if (location.hostname === 'localhost' || location.hostname.startsWith('127.')) { | |
| return './samples'; | |
| } | |
| return 'https://huggingface.co/datasets/Rice-RobotPI-Lab/egoinfinity/resolve/main/samples'; | |
| })(); | |
| const INDEX_URL = `${ASSET_BASE}/index.json`; | |
| // Build an absolute URL to a sample's recording.viser. The viser client iframe | |
| // lives at viser-client/, so a relative ASSET_BASE like ./samples would resolve | |
| // against viser-client/ instead of the page. We resolve against location.href | |
| // (not document.baseURI) because HF Static Spaces sandbox the page's baseURI | |
| // to a different origin than the URL we actually want to anchor to. | |
| function viserPlaybackUrl(name) { | |
| return new URL( | |
| `${ASSET_BASE}/${encodeURIComponent(name)}/recording.viser`, | |
| location.href | |
| ).href; | |
| } | |
| // Absolute URL of the viser-client iframe's index.html, with the playbackPath | |
| // query already attached. Two non-obvious bits: | |
| // - Anchored to location.href so the iframe loads from the same origin as | |
| // app.js (HF Static Spaces sandbox baseURI to a different origin). | |
| // - Path explicitly ends in /index.html - HF's static-file server does | |
| // NOT auto-serve directory indexes, so requesting "viser-client/" 404s. | |
| function viserClientUrl(name) { | |
| return new URL( | |
| `viser-client/index.html?playbackPath=${encodeURIComponent(viserPlaybackUrl(name))}`, | |
| location.href | |
| ).href; | |
| } | |
| // === YouTube IFrame API integration ========================================= | |
| // YouTube is the master clock - the local <video> elements (depth/mask/flow) | |
| // snap their currentTime to whatever YT reports. This direction is more | |
| // robust than depth-as-master because we can set <video>.currentTime | |
| // instantly and exactly, while seeking YT via the IFrame API costs ~200ms | |
| // of buffering and has ~0.1s precision. | |
| // | |
| // Post clip-trim, depth.mp4 / mask.mp4 / flow.mp4 cover exactly the manifest | |
| // [start_sec, end_sec] window of source content (no padding). depth.mp4 is | |
| // re-encoded at scene.fps, so its wall-clock duration differs from the source | |
| // duration; we recover the rate from the loaded <video>.duration: | |
| // depthRate = depth.duration / (manifestEnd - manifestStart) | |
| // and the per-tick mapping is: | |
| // depth.currentTime = (yt.currentTime - manifestStart) × depthRate | |
| const FOLLOW_DRIFT_S = 0.40; // re-snap depth if drift exceeds this | |
| const FOLLOW_INTERVAL_MS = 250; // poll YT.getCurrentTime this often | |
| let _ytApiReady = null; | |
| function loadYouTubeIframeAPI() { | |
| if (_ytApiReady) return _ytApiReady; | |
| _ytApiReady = new Promise(resolve => { | |
| if (window.YT && window.YT.Player) { resolve(); return; } | |
| const prev = window.onYouTubeIframeAPIReady; | |
| window.onYouTubeIframeAPIReady = () => { if (prev) try { prev(); } catch {} resolve(); }; | |
| const tag = document.createElement('script'); | |
| tag.src = 'https://www.youtube.com/iframe_api'; | |
| tag.async = true; | |
| document.head.appendChild(tag); | |
| }); | |
| return _ytApiReady; | |
| } | |
| async function ensureYouTubePlayer(videoId, manifestStart, manifestEnd) { | |
| await loadYouTubeIframeAPI(); | |
| // depth.mp4 / mask.mp4 / flow.mp4 cover exactly the manifest window. | |
| const startSec = manifestStart; | |
| const endSec = manifestEnd; | |
| state.ytStartSec = startSec; | |
| state.ytEndSec = endSec; | |
| // depthToYtRatio = depth.currentTime per yt content-second; computed | |
| // once <video> metadata is loaded (we need depth.duration). Default to 1 | |
| // so the first sync tick before metadata loads doesn't divide by NaN. | |
| state.depthToYtRatio = 1; | |
| // Reset the stale-value extrapolator so we don't carry the previous | |
| // episode's (lastReportedTime, lastReportedAt) into the new one. | |
| state._ytLastReportedTime = null; | |
| state._ytLastReportedAt = 0; | |
| // Per-episode YT reset + failure watchdog. If the raw clip is private / | |
| // age-restricted / embed-disabled / sign-in-required, it never reaches | |
| // PLAYING; without this, the YT-master sync loop would also pause the local | |
| // depth/mask/flow/robot videos. The watchdog flips to standalone local | |
| // playback (and shows a "watch on YouTube" placeholder) instead. | |
| state.ytVideoId = videoId; | |
| state.ytStarted = false; | |
| _ytClearFallback(); | |
| const epoch = (state._ytEpoch = (state._ytEpoch || 0) + 1); | |
| setTimeout(() => { | |
| if (state._ytEpoch === epoch && !state.ytFallbackActive && !state.ytStarted) { | |
| _ytEnterFallback(); | |
| } | |
| }, 7000); | |
| if (state.ytPlayer && state.ytReady) { | |
| try { | |
| state.ytPlayer.loadVideoById({ | |
| videoId, startSeconds: startSec, endSeconds: endSec, | |
| }); | |
| } catch {} | |
| return; | |
| } | |
| // First load on this page: seed the iframe URL with enablejsapi=1 so the | |
| // YT.Player constructor can attach via postMessage, then create the player. | |
| // Intentionally do NOT include &start/&end in the URL - those round to | |
| // integer seconds and on short clips (< 5s) the URL bounds fight the | |
| // fractional bounds we set via loadVideoById/seekTo, causing the player | |
| // to ENDED+seek+buffer in a tight loop that the user perceives as | |
| // "stuck within the first second" (Squeeze soap, Open wine bottle). | |
| const iframe = document.getElementById('yt-iframe'); | |
| if (!iframe) return; | |
| iframe.src = `https://www.youtube.com/embed/${videoId}` | |
| + `?enablejsapi=1&autoplay=1&mute=1` | |
| + `&controls=1&rel=0&modestbranding=1&playsinline=1` | |
| + `&origin=${encodeURIComponent(location.origin)}`; | |
| state.ytPlayer = new YT.Player('yt-iframe', { | |
| events: { | |
| onReady: (e) => { | |
| state.ytReady = true; | |
| // Set fractional start/end via API (no URL bounds) - this | |
| // is the only way YT honours sub-second precision. | |
| try { | |
| e.target.loadVideoById({ | |
| videoId, startSeconds: state.ytStartSec, endSeconds: state.ytEndSec, | |
| }); | |
| } catch { | |
| try { e.target.playVideo(); } catch {} | |
| } | |
| }, | |
| // YT URL `loop` requires `playlist=`; we handle ENDED manually so the | |
| // segment loops without re-buffering the whole 5-minute source video. | |
| onStateChange: (e) => { | |
| const S = YT.PlayerState; | |
| // PLAYING or BUFFERING both mean the embed is permitted and loading, | |
| // so the 7s "looks blocked" timeout should not fire for a slow start. | |
| if (e.data === S.PLAYING || e.data === S.BUFFERING) state.ytStarted = true; | |
| // Recovery: if a slow start (e.g. sign-in) already tripped the fallback | |
| // but YT is now actually playing, drop the overlay and resume syncing | |
| // the local panels to the YT master clock. | |
| if (e.data === S.PLAYING && state.ytFallbackActive) _ytClearFallback(); | |
| if (e.data === S.ENDED && state.ytPlayer) { | |
| try { | |
| state.ytPlayer.seekTo(state.ytStartSec, true); | |
| state.ytPlayer.playVideo(); | |
| } catch {} | |
| } | |
| }, | |
| // Embedding disabled / private / age-restricted (err 100/101/150) etc. → | |
| // fall back to local-only playback immediately. | |
| onError: () => { _ytEnterFallback(); }, | |
| }, | |
| }); | |
| } | |
| // === YouTube-unavailable fallback =========================================== | |
| // When the raw YouTube clip can't play (private / age-restricted / embedding | |
| // disabled / sign-in required), drive the local mp4s standalone so the | |
| // depth / mask / flow / robot panels still play, and overlay a "watch on | |
| // YouTube" note on the raw pane. | |
| const _LOCAL_VIDEO_IDS = [ | |
| 'detail-depth', 'detail-mask', 'detail-flow', | |
| 'retarget-franka', 'retarget-g1', 'retarget-robonaut2', 'retarget-xlerobot', | |
| ]; | |
| function _ytEnterFallback() { | |
| if (state.ytFallbackActive) return; | |
| state.ytFallbackActive = true; | |
| _showYtPlaceholder(true); | |
| // depth/mask/flow are equal-length (re-encoded to the same window), so just | |
| // loop them on their own clock; depth's `play` cascades to mask/flow. | |
| for (const id of _LOCAL_VIDEO_IDS) { | |
| const v = document.getElementById(id); | |
| if (!v) continue; | |
| v.loop = true; | |
| v.playbackRate = 1; | |
| try { if (Number.isFinite(v.duration)) v.currentTime = 0; } catch {} | |
| v.play().catch(() => {}); | |
| } | |
| } | |
| function _ytClearFallback() { | |
| state.ytFallbackActive = false; | |
| _showYtPlaceholder(false); | |
| // Synced mode drives the local panels off the YT clock; undo the standalone | |
| // loop the fallback set so recovery resumes clean sync. | |
| for (const id of _LOCAL_VIDEO_IDS) { | |
| const v = document.getElementById(id); | |
| if (v) v.loop = false; | |
| } | |
| } | |
| function _showYtPlaceholder(show) { | |
| const iframe = document.getElementById('yt-iframe'); | |
| const frame = iframe && iframe.closest('.sub-frame'); | |
| if (!frame) return; | |
| let ph = frame.querySelector('.yt-fallback-msg'); | |
| if (!show) { if (ph) ph.hidden = true; return; } | |
| if (!ph) { | |
| if (getComputedStyle(frame).position === 'static') frame.style.position = 'relative'; | |
| ph = document.createElement('div'); | |
| ph.className = 'yt-fallback-msg'; | |
| ph.style.cssText = 'position:absolute;inset:0;z-index:2;display:flex;' | |
| + 'flex-direction:column;align-items:center;justify-content:center;gap:.6em;' | |
| + 'padding:1em;text-align:center;background:rgba(0,0,0,.74);color:#ddd;' | |
| + 'font-size:.82rem;line-height:1.4;'; | |
| frame.appendChild(ph); | |
| } | |
| const vid = state.ytVideoId; | |
| const url = vid ? `https://www.youtube.com/watch?v=${encodeURIComponent(vid)}` | |
| : 'https://www.youtube.com'; | |
| ph.innerHTML = | |
| `<div>Raw YouTube clip can't be embedded here<br>(private / age-restricted / sign-in required).</div>` | |
| + `<a href="${url}" target="_blank" rel="noopener" style="color:#7ab7ff;font-weight:600">▶ Watch on YouTube ↗</a>` | |
| + `<div style="opacity:.7">Depth / flow / mask / robot panels play normally.</div>`; | |
| ph.hidden = false; | |
| } | |
| function syncLocalVideosToYouTube() { | |
| if (state.ytFallbackActive) return; // YT unavailable → local videos run standalone | |
| const yt = state.ytPlayer; | |
| if (!state.ytReady || !yt || state.ytStartSec == null) return; | |
| let ytTime, ytState; | |
| try { ytTime = yt.getCurrentTime(); ytState = yt.getPlayerState(); } | |
| catch { return; } | |
| if (!Number.isFinite(ytTime)) return; | |
| const PLAYING = YT.PlayerState.PLAYING, BUFFERING = YT.PlayerState.BUFFERING; | |
| const wantPlaying = ytState === PLAYING || ytState === BUFFERING; | |
| // YT.getCurrentTime() is updated via postMessage at ~1Hz; between updates it | |
| // returns the LAST reported value (stale by up to ~1s). Naively trusting it | |
| // makes the sync snap depth backwards every time the value catches up, | |
| // which the user sees as "depth keeps running ahead". We extrapolate from | |
| // the last fresh report using wall-clock elapsed time, assuming YT plays at | |
| // 1× content rate. The estimate is reset on any reported-value change (a | |
| // jump → loop / scrub / buffering pause). | |
| const wall = performance.now(); | |
| if (state._ytLastReportedTime !== ytTime || !state._ytLastReportedAt) { | |
| state._ytLastReportedTime = ytTime; | |
| state._ytLastReportedAt = wall; | |
| } | |
| let estTime = ytTime; | |
| if (wantPlaying) { | |
| // Cap extrapolation so we don't run away if YT silently buffered. | |
| const elapsedSec = (wall - state._ytLastReportedAt) / 1000; | |
| estTime = ytTime + Math.min(Math.max(elapsedSec, 0), 1.5); | |
| } | |
| const ratio = state.depthToYtRatio || 1; | |
| const target = (estTime - state.ytStartSec) * ratio; | |
| for (const id of ['detail-depth', 'detail-mask', 'detail-flow', | |
| 'retarget-franka', 'retarget-g1', | |
| 'retarget-robonaut2', 'retarget-xlerobot']) { | |
| const v = document.getElementById(id); | |
| if (!v || !Number.isFinite(v.duration)) continue; | |
| // Re-apply playbackRate defensively - some browsers reset it on loop / | |
| // src change / buffering glitches; resetting every tick is harmless. | |
| if (v.playbackRate !== ratio) v.playbackRate = ratio; | |
| const clamped = Math.max(0, Math.min(v.duration, target)); | |
| if (Math.abs(v.currentTime - clamped) > FOLLOW_DRIFT_S) { | |
| try { v.currentTime = clamped; } catch {} | |
| } | |
| if (wantPlaying) { | |
| if (v.paused) v.play().catch(() => {}); | |
| } else if (!v.paused) { | |
| v.pause(); | |
| } | |
| } | |
| } | |
| const RETARGET_ROBOTS = ['franka', 'g1', 'robonaut2', 'xlerobot']; | |
| // Wire the 4-cell retarget row at the bottom of the viewer section. | |
| // Three states (mutually exclusive divs in index.html): | |
| // #retarget-idle - initial, no clip picked | |
| // #retarget-row - clip has retarget (has_retarget=true) → 4 sim videos | |
| // #retarget-empty - clip lacks retarget (2 clips today) → fallback msg | |
| function populateRetargets(ep, sampleBase) { | |
| const row = document.getElementById('retarget-row'); | |
| const empty = document.getElementById('retarget-empty'); | |
| const idle = document.getElementById('retarget-idle'); | |
| if (idle) idle.hidden = true; | |
| if (ep.has_retarget) { | |
| if (row) row.hidden = false; | |
| if (empty) empty.hidden = true; | |
| for (const r of RETARGET_ROBOTS) { | |
| const v = document.getElementById(`retarget-${r}`); | |
| if (!v) continue; | |
| v.src = `${sampleBase}/retarget/${r}/robot_sim.mp4`; | |
| v.load(); | |
| } | |
| } else { | |
| if (row) row.hidden = true; | |
| if (empty) empty.hidden = false; | |
| // Clear stale sources so we don't keep a previous clip's video lingering. | |
| for (const r of RETARGET_ROBOTS) { | |
| const v = document.getElementById(`retarget-${r}`); | |
| if (v) { v.removeAttribute('src'); v.load(); } | |
| } | |
| } | |
| } | |
| const state = { | |
| episodes: [], | |
| current: null, // current episode summary | |
| detail: null, // detail JSON | |
| timeseries: null, // timeseries JSON | |
| filters: { search: '', source: '', hasGrasp: false, hasTracking: false, multiObj: false }, | |
| sort: { key: 'default', dir: 'asc' }, | |
| page: 0, | |
| shuffleSeed: Math.floor(Math.random() * 0xffff_ffff), | |
| view: localStorage.getItem('dsv-view') || 'grid', | |
| theme: localStorage.getItem('dsv-theme') || 'noir', | |
| panel: null, | |
| viser: { port: null, ready: false, current: null, launchToken: 0 }, | |
| }; | |
| const fmtDur = (s) => { | |
| if (s == null) return '-'; | |
| const m = Math.floor(s / 60); | |
| const r = (s - m * 60); | |
| return m > 0 ? `${m}m ${r.toFixed(0)}s` : `${r.toFixed(1)}s`; | |
| }; | |
| document.body.dataset.theme = state.theme; | |
| document.getElementById('theme-btn').addEventListener('click', () => { | |
| const cycle = ['paper', 'noir', 'ink']; | |
| const idx = (cycle.indexOf(state.theme) + 1) % cycle.length; | |
| state.theme = cycle[idx]; | |
| document.body.dataset.theme = state.theme; | |
| localStorage.setItem('dsv-theme', state.theme); | |
| }); | |
| async function fetchJSON(url) { | |
| const r = await fetch(url); | |
| if (!r.ok) throw new Error(`${url}: ${r.status}`); | |
| return r.json(); | |
| } | |
| // ---------- top-level: load episode list, populate everything ---------- | |
| async function init() { | |
| let data; | |
| try { | |
| data = await fetchJSON(INDEX_URL); | |
| } catch (e) { | |
| const sc = document.getElementById('showcase'); | |
| if (sc) sc.innerHTML = `<div class="loading" style="grid-column:1/-1; padding:40px; text-align:center; color: var(--accent)">Failed to load ${INDEX_URL}: ${e.message}</div>`; | |
| return; | |
| } | |
| state.episodes = data.episodes || []; | |
| // hero count (topbar pill was removed) | |
| document.getElementById('hero-count').textContent = String(state.episodes.length); | |
| // hero meta | |
| const totalFrames = state.episodes.reduce((a, e) => a + (e.n_frames || 0), 0); | |
| const totalDur = state.episodes.reduce((a, e) => a + (e.duration_sec || 0), 0); | |
| document.getElementById('hm-eps').textContent = String(state.episodes.length); | |
| document.getElementById('hm-frames').textContent = totalFrames.toLocaleString(); | |
| document.getElementById('hm-dur').textContent = fmtDur(totalDur); | |
| document.getElementById('footer-counts').textContent = `${state.episodes.length} episodes · ${totalFrames.toLocaleString()} frames · ${fmtDur(totalDur)}`; | |
| populateSourceFilter(); | |
| renderSideCliplist(); | |
| applyView(state.view); // calls renderBrowse internally | |
| // hook filters - any change resets to page 1 | |
| const onFilterChange = () => { | |
| state.filters.search = document.getElementById('f-search').value.trim().toLowerCase(); | |
| state.filters.source = document.getElementById('f-source').value; | |
| state.filters.hasGrasp = document.getElementById('f-has-grasp').checked; | |
| state.filters.hasTracking = document.getElementById('f-has-tracking').checked; | |
| state.filters.multiObj = document.getElementById('f-multi-obj').checked; | |
| state.page = 0; | |
| renderBrowse(); | |
| }; | |
| document.getElementById('f-search').addEventListener('input', onFilterChange); | |
| document.getElementById('f-source').addEventListener('change', onFilterChange); | |
| ['f-has-grasp', 'f-has-tracking', 'f-multi-obj'].forEach(id => { | |
| document.getElementById(id).addEventListener('change', onFilterChange); | |
| }); | |
| document.getElementById('f-sort').addEventListener('change', (e) => { | |
| const [key, dir] = e.target.value.split(':'); | |
| state.sort = { key, dir }; | |
| state.page = 0; | |
| renderBrowse(); | |
| }); | |
| document.getElementById('view-grid').addEventListener('click', () => applyView('grid')); | |
| document.getElementById('view-list').addEventListener('click', () => applyView('list')); | |
| document.getElementById('pg-prev').addEventListener('click', () => goToPage(state.page - 1)); | |
| document.getElementById('pg-next').addEventListener('click', () => goToPage(state.page + 1)); | |
| // signals | |
| state.panel = new SignalPanel(); | |
| state.statePanel = new StateTimelinePanel(); | |
| document.getElementById('viser-open').addEventListener('click', () => { | |
| if (!state.current) return; | |
| window.open(viserClientUrl(state.current.name), '_blank'); | |
| }); | |
| // YouTube is the playback master clock - see syncLocalVideosToYouTube | |
| // (a setInterval poll snaps depth/mask/flow to whatever YT reports). | |
| // The depth element still drives: | |
| // - the signal-panel playhead | |
| // - the skeleton overlay | |
| // ...because those are tied to depth's frame index (which now follows YT). | |
| const dd = document.getElementById('detail-depth'); | |
| const df = document.getElementById('detail-flow'); | |
| const dm = document.getElementById('detail-mask'); | |
| const syncFollowers = () => { | |
| if (!dd.duration) return; | |
| for (const v of [df, dm]) { | |
| if (!v || !v.duration) continue; | |
| if (Math.abs(v.currentTime - dd.currentTime) > 0.08) { | |
| try { v.currentTime = dd.currentTime; } catch {} | |
| } | |
| } | |
| }; | |
| const onVideoTime = () => { | |
| if (!state.scene || !dd.duration) return; | |
| const fps = state.scene.fps || 10; | |
| const f = Math.max(0, Math.min(state.scene.stats.n_frames - 1, | |
| Math.round(dd.currentTime * fps))); | |
| state.panel.setPlayheadFrame(f); | |
| if (state.statePanel) state.statePanel.setPlayheadFrame(f); | |
| syncFollowers(); | |
| drawSkeletonOverlay(f); | |
| }; | |
| // requestVideoFrameCallback fires once per video frame (browser-decoded), | |
| // giving us perfectly-synced overlays. timeupdate alone fires only 4-15Hz | |
| // depending on browser, which is why the skeleton lagged behind the depth | |
| // motion. Fall back to a rAF loop if rVFC is unavailable (older browsers). | |
| const useRVFC = typeof dd.requestVideoFrameCallback === 'function'; | |
| if (useRVFC) { | |
| const tick = () => { | |
| onVideoTime(); | |
| dd.requestVideoFrameCallback(tick); | |
| }; | |
| dd.requestVideoFrameCallback(tick); | |
| } else { | |
| let _rafId = null; | |
| const rafLoop = () => { | |
| if (!dd.paused && !dd.ended) onVideoTime(); | |
| _rafId = requestAnimationFrame(rafLoop); | |
| }; | |
| rafLoop(); | |
| } | |
| // Keep timeupdate / seeked / loadedmetadata as fallback triggers for cases | |
| // rVFC misses (initial load before first decode, scrub). | |
| dd.addEventListener('timeupdate', onVideoTime); | |
| dd.addEventListener('seeked', onVideoTime); | |
| dd.addEventListener('loadedmetadata', onVideoTime); | |
| // Once depth.duration is known, compute the per-clip ratio that matches | |
| // YouTube's natural 1× playback rate, and apply it to all three local | |
| // videos. Re-applied on every loadedmetadata since some browsers reset | |
| // playbackRate on load() or src change. | |
| const recomputeRate = () => { | |
| if (!Number.isFinite(dd.duration) || dd.duration <= 0) return; | |
| if (state.ytStartSec == null || state.ytEndSec == null) return; | |
| const ytDur = state.ytEndSec - state.ytStartSec; | |
| if (ytDur <= 0) return; | |
| const rate = dd.duration / ytDur; | |
| state.depthToYtRatio = rate; | |
| for (const v of [dd, df, dm]) { | |
| if (v) v.playbackRate = rate; | |
| } | |
| }; | |
| for (const v of [dd, df, dm]) { | |
| if (v) v.addEventListener('loadedmetadata', recomputeRate); | |
| } | |
| // depth play/pause cascade to mask/flow only - YT is the master and | |
| // controls all four panels via the setInterval below. | |
| dd.addEventListener('play', () => { | |
| df && df.play().catch(() => {}); | |
| dm && dm.play().catch(() => {}); | |
| }); | |
| dd.addEventListener('pause', () => { | |
| df && df.pause(); | |
| dm && dm.pause(); | |
| }); | |
| // YT-as-master poll: snap depth/mask/flow to YT.getCurrentTime() periodically. | |
| if (state._ytFollowTimer) clearInterval(state._ytFollowTimer); | |
| state._ytFollowTimer = setInterval(syncLocalVideosToYouTube, FOLLOW_INTERVAL_MS); | |
| dd.addEventListener('seeking', () => { | |
| if (dd.currentTime < 0.05) { | |
| try { df && (df.currentTime = 0); } catch {} | |
| try { dm && (dm.currentTime = 0); } catch {} | |
| } | |
| }); | |
| // Re-render skeleton when the depth panel changes size (resize / theme) | |
| window.addEventListener('resize', () => { | |
| if (state.skel) drawSkeletonOverlay(state.panel?.frameIdx || 0); | |
| }); | |
| // pick first episode by default (or restore from URL hash) | |
| let initial = state.episodes[0]; | |
| const u = new URL(window.location.href); | |
| const fromUrl = u.searchParams.get('ep'); | |
| if (fromUrl) { | |
| const m = state.episodes.find(e => e.name === fromUrl); | |
| if (m) initial = m; | |
| } | |
| if (initial) await selectEpisode(initial.name); | |
| } | |
| function populateSourceFilter() { | |
| const sel = document.getElementById('f-source'); | |
| const seen = new Set(); | |
| state.episodes.forEach(e => seen.add(e.video_uid)); | |
| [...seen].sort().forEach(uid => { | |
| const opt = document.createElement('option'); | |
| opt.value = uid; opt.textContent = uid; | |
| sel.appendChild(opt); | |
| }); | |
| } | |
| // ---------- view toggle ---------- | |
| function applyView(view) { | |
| const next = (view === 'list') ? 'list' : 'grid'; | |
| if (next !== state.view) state.page = 0; | |
| state.view = next; | |
| localStorage.setItem('dsv-view', state.view); | |
| const root = document.getElementById('browse-content'); | |
| if (root) root.dataset.view = state.view; | |
| document.getElementById('view-grid').setAttribute('aria-pressed', state.view === 'grid'); | |
| document.getElementById('view-list').setAttribute('aria-pressed', state.view === 'list'); | |
| renderBrowse(); | |
| } | |
| function pageSize() { return state.view === 'list' ? 10 : 12; } | |
| function goToPage(p) { | |
| state.page = Math.max(0, p); | |
| renderBrowse(); | |
| } | |
| // ---------- combined render ---------- | |
| function renderBrowse() { | |
| const visible = getVisible(); | |
| const ps = pageSize(); | |
| const totalPages = Math.max(1, Math.ceil(visible.length / ps)); | |
| if (state.page >= totalPages) state.page = totalPages - 1; | |
| const start = state.page * ps; | |
| const slice = visible.slice(start, start + ps); | |
| document.getElementById('visible-count').textContent = | |
| `${visible.length} of ${state.episodes.length}`; | |
| renderStats(visible); | |
| renderShowcase(slice); | |
| renderTable(slice); | |
| renderPagination(visible.length, totalPages, ps); | |
| } | |
| function renderPagination(total, totalPages, ps) { | |
| const info = document.getElementById('pg-info'); | |
| if (total === 0) { | |
| info.textContent = 'no results'; | |
| } else { | |
| const start = state.page * ps + 1; | |
| const end = Math.min(start + ps - 1, total); | |
| info.textContent = `${start}–${end} of ${total}`; | |
| } | |
| document.getElementById('pg-prev').disabled = state.page <= 0; | |
| document.getElementById('pg-next').disabled = state.page >= totalPages - 1; | |
| const wrap = document.getElementById('pg-pages'); | |
| wrap.innerHTML = ''; | |
| const pages = pageNumbersToShow(state.page, totalPages); | |
| pages.forEach(p => { | |
| if (p === '…') { | |
| const e = document.createElement('span'); | |
| e.className = 'pg-ellipsis'; | |
| e.textContent = '…'; | |
| wrap.appendChild(e); | |
| } else { | |
| const b = document.createElement('button'); | |
| b.className = 'pg-page'; | |
| b.textContent = String(p + 1); | |
| if (p === state.page) b.setAttribute('aria-current', 'true'); | |
| b.addEventListener('click', () => goToPage(p)); | |
| wrap.appendChild(b); | |
| } | |
| }); | |
| } | |
| function pageNumbersToShow(cur, total) { | |
| if (total <= 7) return Array.from({ length: total }, (_, i) => i); | |
| const out = [0]; | |
| const left = Math.max(1, cur - 1); | |
| const right = Math.min(total - 2, cur + 1); | |
| if (left > 1) out.push('…'); | |
| for (let i = left; i <= right; i++) out.push(i); | |
| if (right < total - 2) out.push('…'); | |
| out.push(total - 1); | |
| return out; | |
| } | |
| // ---------- showcase grid ---------- | |
| function renderShowcase(eps) { | |
| const sc = document.getElementById('showcase'); | |
| sc.innerHTML = ''; | |
| eps.forEach(e => { | |
| const el = document.createElement('div'); | |
| el.className = 'clip'; | |
| el.dataset.name = e.name; | |
| const objsTxt = (e.objects || []).slice(0, 3).join(', ') + ((e.objects || []).length > 3 ? ` +${e.objects.length - 3}` : ''); | |
| const trustTxt = (e.mean_trust != null) ? `trust ${Math.round(e.mean_trust * 100)}%` : ''; | |
| const graspBadge = e.has_grasp ? `<span class="ov-grasp">grasp</span>` : ''; | |
| el.innerHTML = ` | |
| <img src="${e.thumb_url}" alt="${escapeAttr(e.action_brief)}" loading="lazy" decoding="async" | |
| onerror="this.onerror=null;this.src='${ASSET_BASE}/${encodeURIComponent(e.name)}/thumb.jpg'"> | |
| <video data-src="${e.video_url}" loop muted playsinline preload="none"></video> | |
| <span class="clip-tag">${escapeHtml(e.video_uid.slice(0, 8))}</span> | |
| <div class="clip-label"> | |
| <span>${escapeHtml(e.action_brief || '(unlabeled)')}</span> | |
| <span class="clip-sub">${e.n_frames}f · ${fmtDur(e.duration_sec)} · ${e.n_objects} obj</span> | |
| </div> | |
| <div class="clip-overlay"> | |
| <div class="ov-title">${escapeHtml(e.action_brief || '(unlabeled)')}</div> | |
| <div class="ov-actor">${escapeHtml(truncate(e.actor || '-', 100))}</div> | |
| ${objsTxt ? `<div class="ov-objects">▸ ${escapeHtml(objsTxt)}</div>` : ''} | |
| <div class="ov-meta"> | |
| <span><strong>${e.n_frames}</strong> f</span> | |
| <span class="ov-dot">·</span> | |
| <span><strong>${fmtDur(e.duration_sec)}</strong></span> | |
| <span class="ov-dot">·</span> | |
| <span><strong>${e.n_objects}</strong> obj</span> | |
| ${graspBadge ? `<span class="ov-dot">·</span>${graspBadge}` : ''} | |
| ${trustTxt ? `<span class="ov-dot">·</span><span class="ov-trust">${trustTxt}</span>` : ''} | |
| <span class="ov-dot" style="margin-left:auto"></span> | |
| <span style="opacity:0.55">${escapeHtml(e.video_uid.slice(0, 8))}</span> | |
| </div> | |
| </div> | |
| `; | |
| // Lazy hover-to-play: only fetch the mp4 when user hovers. | |
| const v = el.querySelector('video'); | |
| el.addEventListener('mouseenter', () => { | |
| if (!v.src) v.src = v.dataset.src; | |
| v.play().catch(() => {}); | |
| }); | |
| el.addEventListener('mouseleave', () => { | |
| v.pause(); | |
| try { v.currentTime = 0; } catch {} | |
| }); | |
| el.addEventListener('click', () => { selectEpisode(e.name); scrollToInspect(); }); | |
| sc.appendChild(el); | |
| }); | |
| } | |
| function scrollToInspect() { | |
| const target = document.getElementById('inspect'); | |
| if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| // ---------- stats ---------- | |
| function renderStats(filtered) { | |
| document.getElementById('stat-eps').innerHTML = `${filtered.length}<sub>ep</sub>`; | |
| const dur = filtered.reduce((a, e) => a + (e.duration_sec || 0), 0); | |
| document.getElementById('stat-dur').innerHTML = `${dur.toFixed(1)}<sub>s</sub>`; | |
| const objs = filtered.reduce((a, e) => a + (e.n_objects || 0), 0); | |
| document.getElementById('stat-objs').innerHTML = `${objs}<sub>obj</sub>`; | |
| const grasping = filtered.filter(e => e.has_grasp).length; | |
| const pct = filtered.length ? Math.round(100 * grasping / filtered.length) : 0; | |
| document.getElementById('stat-grasp').innerHTML = `${pct}<sub>%</sub>`; | |
| } | |
| // ---------- filters + sort ---------- | |
| function getVisible() { | |
| const { search, source, hasGrasp, hasTracking, multiObj } = state.filters; | |
| let out = state.episodes.filter(e => { | |
| if (source && e.video_uid !== source) return false; | |
| if (hasGrasp && !e.has_grasp) return false; | |
| if (hasTracking && !(e.mean_trust != null || e.mean_iou != null)) return false; | |
| if (multiObj && (e.n_objects || 0) < 2) return false; | |
| if (search) { | |
| const hay = [e.action_brief, e.actor, ...(e.objects || []), e.video_uid].join(' ').toLowerCase(); | |
| if (!hay.includes(search)) return false; | |
| } | |
| return true; | |
| }); | |
| const { key, dir } = state.sort; | |
| if (key === 'default') { | |
| return shuffleAndDecluster(out, state.shuffleSeed); | |
| } | |
| const sign = dir === 'asc' ? 1 : -1; | |
| const nullLow = sign; // nulls always at the bottom regardless of asc/desc | |
| out.sort((a, b) => { | |
| const va = a[key], vb = b[key]; | |
| if (va == null && vb == null) return 0; | |
| if (va == null) return 1 * nullLow; | |
| if (vb == null) return -1 * nullLow; | |
| if (typeof va === 'string') return sign * va.localeCompare(vb); | |
| return sign * (va < vb ? -1 : va > vb ? 1 : 0); | |
| }); | |
| return out; | |
| } | |
| // Seeded Fisher-Yates + bucket-by-source round-robin merge. | |
| // Same seed → same order (so paginating through doesn't reshuffle on every render). | |
| // Round-robin guarantees no two clips from the same `video_uid` end up adjacent | |
| // (when there are at least 2 distinct sources). | |
| function shuffleAndDecluster(arr, seed) { | |
| let s = (seed >>> 0) || 1; | |
| const rng = () => { | |
| s = (Math.imul(s, 1664525) + 1013904223) >>> 0; | |
| return s / 0x1_0000_0000; | |
| }; | |
| const shuffled = arr.slice(); | |
| for (let i = shuffled.length - 1; i > 0; i--) { | |
| const j = Math.floor(rng() * (i + 1)); | |
| [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; | |
| } | |
| // Bucket by source video. | |
| const buckets = new Map(); | |
| for (const e of shuffled) { | |
| const k = e.video_uid || ''; | |
| if (!buckets.has(k)) buckets.set(k, []); | |
| buckets.get(k).push(e); | |
| } | |
| // Drain biggest buckets first; round-robin one item from each bucket per pass. | |
| const lists = [...buckets.values()].sort((a, b) => b.length - a.length); | |
| const merged = []; | |
| while (lists.some(l => l.length > 0)) { | |
| for (const l of lists) { | |
| if (l.length > 0) merged.push(l.shift()); | |
| } | |
| } | |
| return merged; | |
| } | |
| // ---------- table ---------- | |
| function renderTable(filtered) { | |
| const body = document.getElementById('episodes-body'); | |
| body.innerHTML = ''; | |
| filtered.forEach(e => { | |
| const tr = document.createElement('tr'); | |
| if (state.current && state.current.name === e.name) tr.classList.add('active'); | |
| tr.dataset.name = e.name; | |
| const objsTxt = (e.objects || []).slice(0, 2).join(', ') + ((e.objects || []).length > 2 ? '…' : ''); | |
| tr.innerHTML = ` | |
| <td> | |
| <div class="task-cell"> | |
| <img class="task-thumb" src="${e.thumb_url}" alt="" loading="lazy" decoding="async" | |
| onerror="this.onerror=null;this.src='${ASSET_BASE}/${encodeURIComponent(e.name)}/thumb.jpg'"> | |
| <div class="task-text"> | |
| <div class="task-name">${escapeHtml(e.action_brief || '(unlabeled)')}</div> | |
| <div class="task-scene">${escapeHtml(e.name)}</div> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="mono-cell">${escapeHtml((e.actor || '').slice(0, 36) + ((e.actor || '').length > 36 ? '…' : ''))}</td> | |
| <td class="mono-cell">${escapeHtml(e.video_uid)}</td> | |
| <td class="mono-cell">${e.n_objects} <span style="opacity:0.6">${escapeHtml(objsTxt)}</span></td> | |
| <td class="num-cell">${e.n_frames}</td> | |
| <td class="num-cell">${fmtDur(e.duration_sec)}</td> | |
| <td>${e.has_grasp ? '<span class="badge" style="--badge-color: var(--accent)">grasp</span>' : '<span class="mono-cell" style="opacity:0.5">-</span>'}</td> | |
| `; | |
| tr.addEventListener('click', () => { selectEpisode(e.name); scrollToInspect(); }); | |
| body.appendChild(tr); | |
| }); | |
| } | |
| // ---------- select / load episode ---------- | |
| async function selectEpisode(name) { | |
| const ep = state.episodes.find(e => e.name === name); | |
| if (!ep) return; | |
| state.current = ep; | |
| // update url hash | |
| const u = new URL(window.location.href); | |
| u.searchParams.set('ep', name); | |
| history.replaceState(null, '', u.toString()); | |
| // selected state | |
| document.querySelectorAll('.clip').forEach(c => c.classList.toggle('selected', c.dataset.name === name)); | |
| document.querySelectorAll('#episodes-body tr').forEach(r => r.classList.toggle('active', r.dataset.name === name)); | |
| // viewer chip | |
| document.getElementById('viewer-clip-tag').textContent = name; | |
| const sampleBase = `${ASSET_BASE}/${encodeURIComponent(name)}`; | |
| // detail meta header (everything ep-summary already has) | |
| document.getElementById('d-title').textContent = ep.action_brief || '(unlabeled)'; | |
| document.getElementById('d-id').textContent = `${ep.name} · segment ${ep.segment_id || '-'}`; | |
| document.getElementById('d-dur').textContent = fmtDur(ep.duration_sec); | |
| document.getElementById('d-frames').textContent = String(ep.n_frames); | |
| document.getElementById('d-uid').textContent = ep.video_uid; | |
| document.getElementById('d-start').textContent = ep.start_sec != null ? `${ep.start_sec.toFixed(2)}s` : '-'; | |
| document.getElementById('d-end').textContent = ep.end_sec != null ? `${ep.end_sec.toFixed(2)}s` : '-'; | |
| // viewer side panel | |
| document.getElementById('loaded-title').textContent = ep.action_brief || ep.name; | |
| document.getElementById('loaded-meta').textContent = `${ep.video_uid} · ${ep.n_frames}f · ${ep.n_objects} obj`; | |
| document.getElementById('loaded-objects').innerHTML = ''; | |
| // load full scene.json (for fps + tracking summary) and signals.json | |
| let scene, sig; | |
| try { | |
| [scene, sig] = await Promise.all([ | |
| fetchJSON(`${sampleBase}/scene.json`), | |
| fetchJSON(`${sampleBase}/signals.json`), | |
| ]); | |
| } catch (e) { | |
| document.getElementById('loaded-meta').textContent = `error: ${e.message}`; | |
| return; | |
| } | |
| state.scene = scene; | |
| state.timeseries = sig; | |
| document.getElementById('d-fps').textContent = (scene.fps || 10).toFixed(2); | |
| // Description block: surface the original Action100M annotations from | |
| // scene.json.action100m_metadata. | |
| // - d-desc ← action_detailed (structured action breakdown) | |
| // - d-summary ← summary (multi-sentence narrative) | |
| // - d-source-title ← video_title (original YouTube title) | |
| // Fall back to a synthesized description when the metadata is absent. | |
| const a100m = scene.action100m_metadata || {}; | |
| const desc_el = document.getElementById('d-desc'); | |
| const sum_label = document.getElementById('d-summary-label'); | |
| const sum_el = document.getElementById('d-summary'); | |
| const src_title_el = document.getElementById('d-source-title'); | |
| if (desc_el) { | |
| desc_el.style.whiteSpace = 'pre-wrap'; | |
| if (a100m.action_detailed) { | |
| desc_el.textContent = a100m.action_detailed; | |
| } else if (a100m.summary) { | |
| desc_el.textContent = a100m.summary; | |
| } else { | |
| const objsList = (ep.objects || []).filter(Boolean); | |
| const objsTxt = objsList.length ? objsList.join(', ') : 'no tracked objects'; | |
| desc_el.textContent = | |
| `${ep.action_brief || 'unlabeled action'}. ${ep.actor || 'unknown actor'}. ` + | |
| `Captured at ${ep.start_sec?.toFixed(1) ?? '?'}s to ${ep.end_sec?.toFixed(1) ?? '?'}s of YouTube ` + | |
| `video ${ep.video_uid}; ${ep.n_frames} frames over ${(ep.duration_sec || 0).toFixed(1)}s. ` + | |
| `Tracked objects: ${objsTxt}.`; | |
| } | |
| } | |
| if (sum_el && sum_label) { | |
| sum_el.style.whiteSpace = 'pre-wrap'; | |
| const descText = desc_el ? desc_el.textContent : ''; | |
| if (a100m.summary && a100m.summary !== descText) { | |
| sum_el.textContent = a100m.summary; | |
| sum_label.style.display = ''; | |
| sum_el.style.display = ''; | |
| } else { | |
| sum_label.style.display = 'none'; | |
| sum_el.style.display = 'none'; | |
| } | |
| } | |
| if (src_title_el) { | |
| if (a100m.video_title) { | |
| src_title_el.textContent = `from “${a100m.video_title}”`; | |
| src_title_el.style.display = ''; | |
| } else { | |
| src_title_el.textContent = ''; | |
| src_title_el.style.display = 'none'; | |
| } | |
| } | |
| renderSceneObjects(scene); | |
| renderTrackingSummary(scene); | |
| state.panel.setData(sig); | |
| state.panel.setPlayheadFrame(0); | |
| state.statePanel.setData(sig, scene); | |
| state.statePanel.setPlayheadFrame(0); | |
| // Inspect 4-up: | |
| // panel 1 = YouTube iframe (raw original, served by YouTube) | |
| // panel 2 = mask.mp4 (SAM-tracked region highlighted, rest dimmed) | |
| // panel 3 = depth.mp4 + skeleton overlay (canvas) | |
| // panel 4 = flow.mp4 | |
| const dd = document.getElementById('detail-depth'); | |
| const df = document.getElementById('detail-flow'); | |
| const dm = document.getElementById('detail-mask'); | |
| // YouTube iframe - set up FIRST so state.ytStartSec/ytEndSec are populated | |
| // before depth's `loadedmetadata` event fires (recomputeRate reads them). | |
| if (ep.video_uid) { | |
| const startSec = ep.start_sec || 0; | |
| const endSec = ep.end_sec || (startSec + (ep.duration_sec || 4)); | |
| ensureYouTubePlayer(ep.video_uid, startSec, endSec); | |
| } | |
| // Local videos - playbackRate is set by recomputeRate on loadedmetadata | |
| // (per-clip ratio = depth.duration / yt_window_duration). | |
| if (dd) { dd.src = `${sampleBase}/depth.mp4`; dd.load(); } | |
| if (df) { df.src = `${sampleBase}/flow.mp4`; df.load(); } | |
| if (dm) { dm.src = `${sampleBase}/mask.mp4`; dm.load(); } | |
| // Robot-embodiment retargets row: 4 sim replay videos, synced to YouTube | |
| // the same way depth/mask/flow are. `has_retarget=false` (2 clips today) | |
| // → show fallback message instead of loading. `has_retarget=true` → | |
| // unhide the row and point each <video>.src at samples/<clip>/retarget/<robot>/robot_sim.mp4. | |
| populateRetargets(ep, sampleBase); | |
| // Load hand_joints.bin for skeleton overlay | |
| await loadSkeletonOverlay(scene, sampleBase); | |
| // 3D viewer = official viser client (viser-build-client output) loading | |
| // the per-sample .viser binary. We use an absolute URL anchored to the | |
| // page's location to avoid HF Static Space's sandbox baseURI quirks. | |
| const iframe = document.getElementById('viser-iframe'); | |
| iframe.src = viserClientUrl(name); | |
| document.getElementById('viser-state').textContent = 'loaded'; | |
| const overlay = document.getElementById('viser-overlay'); | |
| if (overlay) overlay.style.display = 'none'; | |
| updateSideCliplistActive(name); | |
| } | |
| // ---------- skeleton overlay on depth video (perspective projection) ---------- | |
| const HAND_EDGES = [ | |
| [0,1],[1,2],[2,3],[3,4], | |
| [0,5],[5,6],[6,7],[7,8], | |
| [0,9],[9,10],[10,11],[11,12], | |
| [0,13],[13,14],[14,15],[15,16], | |
| [0,17],[17,18],[18,19],[19,20], | |
| ]; | |
| const FINGER_HEX = ['#d92e33', '#f28c19', '#33a640', '#d9bf0d', '#2e73d9']; | |
| async function loadSkeletonOverlay(scene, sampleBase) { | |
| state.skel = null; | |
| const cam = scene.camera || {}; | |
| const focal = cam.focal, cx = cam.cx, cy = cam.cy; | |
| const W = cam.width, H = cam.height; | |
| const r = scene.reconstruction || {}; | |
| if (!r.hand_joints || !W || !H) return; | |
| const T = scene.stats.n_frames; | |
| try { | |
| // Prefer WiLoR-native 2D joints (MoGe-2-independent, image-pixel | |
| // space). Fall back to projecting 3D with the depth-camera focal, | |
| // which can be off when MoGe-2's per-frame focal is unreliable. | |
| if (r.hand_joints_2d) { | |
| const buf = await (await fetch(`${sampleBase}/${r.hand_joints_2d}`)).arrayBuffer(); | |
| state.skel = { | |
| mode: '2d', | |
| joints2d: new Float32Array(buf), // (T, 2, 21, 2) | |
| T, H_max: 2, J: 21, | |
| srcW: W, srcH: H, | |
| }; | |
| } else { | |
| const buf = await (await fetch(`${sampleBase}/${r.hand_joints}`)).arrayBuffer(); | |
| state.skel = { | |
| mode: '3d', | |
| joints: new Float32Array(buf), // (T, 2, 21, 3) | |
| T, H_max: 2, J: 21, | |
| focal, cx, cy, srcW: W, srcH: H, | |
| }; | |
| } | |
| } catch (e) { | |
| console.warn('skeleton overlay load failed', e); | |
| } | |
| drawSkeletonOverlay(0); | |
| } | |
| function drawSkeletonOverlay(frameIdx) { | |
| const canvas = document.getElementById('detail-skeleton'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| const s = state.skel; | |
| if (!s) { ctx.clearRect(0, 0, canvas.width, canvas.height); return; } | |
| // Match canvas size to the mask video element's display size | |
| const video = document.getElementById('detail-mask'); | |
| const rect = video.getBoundingClientRect(); | |
| if (canvas.width !== rect.width || canvas.height !== rect.height) { | |
| canvas.width = rect.width; | |
| canvas.height = rect.height; | |
| } | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| const t = Math.max(0, Math.min(s.T - 1, frameIdx | 0)); | |
| const is2d = s.mode === '2d'; | |
| const compsPerJoint = is2d ? 2 : 3; | |
| const stride21 = s.J * compsPerJoint, strideH = stride21 * s.H_max; | |
| const buf = is2d ? s.joints2d : s.joints; | |
| // Source-pixel → display-pixel scale (object-fit:cover) | |
| const dispW = canvas.width, dispH = canvas.height; | |
| const srcAspect = s.srcW / s.srcH; | |
| const dispAspect = dispW / dispH; | |
| let scale, offX, offY; | |
| if (dispAspect > srcAspect) { | |
| scale = dispW / s.srcW; | |
| offX = 0; | |
| offY = (dispH - s.srcH * scale) / 2; | |
| } else { | |
| scale = dispH / s.srcH; | |
| offX = (dispW - s.srcW * scale) / 2; | |
| offY = 0; | |
| } | |
| for (let h = 0; h < s.H_max; h++) { | |
| const off = t * strideH + h * stride21; | |
| if (!Number.isFinite(buf[off])) continue; | |
| const px = new Array(s.J), py = new Array(s.J); | |
| for (let j = 0; j < s.J; j++) { | |
| if (is2d) { | |
| const u = buf[off + j*2 + 0]; | |
| const v = buf[off + j*2 + 1]; | |
| if (!Number.isFinite(u) || !Number.isFinite(v)) { | |
| px[j] = py[j] = NaN; continue; | |
| } | |
| px[j] = u * scale + offX; | |
| py[j] = v * scale + offY; | |
| } else { | |
| const X = buf[off + j*3 + 0]; | |
| const Y = buf[off + j*3 + 1]; | |
| const Z = buf[off + j*3 + 2]; | |
| if (Z <= 0) { px[j] = py[j] = NaN; continue; } | |
| const u = s.focal * X / Z + s.cx; | |
| const v = s.focal * Y / Z + s.cy; | |
| px[j] = u * scale + offX; | |
| py[j] = v * scale + offY; | |
| } | |
| } | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| // Pass 1: dark halo behind every bone for legibility on warm depth bg | |
| ctx.strokeStyle = 'rgba(0,0,0,0.55)'; | |
| ctx.lineWidth = 6; | |
| HAND_EDGES.forEach(([a, b]) => { | |
| if (!Number.isFinite(px[a]) || !Number.isFinite(px[b])) return; | |
| ctx.beginPath(); | |
| ctx.moveTo(px[a], py[a]); | |
| ctx.lineTo(px[b], py[b]); | |
| ctx.stroke(); | |
| }); | |
| // Pass 2: finger-coloured bones on top | |
| ctx.lineWidth = 3.5; | |
| HAND_EDGES.forEach(([a, b], ei) => { | |
| if (!Number.isFinite(px[a]) || !Number.isFinite(px[b])) return; | |
| ctx.strokeStyle = FINGER_HEX[(ei / 4) | 0]; | |
| ctx.beginPath(); | |
| ctx.moveTo(px[a], py[a]); | |
| ctx.lineTo(px[b], py[b]); | |
| ctx.stroke(); | |
| }); | |
| // Joint dots: small, just at junctions | |
| ctx.fillStyle = 'white'; | |
| ctx.strokeStyle = 'rgba(0,0,0,0.7)'; | |
| ctx.lineWidth = 1.2; | |
| for (let j = 0; j < s.J; j++) { | |
| if (!Number.isFinite(px[j])) continue; | |
| ctx.beginPath(); | |
| ctx.arc(px[j], py[j], 2.0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| // ---------- objects + tracking summary (rendered from scene.json) ---------- | |
| function renderSceneObjects(scene) { | |
| const objs = (scene.reconstruction && scene.reconstruction.objects) || []; | |
| const trackSummary = scene.tracking_summary || {}; | |
| const detail = { objects: objs.map(o => ({ | |
| id: o.id, prompt: o.prompt, color_hex: o.color_hex, n_points: o.n_points, | |
| tracking: trackSummary[String(o.id)] || trackSummary[o.id] || {}, | |
| })) }; | |
| // viewer side per-object list | |
| const wrap = document.getElementById('loaded-objects'); | |
| wrap.innerHTML = ''; | |
| detail.objects.forEach(o => { | |
| const t = o.tracking || {}; | |
| const row = document.createElement('div'); | |
| row.className = 'obj-row'; | |
| const fitness = (t.anchor_fitness != null) ? t.anchor_fitness.toFixed(2) : '-'; | |
| const tag = t.path || '-'; | |
| row.innerHTML = ` | |
| <span class="obj-swatch" style="background:${o.color_hex}"></span> | |
| <span class="obj-prompt" title="${escapeAttr(o.prompt)}">${escapeHtml(truncate(o.prompt, 28))}</span> | |
| <span class="obj-stat">${o.n_points ? Math.round(o.n_points / 1000) + 'k pts' : '-'}</span> | |
| <span class="obj-tag" title="anchor fitness ${fitness}">${tag}</span> | |
| `; | |
| wrap.appendChild(row); | |
| }); | |
| // inspect side object pills | |
| const olist = document.getElementById('d-objects'); | |
| olist.innerHTML = ''; | |
| detail.objects.forEach(o => { | |
| const span = document.createElement('span'); | |
| span.className = 'object-pill'; | |
| span.style.setProperty('--obj-color', o.color_hex); | |
| span.textContent = o.prompt || `obj ${o.id}`; | |
| olist.appendChild(span); | |
| }); | |
| } | |
| function renderTrackingSummary(scene) { | |
| const wrap = document.getElementById('d-tracking'); | |
| wrap.innerHTML = ''; | |
| const objs = (scene.reconstruction && scene.reconstruction.objects) || []; | |
| const trackSummary = scene.tracking_summary || {}; | |
| const lines = objs.map(o => { | |
| const t = trackSummary[String(o.id)] || trackSummary[o.id] || {}; | |
| // state breakdown (5-state schema collapses grasped_{l,r,both} → grasped) | |
| let stateStr; | |
| const sf = t.state_fractions; | |
| if (sf) { | |
| const pct = (v) => `${Math.round((v || 0) * 100)}%`; | |
| const globalTag = t.is_static_global ? ' GLOBAL' : ''; | |
| stateStr = `s${pct(sf.static)}/g${pct(sf.grasped)}/m${pct(sf.moving)}${globalTag}`; | |
| } else { | |
| stateStr = '-'; | |
| } | |
| // dominant hand (-, L, R, both) | |
| const hand = t.dominant_hand || '-'; | |
| // mesh: N pts + longest axis in cm | |
| let meshStr = '-'; | |
| if (t.mesh_n_pts != null || t.mesh_extent_cm != null) { | |
| const ptsPart = t.mesh_n_pts != null | |
| ? (t.mesh_n_pts >= 1000 | |
| ? `${(t.mesh_n_pts / 1000).toFixed(t.mesh_n_pts >= 100000 ? 0 : 1)}k pts` | |
| : `${t.mesh_n_pts} pts`) | |
| : '-'; | |
| const cmPart = t.mesh_extent_cm != null | |
| ? `${Number(t.mesh_extent_cm).toFixed(0)}cm` | |
| : '-'; | |
| meshStr = `${ptsPart}, ${cmPart}`; | |
| } | |
| return `<div style="padding:4px 0;border-top:1px solid var(--rule)"><span style="color:${o.color_hex};font-weight:600">obj ${o.id}</span> · state=${stateStr} · hand=${hand} · mesh=${meshStr}</div>`; | |
| }); | |
| wrap.innerHTML = lines.join(''); | |
| } | |
| // ---------- viewer-side cliplist (quick clip switcher beside the 3D viewer) ---------- | |
| function renderSideCliplist() { | |
| const wrap = document.getElementById('side-cliplist'); | |
| if (!wrap) return; | |
| // Use the same shuffled+declustered order as Browse default so the panel | |
| // feels stable across navigations but still mixes sources. | |
| const eps = shuffleAndDecluster(state.episodes, state.shuffleSeed); | |
| const cur = state.current && state.current.name; | |
| wrap.innerHTML = ''; | |
| eps.forEach(e => { | |
| const row = document.createElement('div'); | |
| row.className = 'side-clip'; | |
| row.dataset.name = e.name; | |
| if (cur === e.name) row.classList.add('active'); | |
| row.innerHTML = ` | |
| <img class="side-clip-thumb" src="${e.thumb_url}" alt="" loading="lazy" decoding="async" | |
| onerror="this.onerror=null;this.src='${ASSET_BASE}/${encodeURIComponent(e.name)}/thumb.jpg'"> | |
| <div class="side-clip-text"> | |
| <div class="side-clip-title">${escapeHtml(e.action_brief || '(unlabeled)')}</div> | |
| <div class="side-clip-id">${escapeHtml(e.name)}</div> | |
| </div>`; | |
| // Click stays in the 3D viewer - do NOT call scrollToInspect() | |
| row.addEventListener('click', () => selectEpisode(e.name)); | |
| wrap.appendChild(row); | |
| }); | |
| } | |
| function updateSideCliplistActive(name) { | |
| const wrap = document.getElementById('side-cliplist'); | |
| if (!wrap) return; | |
| let activated = null; | |
| wrap.querySelectorAll('.side-clip').forEach(r => { | |
| const on = r.dataset.name === name; | |
| r.classList.toggle('active', on); | |
| if (on) activated = r; | |
| }); | |
| if (activated) activated.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); | |
| } | |
| // ---------- helpers ---------- | |
| function escapeHtml(s) { | |
| if (s == null) return ''; | |
| return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); | |
| } | |
| function escapeAttr(s) { return escapeHtml(s); } | |
| function truncate(s, n) { s = s || ''; return s.length > n ? s.slice(0, n - 1) + '…' : s; } | |
| init().catch(e => { | |
| console.error(e); | |
| const sc = document.getElementById('showcase'); | |
| if (sc) sc.innerHTML = `<div class="loading" style="grid-column:1/-1; padding:40px; text-align:center; color:var(--accent)">init failed: ${e.message}</div>`; | |
| }); | |