import "./style.css"; type Config = {assetsBaseUrl:string;indexPath:string;datasetUrl:string;schemaUrl:string;assetsRevision:string;mediaPrefetchConcurrency?:number;mediaFetchMaxAttempts?:number;mediaFetchRetryBaseMs?:number;mediaFetchTimeoutMs?:number}; type IndexRow = {sample_id:string;path:string;poster:string;town:string;speed:string;condition:string;subset:string;asset_bytes:number}; type Index = {schema_version:string;sample_count:number;samples:IndexRow[]}; type Media = {path:string;bytes:number;duration_s:number;frame_count:number;frame_rate:string;sha256:string}; type Manifest = {sample_id:string;scenario:string;town:string;speed:string;condition:string;poster:string;summary:string;asset_bytes:number;playback:{animation_fps:number;animation_frame_count:number;media_duration_s:number};synchronization:{valid:boolean;policy:string};media:Record}; const ids=["birdview","cir","cav_2x2","cav_lidar","rsu_2x2","rsu_lidar_radar","csi_amplitude","csi_phase"] as const; type MediaId=typeof ids[number]; const $=(id:string)=>document.getElementById(id) as T; const videos=()=>ids.map(id=>$(id)); const state:{config?:Config;index?:Index;filtered:IndexRow[];row?:IndexRow;manifest?:Manifest;playing:boolean;raf:number;loadId:number;request?:AbortController;objectUrls:string[];ready:Set;failed:Set}={filtered:[],playing:false,raf:0,loadId:0,objectUrls:[],ready:new Set(),failed:new Set()}; const join=(...parts:string[])=>parts.map((part,index)=>index?part.replace(/^\/+/,""):part.replace(/\/+$/,"")).join("/"); const asset=(path:string)=>join(state.config!.assetsBaseUrl.replace("{revision}",state.config!.assetsRevision),path); async function fetchJson(url:string,signal?:AbortSignal,cache:RequestCache="force-cache"):Promise{const response=await fetch(url,{signal,cache});if(!response.ok)throw new Error(`${response.status} ${url}`);return response.json() as Promise} function unique(values:string[]){return [...new Set(values)].sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}))} function options(select:HTMLSelectElement,values:string[],preferred?:string,allLabel="All"){select.replaceChildren(...values.map(value=>new Option(value||allLabel,value)));if(preferred&&values.includes(preferred))select.value=preferred} function currentRow(){return state.filtered.find(row=>row.path===$("sample").value)} function notice(text:string,error=false){const box=$("notice");box.textContent=text;box.classList.toggle("error",error)} function previewSelection(){const row=currentRow();if(!row)return;const bird=$("birdview");if(!bird.src)bird.poster=asset(row.poster);notice(`Selected ${row.sample_id}. Click Load sample to fetch its synchronized media.`)} function applyFilters(){ if(!state.index)return; const old=$("sample").value; const town=$("town").value,speed=$("speed").value,condition=$("condition").value; state.filtered=state.index.samples.filter(row=>(!town||row.town===town)&&(!speed||row.speed===speed)&&(!condition||row.condition===condition)); const sample=$("sample"); sample.replaceChildren(...state.filtered.map((row,index)=>new Option(`${String(index+1).padStart(2,"0")} · ${row.sample_id}`,row.path))); if(state.filtered.some(row=>row.path===old))sample.value=old; previewSelection(); } function rebuildFilters(changed:"town"|"speed"|"condition"){ if(!state.index)return; const town=$("town"),speed=$("speed"),condition=$("condition"); if(changed==="town"){options(speed,["",...unique(state.index.samples.filter(row=>!town.value||row.town===town.value).map(row=>row.speed))],speed.value,"All speeds");} if(changed!=="condition"){options(condition,["",...unique(state.index.samples.filter(row=>(!town.value||row.town===town.value)&&(!speed.value||row.speed===speed.value)).map(row=>row.condition))],condition.value,"All conditions");} applyFilters(); } function setSelection(delta:number){ if(!state.filtered.length)return; const select=$("sample"); const current=state.filtered.findIndex(row=>row.path===select.value); const index=current>=0?current:(delta<0?0:-1); select.value=state.filtered[(index+delta+state.filtered.length)%state.filtered.length].path; previewSelection(); void loadSample(); } function chooseRow(row:IndexRow,load=false){ const town=$("town"),speed=$("speed"),condition=$("condition"),sample=$("sample"); town.value=row.town;rebuildFilters("town"); speed.value=row.speed;rebuildFilters("speed"); condition.value=row.condition;applyFilters(); sample.value=row.path;previewSelection(); if(load)void loadSample(); } function chooseRandomRow(){ const rows=state.index?.samples??[]; const selected=$("sample").value; const candidates=rows.filter(row=>row.path!==selected); const pool=candidates.length?candidates:rows; if(!pool.length)return; chooseRow(pool[Math.floor(Math.random()*pool.length)],true); } function abortableDelay(milliseconds:number,signal:AbortSignal){ return new Promise((resolve,reject)=>{ if(signal.aborted){reject(signal.reason);return} const timer=window.setTimeout(done,milliseconds); function done(){cleanup();resolve()} function abort(){cleanup();reject(signal.reason)} function cleanup(){window.clearTimeout(timer);signal.removeEventListener("abort",abort)} signal.addEventListener("abort",abort,{once:true}); }); } async function fetchMediaBlob(url:string,signal:AbortSignal,timeoutMs:number){ const request=new AbortController(); const abort=()=>request.abort(signal.reason); if(signal.aborted)abort();else signal.addEventListener("abort",abort,{once:true}); const timer=window.setTimeout(()=>request.abort(new DOMException(`Timed out after ${timeoutMs} ms`,"TimeoutError")),timeoutMs); try{ const response=await fetch(url,{signal:request.signal,cache:"force-cache"}); if(!response.ok){const error=new Error(`${response.status} ${url}`);Object.assign(error,{status:response.status});throw error} return await response.blob(); }finally{ window.clearTimeout(timer);signal.removeEventListener("abort",abort); } } function retryable(error:unknown){const status=Number((error as {status?:number})?.status??0);return !status||status===408||status===429||status>=500} async function sourceVideo(id:MediaId,loadId:number,signal:AbortSignal){ if(loadId!==state.loadId||!state.manifest||!state.row)return; const video=$(id),media=state.manifest.media[id]; if(!media)return; const mediaUrl=`${asset(join(state.row.path.replace(/manifest\.v1\.json$/,""),media.path))}?v=${media.sha256.slice(0,12)}`; const attempts=Math.max(1,state.config?.mediaFetchMaxAttempts??3); const retryBaseMs=Math.max(100,state.config?.mediaFetchRetryBaseMs??750); const timeoutMs=Math.max(5000,state.config?.mediaFetchTimeoutMs??60000); for(let attempt=1;attempt<=attempts;attempt++){ let objectUrl:string|undefined; try{ const blob=await fetchMediaBlob(mediaUrl,signal,timeoutMs); if(blob.size!==media.bytes)throw new Error(`${id}: expected ${media.bytes} bytes, received ${blob.size}`); if(loadId!==state.loadId)return; objectUrl=URL.createObjectURL(blob);state.objectUrls.push(objectUrl); video.preload="auto";video.loop=true;video.src=objectUrl; video.load(); await mediaReady(video); return; }catch(error){ if(objectUrl){video.removeAttribute("src");video.load();URL.revokeObjectURL(objectUrl);state.objectUrls=state.objectUrls.filter(url=>url!==objectUrl)} if(signal.aborted)throw error; if(attempt>=attempts||!retryable(error))throw new Error(`${id} failed after ${attempt} attempt${attempt===1?"":"s"}: ${error instanceof Error?error.message:String(error)}`); notice(`Network interruption while caching ${id}; retrying ${attempt+1}/${attempts}…`); await abortableDelay(retryBaseMs*2**(attempt-1),signal); } } } function clearVideos(){videos().forEach(video=>{video.pause();video.removeAttribute("src");video.load();});state.objectUrls.forEach(url=>URL.revokeObjectURL(url));state.objectUrls=[];state.ready.clear();state.failed.clear()} function mediaReady(video:HTMLVideoElement){return new Promise((resolve,reject)=>{if(video.readyState>=1)return resolve();const done=()=>{cleanup();resolve()},fail=()=>{cleanup();reject(new Error(`Failed to load ${video.id}`))},cleanup=()=>{video.removeEventListener("loadedmetadata",done);video.removeEventListener("error",fail)};video.addEventListener("loadedmetadata",done,{once:true});video.addEventListener("error",fail,{once:true});});} async function runConcurrent(items:readonly T[],limit:number,worker:(item:T)=>Promise){let cursor=0;const count=Math.max(1,Math.min(limit,items.length));await Promise.all(Array.from({length:count},async()=>{while(cursor0;if(!resume){clearVideos();state.row=row;state.manifest=undefined}$("play").setAttribute("disabled","");$("seek").disabled=true;$("sync").textContent="Caching";$("sync").classList.remove("ready");$("load").textContent="Load sample"; const controller=new AbortController();state.request=controller;notice(`Loading ${row.sample_id} manifest…`);$("viewer").setAttribute("aria-busy","true"); history.replaceState(null,"",`?sample=${encodeURIComponent(row.sample_id)}`); try{ const manifest=state.manifest??await fetchJson(asset(row.path),controller.signal);if(loadId!==state.loadId)return;state.manifest=manifest; const pending=ids.filter(id=>!state.ready.has(id));state.failed.clear();const failures:Array<{id:MediaId;error:unknown}>=[]; const concurrency=Math.max(1,Math.min(ids.length,state.config?.mediaPrefetchConcurrency??2)); await runConcurrent(pending,concurrency,async id=>{try{await sourceVideo(id,loadId,controller.signal);state.ready.add(id);state.failed.delete(id)}catch(error){if(controller.signal.aborted)throw error;state.failed.add(id);failures.push({id,error})}if(loadId===state.loadId)notice(`Caching ${manifest.sample_id}: ${state.ready.size}/${ids.length} synchronized media streams…`)});if(loadId!==state.loadId)return; if(failures.length){const names=failures.map(item=>item.id).join(", ");$("load").textContent="Retry failed media";$("sync").textContent=`${state.ready.size}/${ids.length} cached`;notice(`Loaded ${state.ready.size}/${ids.length} streams. Failed: ${names}. Click Retry failed media; completed downloads will be kept.`,true);return} const seek=$("seek");seek.max=String(Math.max(0,manifest.playback.animation_frame_count-1));seek.value="0";seek.disabled=false;$("play").removeAttribute("disabled"); $("sync").textContent=manifest.synchronization.valid?"Synchronized":"Sync warning";$("sync").classList.toggle("ready",manifest.synchronization.valid); $("details").textContent=`${manifest.scenario} · ${manifest.town} · ${manifest.speed} · ${manifest.condition} · ${(manifest.asset_bytes/1048576).toFixed(1)} MiB · ${manifest.playback.animation_frame_count} frames at ${manifest.playback.animation_fps} fps`; notice(`Ready: ${manifest.sample_id}. All eight synchronized media streams are cached locally and paused.`);seekFrame(0); }catch(error){if(loadId===state.loadId&&!controller.signal.aborted){$("load").textContent=state.ready.size?"Retry failed media":"Load sample";notice(error instanceof Error?error.message:String(error),true)}}finally{if(loadId===state.loadId){state.request=undefined;$("viewer").setAttribute("aria-busy","false")}} } function seekFrame(frame:number){if(!state.manifest)return;const seconds=frame/state.manifest.playback.animation_fps;videos().filter(video=>video.src).forEach(video=>{if(Number.isFinite(video.duration))video.currentTime=Math.min(seconds,Math.max(0,video.duration-.001))});$("seek").value=String(frame);$("time").textContent=`${seconds.toFixed(3)} s`} function tick(){if(!state.playing||!state.manifest)return;const master=$("birdview"),seconds=master.currentTime||0;const frame=Math.min(state.manifest.playback.animation_frame_count-1,Math.floor(seconds*state.manifest.playback.animation_fps));$("seek").value=String(frame);$("time").textContent=`${seconds.toFixed(3)} s`;videos().filter(video=>video!==master&&video.src).forEach(video=>{if(video.readyState>=2&&Math.abs(video.currentTime-seconds)>.1)video.currentTime=seconds});state.raf=requestAnimationFrame(tick)} function play(){if(!state.manifest)return;if(state.playing){stop();return}state.playing=true;$("play").textContent="Pause";videos().filter(video=>video.src).forEach(video=>video.play().catch(error=>console.warn(`Playback join failed for ${video.id}`,error)));state.raf=requestAnimationFrame(tick)} function stop(){state.playing=false;cancelAnimationFrame(state.raf);videos().forEach(video=>video.pause());$("play").textContent="Play"} async function init(){ state.config=await fetchJson("./demo-config.json",undefined,"no-store"); $("datasetLink").setAttribute("href",state.config.datasetUrl);$("schemaLink").setAttribute("href",state.config.schemaUrl); state.index=await fetchJson(asset(state.config.indexPath),undefined,"no-store"); const requested=new URLSearchParams(location.search).get("sample"),initial=state.index.samples.find(item=>item.sample_id===requested)||state.index.samples[0]; if(!initial)throw new Error("The demo asset index does not contain any samples"); options($("town"),["",...unique(state.index.samples.map(row=>row.town))],initial.town,"All towns"); options($("speed"),["",...unique(state.index.samples.filter(row=>row.town===initial.town).map(row=>row.speed))],initial.speed,"All speeds"); options($("condition"),["",...unique(state.index.samples.filter(row=>row.town===initial.town&&row.speed===initial.speed).map(row=>row.condition))],initial.condition,"All conditions"); applyFilters();$("sample").value=initial.path;previewSelection(); $("town").onchange=()=>rebuildFilters("town");$("speed").onchange=()=>rebuildFilters("speed");$("condition").onchange=()=>rebuildFilters("condition"); $("sample").onchange=previewSelection;$("previous").onclick=()=>setSelection(-1);$("next").onclick=()=>setSelection(1);$("random").onclick=chooseRandomRow;$("load").onclick=loadSample;$("play").onclick=play; $("seek").oninput=event=>{stop();seekFrame(Number((event.target as HTMLInputElement).value))}; await loadSample(); } init().catch(error=>notice(error instanceof Error?error.stack||error.message:String(error),true));