EgoInfinity / js /app.js
VectorW's picture
Initial commit
66d097c
Raw
History Blame Contribute Delete
49.7 kB
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>`;
});