Spaces:
Running
Running
Download app.js from windjj/embodiedtrace: direct link, hf CLI and curl.
- Browser
- Download file 28.7 kB
-
https://huggingface.co/spaces/windjj/embodiedtrace/resolve/main/app.js
- Command line
-
hf download hf://spaces/windjj/embodiedtrace/app.js
-
curl -L -o app.js https://huggingface.co/spaces/windjj/embodiedtrace/resolve/main/app.js
28.7 kB
| ; | |
| 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(); | |