SampleViewer / egoSample /jizhi.html
faradayfuture's picture
egoSample: add Jizhi Stereo photo keychain crafting (Creative Workshops)
e3d65b7 verified
Raw History Blame Contribute Delete
35 kB
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jizhi Stereo Ego Viewer</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:#07090d;color:#e6e8ee;font:14px/1.5 -apple-system,"Helvetica Neue",Arial,sans-serif}
a{color:#6f92c8}
.wrap{display:flex;height:100vh;overflow:hidden}
.main{flex:1;overflow-y:auto}
header{padding:14px 20px 10px}
header h1{margin:0 0 3px;font-size:19px}
.sub{color:#8890a0;font-size:12.5px}.sub code{color:#c7ccd6;background:#12151c;padding:1px 5px;border-radius:4px}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
.bdg{background:#12151c;border:1px solid #1c2028;border-radius:999px;padding:2px 9px;font-size:11px;color:#9aa1ad}
.bdg b{color:#e6e8ee}
.bdg.tag{color:#07090d;background:#6f9fe0;border-color:#6f9fe0;font-weight:700}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;padding:0 20px}
@media(max-width:1360px){.layout{grid-template-columns:1fr}}
.panel{background:#0e1118;border:1px solid #1c2028;border-radius:8px}
.vgrid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px}
@media(max-width:700px){.vgrid{grid-template-columns:1fr}}
.vp{position:relative;background:#000;border-radius:4px;overflow:hidden;min-width:0;aspect-ratio:16/9}
.vp video{width:100%;height:100%;object-fit:contain;display:block}
.vp .lbl{position:absolute;top:5px;left:6px;font:10.5px ui-monospace,monospace;color:#bcd2e6;background:rgba(0,0,0,.45);padding:1px 5px;border-radius:3px;pointer-events:none}
.layout > .panel:first-child{display:flex;flex-direction:column}
.three{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 8px 8px;flex:1}
@media(max-width:700px){.three{grid-template-columns:1fr}}
.cbox{position:relative;background:#111116;border:1px solid #1c2028;border-radius:6px;min-height:clamp(150px,22vh,220px)}
.cbox canvas{position:absolute;inset:0}
.cbox canvas{width:100%;height:100%;display:block;cursor:grab}
.cbox .lbl{position:absolute;z-index:1;top:6px;left:9px;font:12px ui-monospace,monospace;color:#9aa1ad;pointer-events:none}
.cbox .hint{position:absolute;z-index:1;bottom:6px;right:9px;font-size:10px;color:#556;pointer-events:none}
.spans-head{padding:12px 14px 8px;font-size:12px;letter-spacing:.04em;color:#9aa1ad;text-transform:uppercase}
.spans-head b{color:#6f9fe0}
.tl{height:9px;border-radius:4px;margin:0 14px 6px;display:flex;overflow:hidden;cursor:pointer;background:#12151c}
.tl i{height:100%}
.chips{display:flex;flex-wrap:wrap;gap:5px;padding:0 14px 10px;max-height:150px;overflow-y:auto}
.chip{min-width:24px;height:21px;padding:0 5px;display:flex;align-items:center;justify-content:center;border-radius:5px;font-size:10.5px;font-weight:700;cursor:pointer;color:#07090d}
.chip.on{outline:2px solid #fff}
.slist{max-height:430px;overflow-y:auto}
.si{border-left:3px solid transparent;border-radius:4px;padding:7px 10px;margin:0 10px 5px;cursor:pointer;background:#12151c}
.si:hover{background:#171b24}.si.on{background:#182339;border-left-color:#6f9fe0}
.si .n{display:inline-flex;min-width:17px;height:17px;padding:0 3px;border-radius:9px;color:#07090d;font-size:10px;font-weight:700;align-items:center;justify-content:center;margin-right:6px}
.si .k{font-size:13px}.si .m{font-size:10.5px;color:#6f92c8;font-family:ui-monospace,monospace;margin-top:2px}
.si .lb{font-size:11px;color:#8890a0}
.controls{display:flex;align-items:center;gap:12px;padding:9px 16px;margin:14px 20px 0}
.controls button{background:#1b2740;color:#cfe0ff;border:1px solid #2b3654;border-radius:6px;padding:5px 13px;cursor:pointer;font-size:15px}
.controls input[type=range]{flex:1;min-width:0}
.controls .t{font-variant-numeric:tabular-nums;color:#c7ccd6;font-size:13px;white-space:nowrap}
.controls select{background:#12151c;color:#c7ccd6;border:1px solid #2b3654;border-radius:5px;font-size:12px;padding:3px 4px}
.readout{margin:14px 20px 0;padding:13px 18px}
.rt{font-size:11px;letter-spacing:.04em;color:#9aa1ad;text-transform:uppercase;margin-bottom:9px}
.rg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px 26px}
@media(max-width:900px){.rg{grid-template-columns:1fr 1fr}}
.ro{display:flex;justify-content:space-between;border-bottom:1px dashed #1c2028;padding-bottom:4px;gap:8px}
.ro .k{color:#9aa1ad}.ro .v{font-variant-numeric:tabular-nums;text-align:right}.ro .v.hi{color:#6fe08a}.ro .v.b{color:#7fb2ff}
.charts{margin:12px 20px 0;display:grid;gap:8px}
.chp{padding:6px 14px 8px}
.chp .l{font-size:11px;color:#9aa1ad}.chp .u{color:#5a6373;font-size:10px;margin-left:6px}
.chp canvas{width:100%;height:76px;display:block;cursor:pointer}
.leg{display:inline-flex;gap:9px;margin-left:8px}
.leg i{width:9px;height:9px;border-radius:2px;display:inline-block;margin-right:3px;vertical-align:-1px}
.legend{display:flex;flex-wrap:wrap;gap:6px 12px;padding:10px 20px 0;color:#8890a0;font-size:11.5px}
.legend .it{display:flex;align-items:center;gap:5px}
.sw{width:11px;height:11px;border-radius:3px;display:inline-block}
.files{display:flex;flex-wrap:wrap;gap:7px}
.fc{background:#12151c;border:1px solid #1c2028;border-radius:5px;padding:4px 9px;font:11.5px ui-monospace,monospace;color:#c7ccd6;text-decoration:none;display:inline-block}
.fc.dl{color:#7fe0a0;border-color:#24402f}
.fc.dl:hover{background:#16211a;border-color:#3d9970}
.note{margin:10px 20px 0;padding:9px 14px;font-size:12px;color:#9aa1ad;border-left:3px solid #b8860b;background:#14130c;border-radius:4px}
.note b{color:#d9c48a}
</style></head>
<body>
<div class="wrap">
<div class="main" id="main"></div>
</div>
<script id="D" type="application/json">{"eps": [{"slug": "craft_20260629_sticker_ep000", "task": "craft_20260629_sticker", "ep": 0, "task_name": "Photo keychain crafting station", "instruction": "Trim photo stickers, mount them in acrylic keychain frames and round badge cases, and attach key rings", "duration": 79.0, "nframes": 2370, "fps": 30, "session": "session_00017", "location": "irvine", "device": "aef452889295f829", "hw": "Jizhi Stereo", "taxo": {"environment_l1": "Creative Workshops", "environment_l2": "Handcraft Workshop", "environment_l3": "Craft Station", "skill_group": "Assembly & Installation", "job_family": "Assembler", "task_difficulty": "Medium", "naics_primary_code": "339999", "naics_primary_title": "All Other Miscellaneous Manufacturing", "taxonomy_source": "OpenAI Robotics Data Requirements 08-21-2026 (Environment/Skill Group/Jobs taxonomies)"}, "region": "US", "tags": ["US"], "n_segments": 26, "skills": {"Place": 9, "Hang": 7, "Pick": 3, "Insert": 3, "Tear": 2, "Straighten": 1, "Pull": 1}, "skill_list": ["Hang", "Insert", "Pick", "Place", "Pull", "Straighten", "Tear"], "qc": {"status": "pass", "total": 10, "passed": 10, "warnings": 0, "failures": 0}, "state_dim": 18, "codebase_version": "v3.0", "robot_type": "ego_stereo", "imu_valid": 1.0, "measured_fps": 29.94, "segments": [{"skill": "Tear", "label": "Both hands peel the backing paper off a round photo sticker", "ts": 0.0, "te": 3.5, "mistake": false}, {"skill": "Pick", "label": "The right hand picks up a round badge case from the right side of the table", "ts": 3.5, "te": 5.75, "mistake": false}, {"skill": "Insert", "label": "Both hands insert the round photo into the badge case", "ts": 5.75, "te": 10.0, "mistake": false}, {"skill": "Tear", "label": "Both hands peel the yellow backing paper off the next round photo sticker", "ts": 10.0, "te": 20.5, "mistake": false}, {"skill": "Insert", "label": "Both hands fit the round photo into the badge case and press it closed", "ts": 20.5, "te": 22.75, "mistake": false}, {"skill": "Place", "label": "The right hand places the finished round badge on the table and picks up another badge case", "ts": 22.75, "te": 24.0, "mistake": false}, {"skill": "Insert", "label": "Both hands press the round photo into the badge case", "ts": 24.0, "te": 29.25, "mistake": false}, {"skill": "Place", "label": "The right hand places the finished round badge at the top of the table", "ts": 29.25, "te": 30.5, "mistake": false}, {"skill": "Pick", "label": "Both hands pick up a finished square acrylic keychain frame from the top row", "ts": 30.5, "te": 32.25, "mistake": false}, {"skill": "Straighten", "label": "Both hands inspect and square up the acrylic keychain frame", "ts": 32.25, "te": 35.0, "mistake": false}, {"skill": "Place", "label": "Both hands set the keychain frames down in a row", "ts": 35.0, "te": 36.0, "mistake": false}, {"skill": "Hang", "label": "Both hands thread a key ring onto a square acrylic keychain frame", "ts": 36.0, "te": 41.75, "mistake": false}, {"skill": "Place", "label": "The left hand places the finished keychain at the top of the table", "ts": 41.75, "te": 43.0, "mistake": false}, {"skill": "Hang", "label": "Both hands open a key ring and attach it to the next keychain frame", "ts": 43.0, "te": 47.25, "mistake": false}, {"skill": "Pull", "label": "The right hand pulls the key ring to seat it on the frame", "ts": 47.25, "te": 49.0, "mistake": false}, {"skill": "Hang", "label": "Both hands thread the key ring through the keychain frame", "ts": 49.0, "te": 51.25, "mistake": false}, {"skill": "Place", "label": "The right hand places the finished keychain at the top right and picks up the next frame", "ts": 51.25, "te": 53.0, "mistake": false}, {"skill": "Hang", "label": "Both hands attach a key ring to the next acrylic keychain frame", "ts": 53.0, "te": 57.25, "mistake": false}, {"skill": "Place", "label": "The left hand places the finished keychain at the top of the table", "ts": 57.25, "te": 58.5, "mistake": false}, {"skill": "Hang", "label": "Both hands thread a key ring onto another keychain frame", "ts": 58.5, "te": 63.75, "mistake": false}, {"skill": "Pick", "label": "The right hand picks up a key ring from the right side of the table", "ts": 63.75, "te": 66.0, "mistake": false}, {"skill": "Hang", "label": "Both hands attach the key ring to the keychain frame", "ts": 66.0, "te": 67.75, "mistake": false}, {"skill": "Place", "label": "The right hand places the finished keychain at the top right of the table", "ts": 67.75, "te": 69.75, "mistake": false}, {"skill": "Hang", "label": "Both hands thread a key ring onto the last keychain frame", "ts": 69.75, "te": 75.0, "mistake": false}, {"skill": "Place", "label": "The right hand places the finished keychain at the top of the table", "ts": 75.0, "te": 76.25, "mistake": false}, {"skill": "Place", "label": "The right hand arranges the finished keychains at the top right of the table", "ts": 76.25, "te": 79.0, "mistake": false}]}], "skc": {"Clip": "#4f83cc", "Close": "#e0913a", "CloseBox": "#5aa469", "Cut": "#c0504d", "Dip": "#8064a2", "Drop": "#4bacc6", "Flip": "#d98594", "Fold": "#9b8f2e", "HandOver": "#7f8fa6", "Hang": "#674ea7", "Insert": "#3d9970", "Knead": "#b8860b", "Open": "#a0522d", "Paint": "#2e8b8b", "Pick": "#b5651d", "Place": "#6a5acd", "Plug": "#556b2f", "Pour": "#8b3a62", "Press": "#487eb0", "PressButton": "#cc8e35", "Pull": "#6b8e23", "Rinse": "#9370db", "Rotate": "#20b2aa", "Scan": "#cd853f", "Scoop": "#4f83cc", "Screw (tighten)": "#e0913a", "Smear": "#5aa469", "Stack": "#c0504d", "Stir": "#8064a2", "Straighten": "#4bacc6", "Takedown": "#d98594", "Takeout (remove)": "#9b8f2e", "Tear": "#7f8fa6", "Wipe": "#674ea7", "dip": "#3d9970", "plug": "#b8860b"}, "hf": "https://huggingface.co/datasets/faradayfuture/egoSample"}</script>
<script>
const D = JSON.parse(document.getElementById('D').textContent);
const EPS = D.eps, SKC = D.skc, HF = D.hf;
const CAMS = ["head_stereo_left","head_stereo_right"];
const AX = {x:"#e0616f", y:"#41c76b", z:"#4f8ff7"};
const AXR = ['#ff0000','#00ff00','#0000ff'];
let cur=-1, vids=[], master=null, playing=false, EP=null, SIG=null, raf=0, syncTimer=0, _toggle=null, _lastCi=-2;
let orbO={yaw:0.6,pitch:-0.45,drag:0};
const esc = s => (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const fmt = t => { t=Math.max(0,t); const m=Math.floor(t/60), s=t%60; return m+":"+s.toFixed(2).padStart(5,'0'); };
const skc = s => SKC[s]||'#666';
/* ---------- episode select (hash = slug) ---------- */
function select(i){
if(i===cur) return;
cur=i; EP=EPS[i]; SIG=null;
cancelAnimationFrame(raf); clearInterval(syncTimer); syncTimer=0; playing=false;
document.getElementById('main').scrollTop=0;
render(); loadSig(EP.slug);
}
function loadSig(slug){
const prev=document.getElementById('sigjs'); if(prev) prev.remove();
window.EPDATA=window.EPDATA||{};
const done=()=>{ SIG=window.EPDATA[slug]; if(!SIG) return; SIG._fw=null; sizeCanvases(); drawCharts();
updateUI(master?master.currentTime:0);
const go=()=>{ if(_toggle && !playing) _toggle(); };
if(!master){} else if(master.readyState>=3) go(); else master.addEventListener('canplay',go,{once:true}); };
if(window.EPDATA[slug]){ done(); return; }
const s=document.createElement('script'); s.id='sigjs'; s.src='epdata/'+slug+'.js';
s.onload=done; s.onerror=()=>{ SIG=null; }; document.body.appendChild(s);
}
/* ---------- render ---------- */
function render(){
_lastCi=-2;
const e=EP, dur=e.duration, segs=e.segments;
const tile=k=>`<div class="vp"><video data-k="${k}" src="media/${e.slug}/${CAMS[k]}.mp4" muted playsinline preload="auto"></video><span class="lbl">${CAMS[k]}</span></div>`;
const chips = segs.map((s,idx)=>`<div class="chip" data-i="${idx}" style="background:${skc(s.skill)}" title="${esc(s.skill)} — ${esc(s.label)}">${idx+1}</div>`).join('');
const slist = segs.map((s,idx)=>`
<div class="si" data-i="${idx}">
<div><span class="n" style="background:${skc(s.skill)}">${idx+1}</span><span class="k">${esc(s.skill)}</span>
<span class="lb"> · ${fmt(s.ts)}–${fmt(s.te)}</span></div>
<div class="m">${esc(s.label)}</div>
</div>`).join('');
const legend = Object.keys(e.skills).sort().map(s=>`<span class="it"><span class="sw" style="background:${skc(s)}"></span>${esc(s)} <b style="color:#e6e8ee">${e.skills[s]}</b></span>`).join('');
const HFDIR = HF+'/tree/main/jizhi_'+e.slug;
const raw = f => HF+'/resolve/main/jizhi_'+e.slug+'/'+f;
const camDl = CAMS.map(c=>`<a class="fc dl" href="media/${e.slug}/${c}.mp4" download="${e.slug}_${c}.mp4">${c}.mp4</a>`).join('');
const refChips = ['data/file-000.parquet','meta/ff/segments.parquet','meta/ff/episode_meta.parquet',
'meta/ff/camera_params.json','meta/ff/quality.json','meta/info.json']
.map(f=>`<a class="fc dl" href="${raw(f)}" target="_blank" rel="noopener">${f}</a>`).join('');
document.getElementById('main').innerHTML = `
<header>
<h1>${esc(e.task_name)}</h1>
<div class="sub"><code>${esc(e.task)}</code> · ${esc(e.session)} · episode ${e.ep} · ${fmt(dur)} @ ${e.fps}fps · ${esc(e.location)}</div>
<div class="badges">${(e.tags||[]).map(t=>`<span class="bdg tag">${esc(t)}</span>`).join('')}<span class="bdg">instruction: <b>${esc(e.instruction)}</b></span></div>
${e.taxo?`<div class="badges"><span class="bdg">environment <b>${esc(e.taxo.environment_l1)} › ${esc(e.taxo.environment_l2)} › ${esc(e.taxo.environment_l3)}</b></span><span class="bdg">skill group <b>${esc(e.taxo.skill_group)}</b></span><span class="bdg">job <b>${esc(e.taxo.job_family)}</b></span><span class="bdg">difficulty <b>${esc(e.taxo.task_difficulty)}</b></span><span class="bdg">NAICS <b>${esc(e.taxo.naics_primary_code)}</b></span></div>`:''}
</header>
<div class="layout">
<div class="panel">
<div class="vgrid">${tile(0)}${tile(1)}</div>
<div class="three">
<div class="cbox">
<span class="lbl">head orientation 3d · <span style="color:#ff5c5c">x</span> <span style="color:#41c76b">y</span> <span style="color:#4f8ff7">z</span></span>
<canvas id="cOri"></canvas><span class="hint">drag to orbit</span>
</div>
<div class="cbox">
<span class="lbl">head heading track · heading / elevation <span style="color:#6ea8ff">(last 10 s)</span> · click to seek</span>
<canvas id="cGaze"></canvas>
</div>
</div>
</div>
<div class="panel">
<div class="spans-head">temporal spans (${e.n_segments}) · <b>${esc(e.task_name)}</b></div>
<div class="tl" id="tl"></div>
<div class="chips" id="chips">${chips}</div>
<div class="slist" id="slist">${slist}</div>
</div>
</div>
<div class="legend">${legend}</div>
<div class="panel controls">
<button id="play" aria-label="play / pause">&#9654;</button>
<input type="range" id="seek" min="0" max="1000" value="0" aria-label="seek">
<span class="t" id="tlab">0:00.00 / ${fmt(dur)}</span>
<select id="rate" aria-label="playback speed"><option value="1">1×</option><option value="2">2×</option><option value="4">4×</option></select>
</div>
<div class="badges" style="padding:0 20px;margin-top:8px">
<span class="bdg"><b>${e.nframes}</b> frames</span>
<span class="bdg"><b>${e.n_segments}</b> skill spans</span>
<span class="bdg"><b>${e.skill_list.length}</b> skills</span>
<span class="bdg">state dim <b>${e.state_dim}</b></span>
<span class="bdg">2 cams · 1920×1080 stereo</span>
<span class="bdg">IMU 200 Hz</span>
<span class="bdg">${esc(e.hw)}</span><span class="bdg">collected in <b>${esc(e.region)}</b></span>
<span class="bdg">QC <b style="color:${e.qc.failures?'#ff5c5c':'#6fe08a'}">${e.qc.passed}/${e.qc.total}</b>${e.qc.warnings?` · ${e.qc.warnings} warn`:''}</span>
</div>
<div class="note"><b>Camera calibration.</b> Camera calibration is part of the licensed deliverable for US sales data and is therefore not distributed with public Samples.
The 3D panel shows head <i>orientation</i> from the device IMU (AHRS quaternion); this rig does not record hand pose or head position.</div>
<div class="panel readout">
<div class="rt">current-time readout</div>
<div class="rg">
<div class="ro"><span class="k">time</span><span class="v" id="roT">0.00s</span></div>
<div class="ro"><span class="k">current skill</span><span class="v b" id="roSk">–</span></div>
<div class="ro"><span class="k">span</span><span class="v" id="roSp">–</span></div>
<div class="ro"><span class="k">head |&omega;| gyro</span><span class="v" id="roW">–</span></div>
<div class="ro"><span class="k">head |a| accel</span><span class="v" id="roA">–</span></div>
<div class="ro"><span class="k">roll / pitch / yaw</span><span class="v" id="roE">–</span></div>
<div class="ro"><span class="k">IMU valid</span><span class="v hi">${(e.imu_valid*100).toFixed(1)}%</span></div>
<div class="ro"><span class="k">measured fps</span><span class="v">${e.measured_fps}</span></div>
<div class="ro"><span class="k">stereo sync</span><span class="v hi">&lt; 11 &micro;s</span></div>
</div>
</div>
<div class="charts">
<div class="panel chp"><div class="l">head IMU · gyro <span class="u">rad/s</span>
<span class="leg"><i style="background:${AX.x}"></i>x<i style="background:${AX.y}"></i>y<i style="background:${AX.z}"></i>z</span></div>
<canvas id="chG"></canvas></div>
<div class="panel chp"><div class="l">head IMU · accel <span class="u">m/s&sup2;</span>
<span class="leg"><i style="background:${AX.x}"></i>x<i style="background:${AX.y}"></i>y<i style="background:${AX.z}"></i>z</span></div>
<canvas id="chA"></canvas></div>
<div class="panel chp"><div class="l">head IMU · attitude <span class="u">deg · head_imu/angle</span>
<span class="leg"><i style="background:${AX.x}"></i>roll<i style="background:${AX.y}"></i>pitch<i style="background:${AX.z}"></i>yaw</span></div>
<canvas id="chN"></canvas></div>
<div class="panel chp"><div class="l">head IMU · magnetometer <span class="u">raw units</span>
<span class="leg"><i style="background:${AX.x}"></i>x<i style="background:${AX.y}"></i>y<i style="background:${AX.z}"></i>z</span></div>
<canvas id="chM"></canvas></div>
</div>
<div class="panel" style="margin:14px 20px 30px;padding:11px 16px">
<div class="rt">episode clips &mdash; click to download (640&times;360 preview)</div>
<div class="files" style="padding:0 0 6px">${camDl}</div>
<div class="rt" style="margin-top:8px">dataset files &middot; <a href="${HFDIR}" target="_blank" rel="noopener" style="text-transform:none;color:#7fe0a0">full episode on HuggingFace &rarr;</a></div>
<div class="files">${refChips}</div>
</div>`;
wire();
}
/* ---------- wiring ---------- */
function wire(){
const e=EP, dur=e.duration, segs=e.segments;
vids=[...document.querySelectorAll('.vp video')];
master=vids[0];
const seek=document.getElementById('seek'), playBtn=document.getElementById('play'), rate=document.getElementById('rate');
playBtn.onclick=toggle; _toggle=toggle;
function toggle(){
playing=!playing;
playBtn.innerHTML = playing?'&#10073;&#10073;':'&#9654;';
if(playing){
vids.forEach(v=>v.play().catch(()=>{}));
tick(); clearInterval(syncTimer);
syncTimer=setInterval(()=>{ if(playing && master) syncSlaves(master.currentTime,false); },250);
} else {
vids.forEach(v=>v.pause()); master.playbackRate=+rate.value; clearInterval(syncTimer); syncTimer=0;
syncSlaves(master.currentTime,true);
}
}
rate.onchange=()=>{ vids.forEach(v=>v.playbackRate=+rate.value); };
seek.oninput=()=>setTime(seek.value/1000*dur);
const tl=document.getElementById('tl'); tl.innerHTML='';
segs.forEach(s=>{ const b=document.createElement('i');
b.style.width=((s.te-s.ts)/dur*100)+'%'; b.style.background=skc(s.skill);
b.title=`${s.skill} — ${s.label}`; b.onclick=ev=>{ev.stopPropagation(); setTime(s.ts+0.01);}; tl.appendChild(b); });
tl.onclick=ev=>{ const r=tl.getBoundingClientRect(); setTime((ev.clientX-r.left)/r.width*dur); };
document.querySelectorAll('#chips .chip').forEach(c=>c.onclick=()=>setTime(segs[+c.dataset.i].ts+0.01));
document.querySelectorAll('#slist .si').forEach(c=>c.onclick=()=>setTime(segs[+c.dataset.i].ts+0.01));
CHIDS.forEach(id=>{ const c=document.getElementById(id);
if(c) c.onclick=ev=>{ const r=c.getBoundingClientRect(); setTime((ev.clientX-r.left)/r.width*dur); }; });
document.getElementById('cGaze').onclick=gazeClick;
bindOrbit('cOri',orbO);
master.addEventListener('seeked',()=>syncSlaves(master.currentTime,true));
master.addEventListener('loadeddata',()=>{ sizeCanvases(); updateUI(master.currentTime); });
document.onkeydown=ev=>{
if(ev.target && ev.target.tagName==='SELECT') return;
if(ev.code==='Space'){ev.preventDefault(); toggle();}
else if(ev.code==='ArrowRight'){ev.preventDefault(); const n=segs.find(s=>s.ts>master.currentTime+0.05); if(n) setTime(n.ts+0.01);}
else if(ev.code==='ArrowLeft'){ev.preventDefault(); const p=[...segs].reverse().find(s=>s.ts<master.currentTime-0.3); setTime(p?p.ts+0.01:0);}
};
sizeCanvases(); setTime(0);
}
/* ---------- stereo sync: left tile is the clock; right tile is trimmed by playbackRate (no per-frame seeks) ---------- */
function inBuf(v,t){ const b=v.buffered; for(let i=0;i<b.length;i++) if(t>=b.start(i)-0.2 && t<=b.end(i)+0.2) return true; return false; }
function syncSlaves(t,hard){
const base=+(document.getElementById('rate')||{value:1}).value;
for(const v of vids){
if(v===master || v.readyState<1) continue;
if(hard){ v.playbackRate=base; if(!v.seeking && Math.abs(v.currentTime-t)>0.04){ try{v.currentTime=t;}catch(e){} } continue; }
if(!playing) continue;
if(v.paused && !v.ended) v.play().catch(()=>{});
const drift=v.currentTime-t, ad=Math.abs(drift);
if(ad>0.75){ if(!v.seeking && inBuf(v,t)){ try{v.currentTime=t;}catch(e){} } v.playbackRate=base; }
else if(ad>0.035){ v.playbackRate=base*Math.max(0.5,Math.min(1.6,1-drift*3)); }
else v.playbackRate=base;
}
}
function setTime(t){
const dur=EP.duration; t=Math.max(0,Math.min(dur-0.01,t));
try{ if(Math.abs(master.currentTime-t)>0.03) master.currentTime=t; }catch(e){}
syncSlaves(t,true); updateUI(t);
}
function tick(){
if(!playing) return;
const t=master.currentTime, dur=EP.duration;
updateUI(t);
if(t>=dur-0.05){ try{master.currentTime=0;}catch(e){} syncSlaves(0,true); vids.forEach(v=>v.play().catch(()=>{})); }
raf=requestAnimationFrame(tick);
}
function curSpan(t){ const s=EP.segments; for(let i=0;i<s.length;i++) if(t>=s[i].ts && t<s[i].te) return i; return -1; }
function fidx(t){ return Math.max(0,Math.min(SIG.n-1,Math.round(t*SIG.fps))); }
function updateUI(t){
const dur=EP.duration, segs=EP.segments;
document.getElementById('seek').value=t/dur*1000;
document.getElementById('tlab').textContent=fmt(t)+' / '+fmt(dur);
document.getElementById('roT').textContent=t.toFixed(2)+'s';
const ci=curSpan(t);
document.getElementById('roSk').textContent=ci>=0?segs[ci].skill:'–';
document.getElementById('roSp').textContent=ci>=0?`${ci+1} / ${segs.length}`:'–';
const slEls=document.querySelectorAll('#slist .si'), chEls=document.querySelectorAll('#chips .chip');
if(ci!==_lastCi){
slEls.forEach((el,k)=>el.classList.toggle('on',k===ci));
chEls.forEach((el,k)=>el.classList.toggle('on',k===ci));
if(ci>=0){
const sl=document.getElementById('slist'), el=slEls[ci], top=el.offsetTop-sl.offsetTop, bot=top+el.offsetHeight;
if(top<sl.scrollTop || bot>sl.scrollTop+sl.clientHeight) sl.scrollTop=top-sl.clientHeight/2+el.offsetHeight/2;
const cc=document.getElementById('chips'), ce=chEls[ci], ct=ce.offsetTop-cc.offsetTop;
if(ct<cc.scrollTop || ct+ce.offsetHeight>cc.scrollTop+cc.clientHeight) cc.scrollTop=ct-cc.clientHeight/2;
}
_lastCi=ci;
}
if(SIG){
const j=fidx(t), g=SIG.headImu.gyro[j], a=SIG.headImu.acc[j], n=SIG.headImu.angle[j];
document.getElementById('roW').textContent=Math.hypot(g[0],g[1],g[2]).toFixed(2)+' rad/s';
document.getElementById('roA').textContent=Math.hypot(a[0],a[1],a[2]).toFixed(2)+' m/s²';
document.getElementById('roE').textContent=n.map(x=>x.toFixed(0)+'°').join(' / ');
drawOri(j); drawGaze(j); drawPlayheads(t);
}
}
/* ---------- 3D head orientation ---------- */
function q2R(q){ const n=Math.hypot(q[0],q[1],q[2],q[3])||1, x=q[0]/n,y=q[1]/n,z=q[2]/n,w=q[3]/n;
return [[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]]; }
function bindOrbit(id,st){
const c=document.getElementById(id); if(!c) return; let px,py;
c.onpointerdown=e=>{ st.drag=1; px=e.clientX; py=e.clientY; c.style.cursor='grabbing'; c.setPointerCapture(e.pointerId); };
c.onpointermove=e=>{ if(!st.drag) return; st.yaw+=(e.clientX-px)*0.01; st.pitch+=(e.clientY-py)*0.01; px=e.clientX; py=e.clientY;
if(SIG) drawOri(fidx(master.currentTime)); };
c.onpointerup=c.onpointercancel=()=>{ st.drag=0; c.style.cursor='grab'; };
}
/* world frame of the IMU quaternion is z-up; view it with z pointing up on screen */
function proj(p,st,cx,cy,sc){
const cy_=Math.cos(st.yaw),sy=Math.sin(st.yaw),cp=Math.cos(st.pitch),sp=Math.sin(st.pitch);
const x=p[0]*cy_-p[1]*sy, y=p[0]*sy+p[1]*cy_, z=p[2];
const yv=y*sp+z*cp; // tilt the camera
return [cx+x*sc, cy-yv*sc];
}
function drawOri(j){
const c=document.getElementById('cOri'); if(!c||!SIG) return;
const g=c.getContext('2d'), w=c.width, h=c.height; g.fillStyle='#111116'; g.fillRect(0,0,w,h);
const sc=Math.min(w,h)*0.32, cx=w/2, cy=h/2+h*0.05, P=p=>proj(p,orbO,cx,cy,sc);
// ground grid in the world xy plane (z = -1)
g.lineWidth=1;
for(let i=-4;i<=4;i++){ const v=i/4*1.2;
g.strokeStyle=i===0?'#2a3a5a':'#1d2230';
let A=P([v,-1.2,-1]),B=P([v,1.2,-1]); g.beginPath(); g.moveTo(A[0],A[1]); g.lineTo(B[0],B[1]); g.stroke();
A=P([-1.2,v,-1]); B=P([1.2,v,-1]); g.beginPath(); g.moveTo(A[0],A[1]); g.lineTo(B[0],B[1]); g.stroke(); }
// trail of the device x-axis tip over the last 5 s
const q=SIG.headQuat, n0=Math.max(0,j-SIG.fps*5);
g.strokeStyle='#6ea8ff'; g.globalAlpha=0.55; g.beginPath();
for(let k=n0;k<=j;k+=2){ const Rk=q2R(q[k]); const p=P([Rk[0][0],Rk[1][0],Rk[2][0]]); k===n0?g.moveTo(p[0],p[1]):g.lineTo(p[0],p[1]); }
g.stroke(); g.globalAlpha=1;
const Rj=q2R(q[j]), O=P([0,0,0]);
// device box
const corners=[]; for(const sx of[-1,1])for(const sy of[-1,1])for(const sz of[-1,1]){
const b=[sx*0.45,sy*0.3,sz*0.15]; corners.push(P([Rj[0][0]*b[0]+Rj[0][1]*b[1]+Rj[0][2]*b[2],Rj[1][0]*b[0]+Rj[1][1]*b[1]+Rj[1][2]*b[2],Rj[2][0]*b[0]+Rj[2][1]*b[1]+Rj[2][2]*b[2]])); }
g.strokeStyle='#8a93a6'; g.lineWidth=1;
[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]].forEach(([a,b])=>{ g.beginPath(); g.moveTo(corners[a][0],corners[a][1]); g.lineTo(corners[b][0],corners[b][1]); g.stroke(); });
[0,1,2].forEach(ax=>{ const p=P([Rj[0][ax],Rj[1][ax],Rj[2][ax]]); g.strokeStyle=AXR[ax]; g.lineWidth=2.2;
g.beginPath(); g.moveTo(O[0],O[1]); g.lineTo(p[0],p[1]); g.stroke(); });
// world gravity reference
const gz=P([0,0,0.9]); g.strokeStyle='#556'; g.setLineDash([3,3]); g.lineWidth=1; g.beginPath(); g.moveTo(O[0],O[1]); g.lineTo(gz[0],gz[1]); g.stroke(); g.setLineDash([]);
g.fillStyle='#556'; g.font='10px ui-monospace,monospace'; g.fillText('world z',gz[0]+4,gz[1]);
}
/* heading track: yaw (x) vs pitch (y) of the IMU attitude */
function gazeFit(){
if(SIG._fw) return SIG._fw;
// device body +y is the forward (viewing) direction: heading = azimuth of +y in the z-up world, elev = its elevation
const yawU=[], el=[]; let prev=null, off=0;
for(const q of SIG.headQuat){ const R=q2R(q), fx=R[0][1], fy=R[1][1], fz=R[2][1];
let y=Math.atan2(fy,fx)*180/Math.PI; if(prev!==null){ const d=y-prev; if(d>180) off-=360; else if(d<-180) off+=360; } prev=y;
yawU.push(y+off); el.push(Math.asin(Math.max(-1,Math.min(1,fz)))*180/Math.PI); }
const qt=(arr,f)=>{ const s=arr.slice().sort((a,b)=>a-b); return s[Math.round(f*(s.length-1))]; };
const f={yaw:yawU, el, y0:qt(yawU,0.01), y1:qt(yawU,0.99), p0:qt(el,0.01), p1:qt(el,0.99)};
const padY=Math.max(4,(f.y1-f.y0)*0.08), padP=Math.max(4,(f.p1-f.p0)*0.1);
f.y0-=padY; f.y1+=padY; f.p0-=padP; f.p1+=padP;
return (SIG._fw=f);
}
function drawGaze(j){
const c=document.getElementById('cGaze'); if(!c||!SIG) return;
const g=c.getContext('2d'), w=c.width, h=c.height; g.fillStyle='#111116'; g.fillRect(0,0,w,h);
const f=gazeFit(), m=26;
const X=v=>m+(v-f.y0)/(f.y1-f.y0)*(w-2*m), Y=v=>h-m+6-(v-f.p0)/(f.p1-f.p0)*(h-2*m);
g.strokeStyle='#1d2230'; g.lineWidth=1;
for(let i=0;i<=4;i++){ const x=m+i/4*(w-2*m), y=m-6+i/4*(h-2*m); g.beginPath(); g.moveTo(x,m-6); g.lineTo(x,h-m+6); g.stroke(); g.beginPath(); g.moveTo(m,y); g.lineTo(w-m,y); g.stroke(); }
// whole-episode cloud
g.fillStyle='#2a3450';
for(let k=0;k<SIG.n;k+=6) g.fillRect(X(f.yaw[k])-0.5,Y(f.el[k])-0.5,1.5,1.5);
// last 10 s
const n0=Math.max(0,j-SIG.fps*10);
g.strokeStyle='#6ea8ff'; g.lineWidth=1.5; g.beginPath();
for(let k=n0;k<=j;k++){ const x=X(f.yaw[k]), y=Y(f.el[k]); k===n0?g.moveTo(x,y):g.lineTo(x,y); } g.stroke();
g.fillStyle='#fff'; g.beginPath(); g.arc(X(f.yaw[j]),Y(f.el[j]),3.5,0,7); g.fill();
g.fillStyle='#667'; g.font='10px ui-monospace,monospace';
g.fillText('heading '+f.y0.toFixed(0)+'°',m,h-4); const s1='heading '+f.y1.toFixed(0)+'°'; g.fillText(s1,w-m-g.measureText(s1).width,h-4);
g.fillText('elev '+f.p1.toFixed(0)+'°',4,m+14); g.fillText('elev '+f.p0.toFixed(0)+'°',4,h-m);
}
function gazeClick(ev){
if(!SIG) return; const c=ev.currentTarget, r=c.getBoundingClientRect(), f=gazeFit(), m=26;
const sx=(ev.clientX-r.left)*c.width/r.width, sy=(ev.clientY-r.top)*c.height/r.height;
const w=c.width, h=c.height, X=v=>m+(v-f.y0)/(f.y1-f.y0)*(w-2*m), Y=v=>h-m+6-(v-f.p0)/(f.p1-f.p0)*(h-2*m);
let best=0, bd=1e18; for(let k=0;k<SIG.n;k+=2){ const d=(X(f.yaw[k])-sx)**2+(Y(f.el[k])-sy)**2; if(d<bd){bd=d;best=k;} }
setTime(best/SIG.fps);
}
/* ---------- charts ---------- */
const CHIDS=['chG','chA','chN','chM'];
function sizeCanvases(){
document.querySelectorAll('.chp canvas').forEach(c=>{ c.width=c.clientWidth*devicePixelRatio; c.height=76*devicePixelRatio;
const g=c.getContext('2d'); g.setTransform(1,0,0,1,0,0); g.scale(devicePixelRatio,devicePixelRatio); });
['cOri','cGaze'].forEach(id=>{ const c=document.getElementById(id); if(c){ c.width=c.clientWidth*devicePixelRatio; c.height=c.clientHeight*devicePixelRatio; } });
CACHE={};
}
window.addEventListener('resize',()=>{ if(EP){ sizeCanvases(); if(SIG){ drawCharts(); updateUI(master?master.currentTime:0); } } });
let CACHE={};
function chartSeries(){
if(SIG._cs) return SIG._cs;
const col=(arr,k)=>arr.map(r=>r[k]), H=SIG.headImu;
return (SIG._cs={
chG:[0,1,2].map(k=>({data:col(H.gyro,k),c:[AX.x,AX.y,AX.z][k]})),
chA:[0,1,2].map(k=>({data:col(H.acc,k),c:[AX.x,AX.y,AX.z][k]})),
chN:[0,1,2].map(k=>({data:col(H.angle,k),c:[AX.x,AX.y,AX.z][k]})),
chM:[0,1,2].map(k=>({data:col(H.mag,k),c:[AX.x,AX.y,AX.z][k]}))});
}
/* 18k samples per series: draw min/max per pixel column once into an offscreen bitmap, then just blit + playhead */
function renderChart(id,series){
const c=document.getElementById(id); if(!c) return null;
const w=c.clientWidth, h=76, off=document.createElement('canvas');
off.width=c.width; off.height=c.height; const g=off.getContext('2d'); g.scale(devicePixelRatio,devicePixelRatio);
g.fillStyle='#0b0e13'; g.fillRect(0,0,w,h);
let mn=1e9,mx=-1e9; series.forEach(s=>s.data.forEach(v=>{ if(v<mn)mn=v; if(v>mx)mx=v; })); if(mn===mx){mn-=1;mx+=1;}
const N=SIG.n, Y=v=>h-4-(v-mn)/(mx-mn)*(h-8);
if(mn<0&&mx>0){ g.strokeStyle='#1c2028'; g.beginPath(); g.moveTo(0,Y(0)); g.lineTo(w,Y(0)); g.stroke(); }
series.forEach(s=>{ g.strokeStyle=s.c; g.lineWidth=1; g.beginPath();
for(let px=0;px<w;px++){ const a=Math.floor(px/w*N), b=Math.max(a+1,Math.floor((px+1)/w*N)); let lo=1e9,hi=-1e9;
for(let i=a;i<b&&i<N;i++){ const v=s.data[i]; if(v<lo)lo=v; if(v>hi)hi=v; }
if(px===0) g.moveTo(px,Y(lo)); g.lineTo(px,Y(lo)); g.lineTo(px,Y(hi)); }
g.stroke(); });
return off;
}
function drawCharts(){ if(!SIG) return; const S=chartSeries(); CHIDS.forEach(id=>CACHE[id]=renderChart(id,S[id])); drawPlayheads(master?master.currentTime:0); }
function drawPlayheads(t){
if(!SIG) return; const frac=t/EP.duration;
CHIDS.forEach(id=>{ const c=document.getElementById(id); if(!c) return; if(!CACHE[id]) CACHE[id]=renderChart(id,chartSeries()[id]);
const g=c.getContext('2d'); g.save(); g.setTransform(1,0,0,1,0,0); g.drawImage(CACHE[id],0,0); g.restore();
g.strokeStyle='#fff'; g.globalAlpha=.55; g.lineWidth=1; g.beginPath(); g.moveTo(frac*c.clientWidth,0); g.lineTo(frac*c.clientWidth,76); g.stroke(); g.globalAlpha=1; });
}
function fromHash(){ const s=decodeURIComponent(location.hash.slice(1)); const i=EPS.findIndex(e=>e.slug===s); select(i>=0?i:0); }
window.addEventListener('hashchange',fromHash);
fromHash();
</script>
</body></html>