embodiedtrace / app.js
windjj's picture
Expose alternate instructions and unresolved intent in episode details
7dfe7cd verified
Raw History Blame Contribute Delete
28.7 kB
"use strict";
const $ = id => document.getElementById(id);
const state = { manifest:null, episode:null, id:null, frame:0, decision:0, camera:0, mode:"observe", request:0, timer:null, videoReady:false, controller:null, cache:new Map(), path:null };
const video = $("video");
const reasons = { translation_threshold:"Translation keyframe", rotation_threshold:"Rotation keyframe", gripper_command_switch:"Gripper change", max_horizon:"Horizon keyframe", episode_end:"Final keyframe" };
const fmt = value => Number(value).toFixed(3);
const clock = seconds => `${String(Math.floor(seconds/60)).padStart(2,"0")}:${String(Math.floor(seconds%60)).padStart(2,"0")}`;
const text = (id,value) => { const element=$(id);if(element.textContent!==String(value))element.textContent=value; };
const narrowLibrary=window.matchMedia("(max-width:1100px)");
function setLibrary(open,{restoreFocus=true}={}) {
open=Boolean(open&&narrowLibrary.matches);
document.body.classList.toggle("library-open",open);
$("library-toggle").setAttribute("aria-expanded",String(open));
$("library-toggle").setAttribute("aria-label",open?"Close episode library":"Open episode library");
$("library-backdrop").hidden=!open;
$("episode-library").inert=narrowLibrary.matches&&!open;
$("workspace").inert=open;
if(open)$("search").focus();
else if(restoreFocus&&narrowLibrary.matches)$("library-toggle").focus();
}
$("library-toggle").addEventListener("click",()=>setLibrary(!document.body.classList.contains("library-open")));
$("close-library").addEventListener("click",()=>setLibrary(false));
$("library-backdrop").addEventListener("click",()=>setLibrary(false));
narrowLibrary.addEventListener("change",()=>setLibrary(false,{restoreFocus:false}));
setLibrary(false,{restoreFocus:false});
$("inspect-annotations").addEventListener("click",()=>{$("annotation-panel").scrollIntoView({block:"start"});$("annotation-panel").focus({preventScroll:true});});
function toast(message) {
text("toast",message);$("toast").hidden=false;
clearTimeout(state.timer);state.timer=setTimeout(()=>$("toast").hidden=true,2600);
}
function filteredEpisodes() {
const query=$("search").value.trim().toLowerCase(),outcome=$("outcome-filter").value,split=$("split-filter").value;
return state.manifest.episodes.filter(e =>
(outcome==="all"||e.source_outcome===outcome) && (split==="all"||e.split===split) &&
(!query||`${e.task} ${e.episode_id} ${e.lab} ${e.ordinal} ${e.recorded_date}`.toLowerCase().includes(query)));
}
function renderLibrary() {
const list=$("episode-list"),scroll=list.scrollTop,episodes=filteredEpisodes();
list.replaceChildren();text("count",episodes.length);
text("library-match",`${episodes.length} of ${state.manifest.episodes.length} episodes`);
const selected=episodes.findIndex(e=>e.episode_id===state.id);
$("previous-episode").disabled=selected<=0;
$("next-episode").disabled=selected<0||selected>=episodes.length-1;
if (!episodes.length) {
const empty=document.createElement("div");empty.className="empty-list";
empty.textContent="No episodes match these filters.";list.append(empty);
}
episodes.forEach(e=>{
const card=document.createElement("button");card.className=`episode-card${e.episode_id===state.id?" active":""}`;
card.dataset.episode=e.episode_id;card.setAttribute("aria-label",`Episode ${e.ordinal}: ${e.instruction_available?e.task:"Unlabeled task"}`);
if(e.episode_id===state.id)card.setAttribute("aria-current","true");
const image=document.createElement("img");image.src=`./${e.thumbnail}`;image.alt="";image.loading="lazy";
const info=document.createElement("div"),title=document.createElement("p");title.className="card-title";
title.textContent=e.instruction_available?e.task:"Unlabeled task";
const meta=document.createElement("div");meta.className="card-meta";
const dot=document.createElement("i");dot.className=`status-dot ${e.source_outcome}`;dot.title=`Recorded ${e.source_outcome}`;
const number=document.createElement("span");number.textContent=`${String(e.ordinal).padStart(3,"0")} · ${e.lab}`;
const split=document.createElement("span");split.className="split-name";split.textContent=e.split;
meta.append(dot,number,split);info.append(title,meta);card.append(image,info);
card.addEventListener("click",()=>{setLibrary(false,{restoreFocus:false});$("workspace").focus({preventScroll:true});if(e.episode_id!==state.id)openEpisode(e.episode_id);});list.append(card);
});
list.scrollTop=scroll;
}
async function openEpisode(id,initialFrame=0) {
const request=++state.request;
const previousId=state.episode?.metadata.episode_id;
state.controller?.abort();state.controller=new AbortController();
video.pause();state.videoReady=false;state.id=id;state.mode="observe";
$("play").disabled=true;
$("workspace").setAttribute("aria-busy","true");$("video-loading").hidden=false;$("video-error").hidden=true;
renderLibrary();
try {
let episode=state.cache.get(id);
if(!episode){
const response=await fetch(`./episodes/${id}.json`,{signal:state.controller.signal});
if(!response.ok)throw new Error("Episode data could not be loaded. Select the episode to retry.");
episode=await response.json();
state.cache.set(id,episode);if(state.cache.size>4)state.cache.delete(state.cache.keys().next().value);
}
if(request!==state.request)return;
state.episode=episode;state.frame=Math.max(0,Math.min(episode.metadata.steps-1,initialFrame));state.decision=findDecision(state.frame);
state.path=null;$("workspace").scrollTop=0;
const meta=episode.metadata;
text("episode-number",`EPISODE ${String(meta.ordinal).padStart(3,"0")}`);text("episode-lab",`${meta.lab} / ${meta.recorded_date}`);
text("task-title",meta.instruction_available?meta.task:"Unlabeled manipulation episode");
$("task-title").classList.toggle("long-task",(meta.task||"").length>110);
text("split-badge",meta.split);text("outcome-badge",`Recorded ${meta.source_outcome}`);
$("outcome-badge").className=`badge ${meta.source_outcome}`;
text("episode-details",`${meta.steps.toLocaleString()} frames · ${meta.turns} EEF decisions · ${clock(meta.duration_s)}`);
$("scrubber").max=meta.steps-1;
$("initial-loader").hidden=true;$("episode-workspace").hidden=false;
video.poster="";video.src=`./${episode.video}`;video.load();
video.dataset.initialFrame=state.frame;
renderMarkers();renderDecision();setCamera(state.camera);renderFrame(state.frame);renderSource();
$("workspace").setAttribute("aria-busy","false");saveLocation();
} catch(error) {
if(request!==state.request)return;
state.id=previousId||null;renderLibrary();
state.videoReady=Boolean(state.episode&&video.readyState>=1);$("play").disabled=!state.videoReady;
toast(error.message);$("video-loading").hidden=true;
if(!state.episode) { text("initial-loader",error.message); }
$("workspace").setAttribute("aria-busy","false");
}
}
function findDecision(frame) {
if(!state.episode)return 0;
const events=state.episode.events;
let result=0;
for(let i=0;i<events.length;i++){if(events[i].start>frame)break;result=i;}
return result;
}
function renderMarkers() {
const box=$("decision-markers");box.replaceChildren();
state.episode.events.forEach((event,index)=>{
const marker=document.createElement("button");marker.className=`decision-marker${event.reason==="gripper_command_switch"?" gripper":""}`;
marker.style.left=`${100*event.start/Math.max(1,state.episode.metadata.steps-1)}%`;
marker.setAttribute("aria-label",`Go to EEF decision ${index+1}, frame ${event.start}`);
marker.title=`Decision ${index+1} · ${reasons[event.reason]||"EEF target"} · frame ${event.start}`;
marker.addEventListener("click",()=>jumpDecision(index));box.append(marker);
});
}
function renderDecision() {
const event=state.episode.events[state.decision],args=event.action.arguments;
text("decision-counter",`${String(state.decision+1).padStart(2,"0")} / ${state.episode.events.length}`);
$("previous-decision").disabled=state.decision===0;$("next-decision").disabled=state.decision===state.episode.events.length-1;
document.querySelectorAll(".decision-marker").forEach((m,i)=>m.classList.toggle("active",i===state.decision));
text("action-description",`${reasons[event.reason]||"EEF target"} · frames ${event.start}–${event.end}. Target sampled from the recorded command at frame ${event.end-1}.`);
text("action-json",JSON.stringify(event.action,null,2));
$("prediction-panel").hidden=!event.prediction;
text("prediction-json",event.prediction?JSON.stringify(event.prediction,null,2):"");
updateSnapshots();renderAnnotations();renderMode();
}
function renderAnnotations() {
const episode=state.episode,annotation=episode.events[state.decision].generated_annotation;
$("annotation-panel").hidden=!annotation;
$("active-goal-strip").hidden=!annotation;
if(!annotation)return;
const generated=episode.generated_annotations,groups=generated.subgoals;
const index=groups.findIndex(g=>g.id===annotation.subgoal.id);
text("active-goal",annotation.subgoal.title);
const select=$("subgoal-select");select.replaceChildren();
groups.forEach((group,i)=>{const option=document.createElement("option");option.value=i;option.textContent=`${String(i+1).padStart(2,"0")} · ${group.title}`;select.append(option);});
select.value=index;$("previous-subgoal").disabled=index<=0;$("next-subgoal").disabled=index>=groups.length-1;
const track=$("subgoal-track");track.replaceChildren();
track.style.setProperty("--phases",Math.min(groups.length,14));
groups.forEach((group,i)=>{
const phase=document.createElement("button");phase.className=`subgoal-phase${i===index?" active":i<index?" past":""}`;
phase.setAttribute("aria-label",`Subgoal ${i+1}: ${group.title}`);phase.setAttribute("aria-pressed",String(i===index));
phase.title=`${i+1} / ${groups.length} · ${group.title}`;
phase.addEventListener("click",()=>jumpSubgoal(i));track.append(phase);
});
text("subgoal-count",`${index+1} / ${groups.length}`);
text("subgoal-title",annotation.subgoal.title);text("subgoal-intent",annotation.subgoal.intent);
text("thinking-text",annotation.thinking.text);text("annotated-action",annotation.action_annotation.text);
text("initial-scene",`${generated.initial_scene_review.initial_scene} Source frame ${generated.initial_scene_review.initial_scene_source_step}. This is an initial-scene memory, not a current object-state claim.`);
const evidence=$("annotation-evidence");evidence.replaceChildren();
annotation.thinking.observed_facts.forEach(fact=>{const li=document.createElement("li");li.textContent=fact;evidence.append(li);});
const source=document.createElement("li");source.textContent=`Task source: ${generated.task_source}. Action description uses command-label frame ${annotation.evidence.target_command_step}; thinking excludes future feedback and episode outcome.`;evidence.append(source);
const plan=$("task-plan");plan.replaceChildren();
generated.proposed_task_plan.steps.forEach(step=>{const li=document.createElement("li");li.textContent=step;plan.append(li);});
const alignment=annotation.subgoal.semantic_alignment;
text("semantic-caveat",alignment==="unresolved"?"Object-level intent or multi-step alignment is unresolved. Subgoal boundaries describe motion phases, not confirmed semantic completion.":"Task intent and motion-phase boundaries are weak hypotheses; no calibrated confidence score is available.");
text("next-check",annotation.next_observation_check);text("progress-text",annotation.progress_annotation.text);
text("progress-check",`Suggested check: ${annotation.next_observation_check}`);
}
function jumpSubgoal(index) {
const groups=state.episode?.generated_annotations?.subgoals;if(!groups)return;
index=Math.max(0,Math.min(groups.length-1,index));jumpDecision(groups[index].start_turn);
}
let snapshotRequest=0;
const snapshotDecoder=$("snapshot-decoder");
function waitMedia(target,event,timeout=12000){
return new Promise((resolve,reject)=>{
const done=()=>{clearTimeout(timer);target.removeEventListener(event,ready);target.removeEventListener("error",failed);};
const ready=()=>{done();resolve();},failed=()=>{done();reject(new Error("Snapshot media unavailable"));};
const timer=setTimeout(()=>{done();reject(new Error("Snapshot timeout"));},timeout);
target.addEventListener(event,ready,{once:true});target.addEventListener("error",failed,{once:true});
});
}
async function updateSnapshots() {
const request=++snapshotRequest,episode=state.episode;
if(!episode)return;
const event=episode.events[state.decision],obs=state.mode==="feedback"?event.feedback.observation:event.observation;
text("snapshot-label",`Video-derived snapshots · recorded frame ${obs.source_step}`);
try {
if(snapshotDecoder.dataset.episode!==episode.metadata.episode_id){
snapshotDecoder.dataset.episode=episode.metadata.episode_id;snapshotDecoder.src=`./${episode.video}`;snapshotDecoder.load();
}
if(snapshotDecoder.readyState<1)await waitMedia(snapshotDecoder,"loadedmetadata");
if(request!==snapshotRequest)return;
const target=Math.min(obs.source_step/state.manifest.fps,Math.max(0,snapshotDecoder.duration-.025));
if(Math.abs(snapshotDecoder.currentTime-target)>.001){
const seek=waitMedia(snapshotDecoder,"seeked");snapshotDecoder.currentTime=target;await seek;
}else if(snapshotDecoder.readyState<2)await waitMedia(snapshotDecoder,"loadeddata");
if(request!==snapshotRequest)return;
const canvas=document.createElement("canvas");canvas.width=320;canvas.height=180;const ctx=canvas.getContext("2d");
const width=snapshotDecoder.videoWidth/3,height=snapshotDecoder.videoHeight;
for(let i=0;i<3;i++){
ctx.drawImage(snapshotDecoder,i*width,0,width,height,0,0,320,180);
const image=$(`snapshot-${i}`);image.src=canvas.toDataURL("image/jpeg",.88);image.alt=`${state.manifest.camera_names[i]} at recorded frame ${obs.source_step}, decoded from compressed preview video`;
}
}catch(error){
if(request!==snapshotRequest)return;
text("snapshot-label","Snapshots unavailable · use the main video or download source JPEG observations");
}
}
function renderMode() {
const feedback=state.mode==="feedback",event=state.episode.events[state.decision];
$("observe-mode").classList.toggle("active",!feedback);$("feedback-mode").classList.toggle("active",feedback);
$("observe-mode").setAttribute("aria-pressed",String(!feedback));$("feedback-mode").setAttribute("aria-pressed",String(feedback));
$("feedback-note").hidden=!feedback;$("residuals").hidden=!feedback;
$("annotation-progress").hidden=!(feedback&&event.generated_annotation);
text("media-type",feedback?"RECORDED FEEDBACK":"RECORDED OBSERVATION");
text("feedback-description",`Frame ${event.end}, after ${event.feedback.steps_elapsed} recorded steps. These frames follow the original dense demonstration commands. Object outcome is unverified.`);
const residuals=$("residuals");residuals.replaceChildren();
if(feedback){
for(const label of [`Δ position ${(event.feedback.position_residual_m*100).toFixed(2)} cm`,`Δ angle ${(event.feedback.orientation_residual_rad*180/Math.PI).toFixed(1)}°`]){
const el=document.createElement("span");el.textContent=label;residuals.append(el);
}
}
}
function setCamera(camera) {
state.camera=camera;
const all=camera==="all";$("camera-window").classList.toggle("all",all);
video.style.transform=all?"none":`translateX(${-Number(camera)*100/3}%)`;
text("camera-label",all?"ALL THREE VIEWS":["EXTERIOR 1","EXTERIOR 2","WRIST"][camera]);
document.querySelectorAll("[data-camera],[data-snapshot]").forEach(b=>{
const active=(b.dataset.camera??b.dataset.snapshot)===String(camera);b.classList.toggle("active",active);b.setAttribute("aria-pressed",String(active));
});
}
function renderVector(id,axes,recorded,target,kind) {
const box=$(id);
if(!box.childElementCount)axes.forEach(()=>{
const row=document.createElement("div");row.className=kind;
["axis","recorded","target"].forEach(cls=>{const cell=document.createElement("span");cell.className=cls;row.append(cell);});box.append(row);
});
axes.forEach((axis,i)=>[axis,fmt(recorded[i]),fmt(target[i])].forEach((value,column)=>{
const cell=box.children[i].children[column];if(cell.textContent!==value)cell.textContent=value;
}));
}
function renderFrame(frame) {
if(!state.episode)return;
frame=Math.max(0,Math.min(state.episode.metadata.steps-1,frame));state.frame=frame;
if(state.mode==="observe"){
const decision=findDecision(frame);if(decision!==state.decision){state.decision=decision;renderDecision();}
}
const recorded=state.episode.pose_trace[frame],target=state.episode.events[state.decision].action.arguments;
text("stage-frame",`FRAME ${String(frame).padStart(4,"0")}`);
text("frame-count",`${frame+1} / ${state.episode.metadata.steps.toLocaleString()} frames · nominal 15 fps`);
text("timecode",`${clock(frame/state.episode.fps)} / ${clock(state.episode.metadata.duration_s)}`);
$("scrubber").value=frame;
$("scrubber").style.background=`linear-gradient(to right,#bf4c2b ${100*frame/Math.max(1,state.episode.metadata.steps-1)}%,#dde2d5 0)`;
renderVector("pose-rows",["X","Y","Z"],recorded,target.target_pose.position_m,"pose-row");
renderVector("quaternion-values",["x","y","z","w"],recorded.slice(3,7),target.target_pose.quaternion_xyzw,"quaternion-row");
const grip=Math.max(0,Math.min(1,recorded[7])),goal=target.gripper_closure;
text("gripper-state",grip<.1?"Open":grip>.9?"Closed":"Partial");
$("gripper-fill").style.width=`${100*grip}%`;$("gripper-target").style.left=`${100*goal}%`;
text("gripper-value",grip.toFixed(2));text("gripper-target-value",goal.toFixed(2));
$("previous-frame").disabled=frame===0;$("next-frame").disabled=frame===state.episode.metadata.steps-1;
renderPath(frame,target.target_pose.position_m);
}
function renderPath(frame,target) {
const key=`${state.id}:${state.decision}`;
if(state.path?.key!==key){
const trace=state.episode.pose_trace;
let xmin=Infinity,xmax=-Infinity,ymin=Infinity,ymax=-Infinity;
trace.forEach(p=>{xmin=Math.min(xmin,p[0]);xmax=Math.max(xmax,p[0]);ymin=Math.min(ymin,p[1]);ymax=Math.max(ymax,p[1]);});
xmin=Math.min(xmin,target[0]);xmax=Math.max(xmax,target[0]);ymin=Math.min(ymin,target[1]);ymax=Math.max(ymax,target[1]);
const range=Math.max(xmax-xmin,ymax-ymin,.08),cx=(xmin+xmax)/2,cy=(ymin+ymax)/2;
const project=p=>[130+(p[0]-cx)*120/range,79-(p[1]-cy)*120/range];
const points=trace.map(project),full=points.map(p=>p.map(v=>v.toFixed(1)).join(",")).join(" ");
const goal=project(target);
$("path-plot").innerHTML=`<path d="M25 28h210M25 78h210M25 128h210M55 15v130M130 15v130M205 15v130" stroke="#dce4d0" stroke-width=".6" fill="none"/><polyline points="${full}" fill="none" stroke="#c4d3af" stroke-width="1.4"/><polyline id="path-past" fill="none" stroke="#6c8b51" stroke-width="1.7"/><path id="path-command" stroke="#bf4c2b" stroke-width=".8" stroke-dasharray="3 3"/><circle cx="${goal[0]}" cy="${goal[1]}" r="3.1" fill="#bf4c2b"/><circle id="path-current" r="4" fill="#416a2b" stroke="#f5f3ee" stroke-width="1.5"/><text x="239" y="159" fill="#768e5a" font-size="8" font-family="monospace">x</text><text x="13" y="18" fill="#768e5a" font-size="8" font-family="monospace">y</text>`;
state.path={key,points,goal,serialized:points.map(p=>p.map(v=>v.toFixed(1)).join(","))};
}
const {points,goal,serialized}=state.path,current=points[frame];
$("path-past").setAttribute("points",serialized.slice(0,frame+1).join(" "));
$("path-command").setAttribute("d",`M${current[0]} ${current[1]}L${goal[0]} ${goal[1]}`);
$("path-current").setAttribute("cx",current[0]);$("path-current").setAttribute("cy",current[1]);
}
function renderSource() {
const meta=state.episode.metadata,box=$("source-details");box.replaceChildren();
const pairs=[["Episode ID",meta.episode_id],["Dataset split",meta.split],["Task annotation",meta.instruction_available?meta.task_source.replaceAll("_"," "):"No task instruction available"],["Recording",`${meta.lab} / ${meta.recorded_date}`],["Source and license","See dataset NOTICE"]];
const alternatives=[...new Set((meta.instruction_variants||[]).filter(Boolean))].filter(value=>value!==meta.task);
if(alternatives.length){pairs.push(["Alternative recorded wording",alternatives.join(" · ")]);pairs.push(["Wording note","Recorded alternatives can differ. Generated annotations use the primary instruction and do not resolve intended direction or object state."]);}
pairs.forEach(([key,value])=>{const dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=key;dd.textContent=value;box.append(dt,dd);});
}
function seekFrame(frame,{pause=true}={}) {
if(!state.episode)return;
if(pause)video.pause();
frame=Math.max(0,Math.min(state.episode.metadata.steps-1,frame));
if(state.videoReady)video.currentTime=frame/state.episode.fps+.0001;
else video.dataset.initialFrame=frame;
renderFrame(frame);saveLocation();
}
function jumpDecision(index) {
if(!state.episode)return;
index=Math.max(0,Math.min(state.episode.events.length-1,index));state.mode="observe";state.decision=index;
renderDecision();seekFrame(state.episode.events[index].start);
}
function setMode(mode) {
if(!state.episode)return;
video.pause();state.mode=mode;
const event=state.episode.events[state.decision];renderMode();updateSnapshots();
seekFrame(mode==="feedback"?event.end:event.start);
}
async function togglePlay() {
if(!state.episode||!state.videoReady)return;
if(!video.paused){video.pause();return;}
if(state.mode==="feedback")setMode("observe");
if(state.frame>=state.episode.metadata.steps-1)seekFrame(0);
try{await video.play();}catch{toast("Playback could not start. Try again.");}
}
function updatePlayIcon() {
const playing=!video.paused;
$("play").setAttribute("aria-label",playing?"Pause episode":"Play episode");
$("play-icon").innerHTML=playing?'<path d="M5 4h3v12H5zm7 0h3v12h-3z"/>':'<path d="m7 4 9 6-9 6z"/>';
}
function saveLocation() {
if(state.id)history.replaceState(null,"",`#episode=${state.id}&frame=${state.frame}`);
}
video.addEventListener("loadedmetadata",()=>{
state.videoReady=true;$("play").disabled=false;video.playbackRate=Number($("speed").value);
const frame=Number(video.dataset.initialFrame||0);video.currentTime=frame/state.episode.fps+.0001;
renderFrame(frame);$("video-loading").hidden=true;$("video-error").hidden=true;
});
video.addEventListener("canplay",()=>{$("video-loading").hidden=true;});
video.addEventListener("waiting",()=>{if(!video.paused)$("video-loading").hidden=false;});
video.addEventListener("playing",()=>{$("video-loading").hidden=true;});
video.addEventListener("error",()=>{state.videoReady=false;$("play").disabled=true;$("video-loading").hidden=true;$("video-error").hidden=false;});
video.addEventListener("play",updatePlayIcon);video.addEventListener("pause",()=>{updatePlayIcon();saveLocation();});
video.addEventListener("ended",()=>{updatePlayIcon();renderFrame(state.episode.metadata.steps-1);saveLocation();});
video.addEventListener("timeupdate",()=>{if(state.episode&&state.videoReady){const frame=Math.min(state.episode.metadata.steps-1,Math.floor(video.currentTime*state.episode.fps+.001));if(frame!==state.frame)renderFrame(frame);}});
if(video.requestVideoFrameCallback){
const callback=(now,metadata)=>{if(state.episode&&state.videoReady&&!video.paused){const frame=Math.floor(metadata.mediaTime*state.episode.fps+.001);if(frame!==state.frame)renderFrame(frame);}video.requestVideoFrameCallback(callback);};
video.requestVideoFrameCallback(callback);
}
$("play").addEventListener("click",togglePlay);video.addEventListener("click",togglePlay);
$("previous-frame").addEventListener("click",()=>{state.mode="observe";renderMode();updateSnapshots();seekFrame(state.frame-1);});
$("next-frame").addEventListener("click",()=>{state.mode="observe";renderMode();updateSnapshots();seekFrame(state.frame+1);});
$("scrubber").addEventListener("input",()=>{state.mode="observe";renderMode();updateSnapshots();seekFrame(Number($("scrubber").value));});
$("previous-decision").addEventListener("click",()=>jumpDecision(state.decision-1));$("next-decision").addEventListener("click",()=>jumpDecision(state.decision+1));
[[-1,"previous-episode"],[1,"next-episode"]].forEach(([direction,id])=>$(id).addEventListener("click",()=>{
const episodes=filteredEpisodes(),index=episodes.findIndex(e=>e.episode_id===state.id);
if(episodes[index+direction])openEpisode(episodes[index+direction].episode_id);
}));
$("subgoal-select").addEventListener("change",()=>jumpSubgoal(Number($("subgoal-select").value)));
$("previous-subgoal").addEventListener("click",()=>jumpSubgoal(Number($("subgoal-select").value)-1));
$("next-subgoal").addEventListener("click",()=>jumpSubgoal(Number($("subgoal-select").value)+1));
$("observe-mode").addEventListener("click",()=>setMode("observe"));$("feedback-mode").addEventListener("click",()=>setMode("feedback"));
$("speed").addEventListener("change",()=>{video.playbackRate=Number($("speed").value);});
$("retry-video").addEventListener("click",()=>{video.load();$("video-error").hidden=true;$("video-loading").hidden=false;});
document.querySelectorAll("[data-camera]").forEach(b=>b.addEventListener("click",()=>setCamera(b.dataset.camera==="all"?"all":Number(b.dataset.camera))));
document.querySelectorAll("[data-snapshot]").forEach(b=>b.addEventListener("click",()=>setCamera(Number(b.dataset.snapshot))));
$("fullscreen").addEventListener("click",async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else if($("stage").requestFullscreen)await $("stage").requestFullscreen();else toast("Fullscreen isn’t available in this browser.");}catch{toast("Fullscreen isn’t available in this browser.");}});
["search","outcome-filter","split-filter"].forEach(id=>$(id).addEventListener(id==="search"?"input":"change",()=>{if(state.manifest)renderLibrary();}));
$("reset-filters").addEventListener("click",()=>{$("search").value="";$("outcome-filter").value="all";$("split-filter").value="all";renderLibrary();});
$("download").addEventListener("click",()=>{
if(!state.episode)return;
const blob=new Blob([JSON.stringify(state.episode,null,2)],{type:"application/json"}),url=URL.createObjectURL(blob),a=document.createElement("a");
a.href=url;a.download=`droid-${state.id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("Episode JSON downloaded.");
});
document.addEventListener("keydown",event=>{
if(document.body.classList.contains("library-open")){
if(event.key==="Escape"){event.preventDefault();setLibrary(false);}
if(event.key==="Tab"){
const elements=[...$("episode-library").querySelectorAll('button:not(:disabled),input,select')].filter(el=>el.offsetParent!==null);
const first=elements[0],last=elements[elements.length-1];
if(event.shiftKey&&(document.activeElement===first||!$("episode-library").contains(document.activeElement))){event.preventDefault();last.focus();}
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
}
return;
}
if(["INPUT","SELECT","TEXTAREA","SUMMARY","BUTTON","A"].includes(event.target.tagName)||event.ctrlKey||event.metaKey||event.altKey||!state.episode)return;
if(event.code==="Space"){event.preventDefault();togglePlay();}
else if(event.key==="ArrowLeft"||event.key==="ArrowRight"){
event.preventDefault();state.mode="observe";renderMode();updateSnapshots();seekFrame(state.frame+(event.key==="ArrowLeft"?-1:1));
}else if(event.key.toLowerCase()==="j")jumpDecision(state.decision-1);
else if(event.key.toLowerCase()==="k")jumpDecision(state.decision+1);
});
async function boot() {
try {
const response=await fetch("./manifest.json");if(!response.ok)throw new Error("The dataset library could not be loaded.");
state.manifest=await response.json();
text("dataset-summary",`${state.manifest.episodes.length} episodes · ${state.manifest.total_frames.toLocaleString()} frames · 3 cameras`);
renderLibrary();
const hash=new URLSearchParams(location.hash.slice(1)),id=hash.get("episode");
const episode=state.manifest.episodes.find(e=>e.episode_id===id)||state.manifest.episodes[0];
await openEpisode(episode.episode_id,Math.max(0,Number(hash.get("frame"))||0));
}catch(error){text("initial-loader",error.message);$("workspace").setAttribute("aria-busy","false");}
}
boot();