Spaces:
Running
Running
| 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<string,Media>}; | |
| 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 $=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T; | |
| const videos=()=>ids.map(id=>$<HTMLVideoElement>(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<MediaId>;failed:Set<MediaId>}={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<T>(url:string,signal?:AbortSignal,cache:RequestCache="force-cache"):Promise<T>{const response=await fetch(url,{signal,cache});if(!response.ok)throw new Error(`${response.status} ${url}`);return response.json() as Promise<T>} | |
| 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===$<HTMLSelectElement>("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=$<HTMLVideoElement>("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=$<HTMLSelectElement>("sample").value; | |
| const town=$<HTMLSelectElement>("town").value,speed=$<HTMLSelectElement>("speed").value,condition=$<HTMLSelectElement>("condition").value; | |
| state.filtered=state.index.samples.filter(row=>(!town||row.town===town)&&(!speed||row.speed===speed)&&(!condition||row.condition===condition)); | |
| const sample=$<HTMLSelectElement>("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=$<HTMLSelectElement>("town"),speed=$<HTMLSelectElement>("speed"),condition=$<HTMLSelectElement>("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=$<HTMLSelectElement>("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=$<HTMLSelectElement>("town"),speed=$<HTMLSelectElement>("speed"),condition=$<HTMLSelectElement>("condition"),sample=$<HTMLSelectElement>("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=$<HTMLSelectElement>("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<void>((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=$<HTMLVideoElement>(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<void>((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<T>(items:readonly T[],limit:number,worker:(item:T)=>Promise<void>){let cursor=0;const count=Math.max(1,Math.min(limit,items.length));await Promise.all(Array.from({length:count},async()=>{while(cursor<items.length){const item=items[cursor++];await worker(item)}}))} | |
| async function loadSample(){ | |
| const row=currentRow();if(!row)return; | |
| state.request?.abort();stop();const loadId=++state.loadId;const resume=state.row?.path===row.path&&!!state.manifest&&state.ready.size>0;if(!resume){clearVideos();state.row=row;state.manifest=undefined}$("play").setAttribute("disabled","");$<HTMLInputElement>("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<Manifest>(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=$<HTMLInputElement>("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))});$<HTMLInputElement>("seek").value=String(frame);$("time").textContent=`${seconds.toFixed(3)} s`} | |
| function tick(){if(!state.playing||!state.manifest)return;const master=$<HTMLVideoElement>("birdview"),seconds=master.currentTime||0;const frame=Math.min(state.manifest.playback.animation_frame_count-1,Math.floor(seconds*state.manifest.playback.animation_fps));$<HTMLInputElement>("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<Config>("./demo-config.json",undefined,"no-store"); | |
| $("datasetLink").setAttribute("href",state.config.datasetUrl);$("schemaLink").setAttribute("href",state.config.schemaUrl); | |
| state.index=await fetchJson<Index>(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($<HTMLSelectElement>("town"),["",...unique(state.index.samples.map(row=>row.town))],initial.town,"All towns"); | |
| options($<HTMLSelectElement>("speed"),["",...unique(state.index.samples.filter(row=>row.town===initial.town).map(row=>row.speed))],initial.speed,"All speeds"); | |
| options($<HTMLSelectElement>("condition"),["",...unique(state.index.samples.filter(row=>row.town===initial.town&&row.speed===initial.speed).map(row=>row.condition))],initial.condition,"All conditions"); | |
| applyFilters();$<HTMLSelectElement>("sample").value=initial.path;previewSelection(); | |
| $<HTMLSelectElement>("town").onchange=()=>rebuildFilters("town");$<HTMLSelectElement>("speed").onchange=()=>rebuildFilters("speed");$<HTMLSelectElement>("condition").onchange=()=>rebuildFilters("condition"); | |
| $<HTMLSelectElement>("sample").onchange=previewSelection;$("previous").onclick=()=>setSelection(-1);$("next").onclick=()=>setSelection(1);$("random").onclick=chooseRandomRow;$("load").onclick=loadSample;$("play").onclick=play; | |
| $<HTMLInputElement>("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)); | |