mfzheng's picture
Finalize retry-safe media cache cleanup
d6b9968 verified
Raw
History Blame Contribute Delete
15.2 kB
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));