| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>Motius SMPL comparison</title> |
| <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin> |
| <link rel="preconnect" href="https://huggingface.co" crossorigin> |
| <link rel="preload" href="manifest.json" as="fetch" crossorigin="anonymous"> |
| <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/@comfyorg/fbx-exporter-three@1.0.1/dist/index.js" crossorigin> |
| <style> |
| :root{color-scheme:light;--ink:#15201c;--muted:#66736d;--line:#d7dfdb;--paper:#fff;--wash:#eef2f0;--accent:#087d72;--header:72px} |
| *{box-sizing:border-box}html,body{height:100%;margin:0}body{overflow:hidden;color:var(--ink);background:var(--wash);font:14px/1.45 ui-sans-serif,system-ui,sans-serif;letter-spacing:0} |
| button,input,select{font:inherit;letter-spacing:0}.app{height:100%;display:grid;grid-template-rows:var(--header) minmax(0,1fr)} |
| .topbar{display:grid;grid-template-columns:minmax(260px,1fr) auto auto minmax(190px,auto) minmax(300px,auto);gap:10px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--paper)} |
| .search,input,select,.icon{height:38px;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--ink)}input,select{width:100%;padding:0 11px}.icon{width:38px;display:grid;place-items:center;cursor:pointer;font-size:17px}.icon:hover{border-color:var(--accent);color:var(--accent)} |
| .playback{display:grid;grid-template-columns:38px minmax(140px,260px) 82px minmax(190px,280px);gap:9px;align-items:center}.playback input{height:22px;padding:0;accent-color:var(--accent)}.playback audio{width:100%;height:34px}.frame{color:var(--muted);font:12px ui-monospace,monospace;text-align:right} |
| .workspace{min-height:0;display:grid;grid-template-columns:260px minmax(0,1fr)} |
| .case-panel{min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);border-right:1px solid var(--line);background:var(--paper)}.case-summary{padding:12px 14px;border-bottom:1px solid var(--line);color:var(--muted);font:12px ui-monospace,monospace}.case-list{overflow:auto;padding:6px}.case-button{width:100%;display:grid;grid-template-columns:56px minmax(0,1fr);gap:8px;padding:9px 8px;border:0;border-left:3px solid transparent;background:transparent;text-align:left;cursor:pointer}.case-button:hover{background:#f2f6f4}.case-button.active{border-left-color:var(--accent);background:#e9f4f1}.case-number{color:var(--muted);font:11px ui-monospace,monospace}.case-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650} |
| .main{min-height:0;overflow:auto}.case-head{position:sticky;top:0;z-index:5;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:18px;padding:15px 20px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.96);backdrop-filter:blur(10px)}.eyebrow{color:var(--accent);font:700 11px ui-monospace,monospace;text-transform:uppercase}.case-head h1{margin:2px 0 5px;font-size:22px;line-height:1.2;overflow-wrap:anywhere}.captions,.condition-legend{display:flex;flex-wrap:wrap;gap:6px}.condition-legend{margin-top:7px}.caption,.legend-chip{padding:4px 7px;border:1px solid var(--line);border-radius:3px;color:#42504a;background:#f8faf9}.caption.active{border-color:var(--accent);color:var(--ink);background:#e9f4f1}.legend-chip{display:flex;align-items:center;gap:6px;font-size:12px}.legend-dot{width:9px;height:9px;border-radius:50%}.loading{align-self:center;color:var(--muted);font:12px ui-monospace,monospace} |
| .gallery-wrap{padding:16px 18px 28px}.gallery{position:relative;display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:12px}.gallery.comparison-four{grid-template-columns:repeat(2,minmax(270px,1fr))}.render-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:0;pointer-events:none}.tile{position:relative;z-index:1;height:300px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:transparent;box-shadow:0 3px 12px rgba(19,34,28,.06)}.tile-head{position:absolute;top:0;left:0;right:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:8px;height:38px;padding:0 8px 0 10px;border-bottom:1px solid rgba(215,223,219,.88);background:rgba(255,255,255,.9);backdrop-filter:blur(7px)}.method{display:flex;align-items:center;gap:8px;font-weight:750}.swatch{width:4px;height:17px;border-radius:2px}.tile-actions{display:flex;align-items:center;gap:4px}.download{height:25px;padding:0 6px;border:1px solid var(--line);border-radius:3px;background:#fff;color:var(--muted);cursor:pointer;font:10px ui-monospace,monospace}.download:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}.download:disabled{cursor:wait;opacity:.45}.motion-meta{color:var(--muted);font:11px ui-monospace,monospace}.viewport{position:absolute;inset:38px 0 0;cursor:grab;touch-action:none}.viewport.dragging{cursor:grabbing}.output-text{position:absolute;left:9px;right:9px;bottom:9px;z-index:2;padding:7px 9px;border:1px solid rgba(255,255,255,.74);border-radius:4px;background:rgba(255,255,255,.88);backdrop-filter:blur(6px);color:#394740;font-size:12px}.text-tile{height:160px;background:#fff}.text-tile .tile-head{position:relative}.text-result{padding:58px 16px 16px;color:#25332d;font-size:15px;line-height:1.55} |
| .retry-load{color:#9d3525;border-color:#d7a89f}.tile[data-load-state="error"]{border-color:#d7a89f;background:#fff7f5}.tile[data-load-state="error"] .viewport::after{content:"Motion unavailable";position:absolute;inset:0;display:grid;place-items:center;color:#9d3525;font:12px ui-monospace,monospace} |
| @media(max-width:1100px){:root{--header:126px}.topbar{grid-template-columns:minmax(0,1fr) auto auto minmax(180px,auto)}.playback{grid-column:1/-1;grid-template-columns:38px minmax(0,1fr) 76px minmax(190px,280px)}} |
| @media(max-width:820px){.workspace{grid-template-columns:1fr}.case-panel{display:none}.gallery,.gallery.comparison-four{grid-template-columns:1fr}.tile{height:340px}.case-head{position:relative}} |
| @media(max-width:600px){:root{--header:198px}.topbar{grid-template-columns:minmax(0,1fr) 38px 38px;padding:8px}.topbar #case-select{grid-column:1/-1}.playback{grid-column:1/-1;grid-template-columns:38px minmax(0,1fr) 70px}.playback audio{grid-column:1/-1}.case-head{padding:12px 14px}.case-head h1{font-size:18px}.gallery-wrap{padding:10px}.tile{height:320px}} |
| </style> |
| <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.166.1/build/three.module.js","fflate":"https://cdn.jsdelivr.net/npm/fflate@0.8.2/esm/browser.js","@comfyorg/fbx-exporter-three":"https://cdn.jsdelivr.net/npm/@comfyorg/fbx-exporter-three@1.0.1/dist/index.js"}}</script> |
| </head> |
| <body> |
| <div class="app"> |
| <header class="topbar"> |
| <input class="search" id="search" type="search" placeholder="Search case ID or caption" aria-label="Search cases"> |
| <button class="icon" id="previous" type="button" title="Previous case" aria-label="Previous case">‹</button> |
| <button class="icon" id="next" type="button" title="Next case" aria-label="Next case">›</button> |
| <select id="case-select" aria-label="Selected case"></select> |
| <div class="playback"> |
| <button class="icon" id="play" type="button" title="Play or pause" aria-label="Play or pause">Ⅱ</button> |
| <input id="timeline" type="range" min="0" max="1" value="0" aria-label="Animation frame"> |
| <span class="frame" id="frame">0 / 0</span> |
| <audio id="audio" controls preload="metadata" hidden></audio> |
| </div> |
| </header> |
| <div class="workspace"> |
| <aside class="case-panel"><div class="case-summary" id="case-summary">Loading cases</div><div class="case-list" id="case-list"></div></aside> |
| <main class="main" id="main"> |
| <section class="case-head"><div><div class="eyebrow" id="position">Complete evaluation population</div><h1 id="case-id">Loading</h1><div class="captions" id="captions"></div><div class="condition-legend" id="condition-legend"></div></div><div class="loading" id="loading">Loading SMPL motions</div></section> |
| <div class="gallery-wrap"><div class="gallery" id="gallery"><canvas class="render-canvas" id="canvas"></canvas></div></div> |
| </main> |
| </div> |
| </div> |
| <script type="module"> |
| import * as THREE from "three"; |
| |
| const $=selector=>document.querySelector(selector); |
| const canvas=$("#canvas"),gallery=$("#gallery"),main=$("#main"),loading=$("#loading"),audio=$("#audio"); |
| const renderer=new THREE.WebGLRenderer({canvas,antialias:true,alpha:false});renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.shadowMap.enabled=true;renderer.outputColorSpace=THREE.SRGBColorSpace; |
| const assetCache=new Map(),descriptorChunkCache=new Map(),fbxCache=new Map(),FBX_EXPORTER_URL="https://cdn.jsdelivr.net/npm/@comfyorg/fbx-exporter-three@1.0.1/dist/index.js",fbxExporterPromise=import(FBX_EXPORTER_URL),fbxWarmQueue=[],fbxWarmAttempted=new Set(),MAX_FETCHES=1,MIN_FETCH_INTERVAL_MS=350,SMPL_NAMES=["Pelvis","L_Hip","R_Hip","Spine1","L_Knee","R_Knee","Spine2","L_Ankle","R_Ankle","Spine3","L_Foot","R_Foot","Neck","L_Collar","R_Collar","Head","L_Shoulder","R_Shoulder","L_Elbow","R_Elbow","L_Wrist","R_Wrist","L_Hand","R_Hand"],AISTPP_PARENTS=[-1,0,0,0,1,2,3,4,5,6,7,8,9,9,9,12,13,14,16,17,18,19,20,21],Y_AXIS=new THREE.Vector3(0,1,0);let manifest,body,filtered=[],selected=0,views=[],frame=0,playing=true,lastTime=0,maxFrames=1,loadGeneration=0,currentItem=null,audioStart=0,audioEnd=Infinity,viewObserver=null,activeFetches=0,lastFetchStartedAt=0,fbxWarmActive=false;const fetchQueue=[]; |
| |
| function url(path,base){return new URL(path,base?new URL(base,location.href):location.href).href} |
| function wait(ms){return new Promise(resolve=>setTimeout(resolve,ms))} |
| function fetchWithSlot(path,init){return new Promise((resolve,reject)=>{const run=()=>{activeFetches++;const spacing=Math.max(0,MIN_FETCH_INTERVAL_MS-(performance.now()-lastFetchStartedAt));wait(spacing).then(()=>{lastFetchStartedAt=performance.now();return fetch(path,init)}).then(resolve,reject).finally(()=>{activeFetches--;const next=fetchQueue.shift();if(next)next()})};if(activeFetches<MAX_FETCHES)run();else fetchQueue.push(run)})} |
| async function fetchWithRetry(path,init={},attempts=7){let lastError;for(let attempt=0;attempt<attempts;attempt++){try{const response=await fetchWithSlot(path,init);if(response.ok)return response;const retryable=response.status===429||response.status>=500;if(!retryable||attempt===attempts-1)throw new Error(`${response.status} ${path}`);const retryAfter=Number(response.headers.get("retry-after")),delay=Number.isFinite(retryAfter)&&retryAfter>0?retryAfter*1000:Math.min(10000,1200*2**attempt);await wait(delay+Math.random()*300)}catch(error){lastError=error;if(attempt===attempts-1||(/^4\d\d /.test(error.message)&&!error.message.startsWith("429 ")))throw error;await wait(Math.min(10000,1200*2**attempt)+Math.random()*300)}}throw lastError} |
| function rememberAsset(key,pending){assetCache.set(key,pending);pending.catch(()=>{if(assetCache.get(key)===pending)assetCache.delete(key)});while(assetCache.size>96)assetCache.delete(assetCache.keys().next().value);return pending} |
| async function fetchBuffer(path){if(assetCache.has(path)){const hit=assetCache.get(path);assetCache.delete(path);assetCache.set(path,hit);return hit}return rememberAsset(path,fetchWithRetry(path,{cache:"force-cache"}).then(response=>response.arrayBuffer()))} |
| function halfFloatTable(){const table=new Float32Array(65536),bits=new Int32Array(1),value=new Float32Array(bits.buffer);for(let half=0;half<65536;half++){const sign=(half&0x8000)<<16,exponent=(half>>10)&31,fraction=half&1023;if(exponent===0){if(fraction===0)bits[0]=sign;else{let e=-14,f=fraction;while((f&1024)===0){f<<=1;e--}bits[0]=sign|((e+127)<<23)|((f&1023)<<13)}}else if(exponent===31)bits[0]=sign|0x7f800000|(fraction<<13);else bits[0]=sign|((exponent+112)<<23)|(fraction<<13);table[half]=value[0]}return table} |
| function decodeHalfArray(source){const table=halfFloatTable(),out=new Float32Array(source.length);for(let index=0;index<source.length;index++)out[index]=table[source[index]];return out} |
| async function loadBody(){const base=url(manifest.body_model_url||"smpl_model/",location.href);const meta=await fetchWithRetry(url("model.json",base),{cache:"force-cache"}).then(r=>r.json());const files=meta.files;const [vb,jb,fb,ib,wb]=await Promise.all([files.vertices,files.joints,files.faces,files.skin_indices,files.skin_weights].map(name=>fetchBuffer(url(name,base))));const positions=new Float32Array(vb),joints=new Float32Array(jb),faces=new Uint16Array(fb),indices=new Uint8Array(ib),weights=new Uint16Array(wb);const geometry=new THREE.BufferGeometry();geometry.setAttribute("position",new THREE.BufferAttribute(positions,3));geometry.setAttribute("skinIndex",new THREE.Uint8BufferAttribute(indices,4));geometry.setAttribute("skinWeight",new THREE.Uint16BufferAttribute(weights,4,true));geometry.setIndex(new THREE.BufferAttribute(faces,1));geometry.computeVertexNormals();let poseCorrectives=null;if(meta.pose_correctives){const spec=meta.pose_correctives,[basisBuffer,projectionBuffer]=await Promise.all([fetchBuffer(url(spec.files.basis,base)),fetchBuffer(url(spec.files.projection,base))]),basisBits=new Uint16Array(basisBuffer),projection=new Float32Array(projectionBuffer),texture=new THREE.DataTexture(basisBits,meta.vertices,spec.rank,THREE.RGBAFormat,THREE.HalfFloatType);texture.needsUpdate=true;texture.magFilter=THREE.NearestFilter;texture.minFilter=THREE.NearestFilter;texture.generateMipmaps=false;geometry.setAttribute("vertexIndex",new THREE.Float32BufferAttribute(Float32Array.from({length:meta.vertices},(_,index)=>index),1));poseCorrectives={rank:spec.rank,dimensions:spec.feature_dimensions,basis:decodeHalfArray(basisBits),projection,texture}}return{meta,geometry,joints,parents:meta.parents,poseCorrectives}} |
| function enablePoseCorrectives(material){const correctives=body.poseCorrectives;if(!correctives)return;const coefficients=new Float32Array(correctives.rank);material.userData.poseCorrectiveCoefficients=coefficients;material.onBeforeCompile=shader=>{shader.uniforms.poseCorrectiveBasis={value:correctives.texture};shader.uniforms.poseCorrectiveCoefficients={value:coefficients};shader.vertexShader=shader.vertexShader.replace("#include <common>",`#include <common>\nattribute float vertexIndex;\nuniform sampler2D poseCorrectiveBasis;\nuniform float poseCorrectiveCoefficients[${correctives.rank}];\nvec3 motiusPoseCorrective(){vec3 value=vec3(0.0);float u=(vertexIndex+0.5)/${body.meta.vertices}.0;for(int index=0;index<${correctives.rank};index++){float v=(float(index)+0.5)/${correctives.rank}.0;value+=texture2D(poseCorrectiveBasis,vec2(u,v)).xyz*poseCorrectiveCoefficients[index];}return value;}`).replace("#include <begin_vertex>","vec3 transformed = vec3(position) + motiusPoseCorrective();")};material.customProgramCacheKey=()=>`motius-smpl-correctives-${correctives.rank}`} |
| function buildSkinnedMesh(material){enablePoseCorrectives(material);const bones=[];for(let i=0;i<body.meta.joints;i++){const bone=new THREE.Bone(),p=body.parents[i],j=i*3;bone.name=`SMPL_${SMPL_NAMES[i]||i}`;if(p<0)bone.position.set(body.joints[j],body.joints[j+1],body.joints[j+2]);else{const q=p*3;bone.position.set(body.joints[j]-body.joints[q],body.joints[j+1]-body.joints[q+1],body.joints[j+2]-body.joints[q+2])}bones.push(bone)}for(let i=1;i<bones.length;i++)bones[body.parents[i]].add(bones[i]);const mesh=new THREE.SkinnedMesh(body.geometry,material);mesh.name="SMPL_Neutral";mesh.add(bones[0]);const skeleton=new THREE.Skeleton(bones);mesh.bind(skeleton);mesh.frustumCulled=false;mesh.castShadow=true;mesh.receiveShadow=true;return{mesh,bones}} |
| function makeEnvironment(index){const scene=new THREE.Scene();scene.background=new THREE.Color(index%2?0xf1f4f2:0xeef2f0);scene.fog=new THREE.Fog(scene.background,7,16);scene.add(new THREE.HemisphereLight(0xffffff,0x66746e,2.1));const key=new THREE.DirectionalLight(0xffffff,2.8);key.position.set(3,6,4);key.castShadow=true;scene.add(key);const floor=new THREE.Mesh(new THREE.PlaneGeometry(20,20),new THREE.MeshStandardMaterial({color:0xf8faf9,roughness:1}));floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor);const grid=new THREE.GridHelper(20,40,0xa8b6af,0xd8dfdb);grid.position.y=.003;scene.add(grid);return{scene,camera:new THREE.PerspectiveCamera(38,1,.05,60),orbit:{yaw:.562,pitch:.265,distance:4.96}}} |
| function buildNativeSkeleton(method){const skeletonGroup=new THREE.Group(),jointMaterial=new THREE.MeshBasicMaterial({color:0xff4f32,transparent:true,opacity:1,depthTest:false,depthWrite:false,toneMapped:false}),boneMaterial=new THREE.MeshBasicMaterial({color:0xff4f32,transparent:true,opacity:.92,depthTest:false,depthWrite:false,toneMapped:false}),jointGeometry=new THREE.SphereGeometry(.028,12,9),boneGeometry=new THREE.CylinderGeometry(.012,.012,1,8),nativeJoints=[],nativeBones=[];skeletonGroup.renderOrder=8;for(let joint=0;joint<24;joint++){const mesh=new THREE.Mesh(jointGeometry,jointMaterial);mesh.renderOrder=9;skeletonGroup.add(mesh);nativeJoints.push(mesh);if(AISTPP_PARENTS[joint]>=0){const bone=new THREE.Mesh(boneGeometry,boneMaterial);bone.renderOrder=8;skeletonGroup.add(bone);nativeBones.push({joint,mesh:bone})}}return{skeletonGroup,nativeJoints,nativeBones,jointMaterial,boneMaterial}} |
| function makeView(method,index){const environment=makeEnvironment(index),material=new THREE.MeshStandardMaterial({color:method.accent||"#2e78ad",roughness:.58,metalness:.02}),skinned=buildSkinnedMesh(material),skeletonMethod=(manifest.skeleton_methods||[]).find(value=>value.key===method.key),rig=skeletonMethod?buildNativeSkeleton(skeletonMethod):{};environment.scene.add(skinned.mesh);if(rig.skeletonGroup)environment.scene.add(rig.skeletonGroup);return{...environment,method,kind:skeletonMethod?"overlay":"mesh",...skinned,...rig,skeletonMethod,motion:null,skeleton:null,viewport:null,groundOffset:0,skeletonOffset:new THREE.Vector3()}} |
| function resetCamera(view){view.orbit.yaw=.562;view.orbit.pitch=.265;view.orbit.distance=4.96} |
| function applyCamera(view,targetX,targetZ){const horizontal=view.orbit.distance*Math.cos(view.orbit.pitch),targetY=.85;view.camera.position.set(targetX+horizontal*Math.sin(view.orbit.yaw),targetY+view.orbit.distance*Math.sin(view.orbit.pitch),targetZ+horizontal*Math.cos(view.orbit.yaw));view.camera.lookAt(targetX,targetY,targetZ)} |
| function refreshCamera(view){updateView(view);renderViews()} |
| function attachCameraControls(view){const viewport=view.viewport,drag={pointer:null,x:0,y:0};viewport.addEventListener("pointerdown",event=>{if(event.button!==0)return;drag.pointer=event.pointerId;drag.x=event.clientX;drag.y=event.clientY;viewport.setPointerCapture(event.pointerId);viewport.classList.add("dragging")});viewport.addEventListener("pointermove",event=>{if(event.pointerId!==drag.pointer)return;const dx=event.clientX-drag.x,dy=event.clientY-drag.y;drag.x=event.clientX;drag.y=event.clientY;view.orbit.yaw-=dx*.008;view.orbit.pitch=THREE.MathUtils.clamp(view.orbit.pitch+dy*.006,-.08,1.35);refreshCamera(view)});const release=event=>{if(event.pointerId!==drag.pointer)return;drag.pointer=null;viewport.classList.remove("dragging")};viewport.addEventListener("pointerup",release);viewport.addEventListener("pointercancel",release);viewport.addEventListener("wheel",event=>{event.preventDefault();view.orbit.distance=THREE.MathUtils.clamp(view.orbit.distance*Math.exp(event.deltaY*.001),1.8,12);refreshCamera(view)},{passive:false});viewport.addEventListener("dblclick",()=>{resetCamera(view);refreshCamera(view)})} |
| function decodeMotion(buffer,descriptor){const trans=new Uint16Array(buffer,descriptor.translation_offset,descriptor.translation_count),rot=new Int16Array(buffer,descriptor.rotation_offset,descriptor.rotation_count);return{descriptor,trans,rot}} |
| async function loadViewMotion(view,descriptor){const path=url(descriptor.asset,manifest.asset_base_url||location.href),start=descriptor.translation_offset,end=descriptor.rotation_offset+descriptor.rotation_count*2,key=`${path}#${start}-${end}`;let pending=assetCache.get(key);if(!pending)pending=rememberAsset(key,fetchWithRetry(path,{headers:{Range:`bytes=${start}-${end-1}`},cache:"force-cache"}).then(async response=>({buffer:await response.arrayBuffer(),partial:response.status===206})));const loaded=await pending;if(loaded.partial){const local={...descriptor,translation_offset:0,rotation_offset:descriptor.translation_count*2};view.motion=decodeMotion(loaded.buffer,local)}else view.motion=decodeMotion(loaded.buffer,descriptor)} |
| function decodeSkeleton(buffer,descriptor){return{descriptor,positions:new Uint16Array(buffer,descriptor.position_offset,descriptor.position_count)}} |
| async function loadViewSkeleton(view,descriptor){const path=url(descriptor.asset,manifest.asset_base_url||location.href),start=descriptor.position_offset,end=start+descriptor.position_count*2,key=`${path}#${start}-${end}`;let pending=assetCache.get(key);if(!pending)pending=rememberAsset(key,fetchWithRetry(path,{headers:{Range:`bytes=${start}-${end-1}`},cache:"force-cache"}).then(async response=>({buffer:await response.arrayBuffer(),partial:response.status===206})));const loaded=await pending;view.skeleton=decodeSkeleton(loaded.buffer,loaded.partial?{...descriptor,position_offset:0}:descriptor)} |
| async function ensureCaseMotions(index){const item=manifest.cases[index];if(item.motions)return item;const spec=manifest.case_descriptor_chunks;if(!spec)throw new Error(`No motion descriptors for case ${item.case_id}`);const chunk=Math.floor(index/spec.size),key=String(chunk).padStart(3,"0");let pending=descriptorChunkCache.get(key);if(!pending){const path=spec.path.replace("{chunk}",key),source=url(path,spec.base_url?url(spec.base_url,location.href):location.href);pending=fetchWithRetry(source,{cache:"force-cache"}).then(response=>response.json());descriptorChunkCache.set(key,pending);pending.catch(()=>{if(descriptorChunkCache.get(key)===pending)descriptorChunkCache.delete(key)})}const payload=await pending;payload.motions.forEach((motions,offset)=>{const target=manifest.cases[payload.start+offset];if(target)target.motions=motions});return manifest.cases[index]} |
| function prefetchCaseMotions(index){if(index<0||index>=manifest.cases.length)return;ensureCaseMotions(index).catch(error=>console.debug("Descriptor prefetch failed",error))} |
| function rot6dQuaternion(values,base,out){const a1=new THREE.Vector3(values[base],values[base+2],values[base+4]);if(a1.lengthSq()<1e-10)a1.set(1,0,0);else a1.normalize();const a2=new THREE.Vector3(values[base+1],values[base+3],values[base+5]);a2.addScaledVector(a1,-a1.dot(a2));if(a2.lengthSq()<1e-10){a2.set(0,1,0);if(Math.abs(a1.y)>.9)a2.set(0,0,1);a2.addScaledVector(a1,-a1.dot(a2))}a2.normalize();const a3=new THREE.Vector3().crossVectors(a1,a2).normalize();const matrix=new THREE.Matrix4().set(a1.x,a2.x,a3.x,0,a1.y,a2.y,a3.y,0,a1.z,a2.z,a3.z,0,0,0,0,1);out.setFromRotationMatrix(matrix)} |
| function decodeFrame(view,displayFrame,out){const d=view.motion.descriptor,source=Math.min(displayFrame/d.stride,d.frames-1),lo=Math.floor(source),hi=Math.min(lo+1,d.frames-1),alpha=source-lo,t0=lo*3,t1=hi*3,min=d.translation_minimum,scale=d.translation_scale;const tx=THREE.MathUtils.lerp(min[0]+view.motion.trans[t0]*scale[0],min[0]+view.motion.trans[t1]*scale[0],alpha),ty=THREE.MathUtils.lerp(min[1]+view.motion.trans[t0+1]*scale[1],min[1]+view.motion.trans[t1+1]*scale[1],alpha)+view.groundOffset,tz=THREE.MathUtils.lerp(min[2]+view.motion.trans[t0+2]*scale[2],min[2]+view.motion.trans[t1+2]*scale[2],alpha),r0=lo*132,r1=hi*132;const values=out||new Float32Array(135);values[0]=tx;values[1]=ty;values[2]=tz;for(let i=0;i<132;i++)values[i+3]=THREE.MathUtils.lerp(view.motion.rot[r0+i]/32767,view.motion.rot[r1+i]/32767,alpha);return values} |
| function updatePoseCorrectives(view){const correctives=body.poseCorrectives;if(!correctives)return;const feature=view.poseFeature||(view.poseFeature=new Float32Array(correctives.dimensions));let cursor=0;for(let joint=1;joint<24;joint++){const q=view.bones[joint].quaternion,x=q.x,y=q.y,z=q.z,w=q.w,xx=x*x,yy=y*y,zz=z*z,xy=x*y,xz=x*z,yz=y*z,xw=x*w,yw=y*w,zw=z*w;feature[cursor++]=-2*(yy+zz);feature[cursor++]=2*(xy-zw);feature[cursor++]=2*(xz+yw);feature[cursor++]=2*(xy+zw);feature[cursor++]=-2*(xx+zz);feature[cursor++]=2*(yz-xw);feature[cursor++]=2*(xz-yw);feature[cursor++]=2*(yz+xw);feature[cursor++]=-2*(xx+yy)}const coefficients=view.mesh.material.userData.poseCorrectiveCoefficients,projection=correctives.projection,dimensions=correctives.dimensions;for(let rank=0;rank<correctives.rank;rank++){let value=0,base=rank*dimensions;for(let index=0;index<dimensions;index++)value+=projection[base+index]*feature[index];coefficients[rank]=value}} |
| function applyPoseCorrective(view,vertex,position){const correctives=body.poseCorrectives;if(!correctives)return position;const coefficients=view.mesh.material.userData.poseCorrectiveCoefficients,basis=correctives.basis,count=body.meta.vertices;let x=0,y=0,z=0;for(let rank=0;rank<correctives.rank;rank++){const base=(rank*count+vertex)*4,value=coefficients[rank];x+=basis[base]*value;y+=basis[base+1]*value;z+=basis[base+2]*value}position.x+=x;position.y+=y;position.z+=z;return position} |
| function setPose(view,displayFrame){const values=decodeFrame(view,displayFrame);for(let joint=0;joint<22;joint++)rot6dQuaternion(values,3+joint*6,view.bones[joint].quaternion);for(let joint=22;joint<24;joint++)view.bones[joint].quaternion.identity();updatePoseCorrectives(view);view.bones[0].position.set(body.joints[0]+values[0],body.joints[1]+values[1],body.joints[2]+values[2]);view.mesh.updateMatrixWorld(true);view.mesh.skeleton.update();return values} |
| function decodeSkeletonFrame(view,displayFrame,out){const d=view.skeleton.descriptor,source=Math.min(displayFrame/d.stride,d.frames-1),lo=Math.floor(source),hi=Math.min(lo+1,d.frames-1),alpha=source-lo,count=d.joint_count*3,minimum=d.position_minimum,scale=d.position_scale,values=out||new Float32Array(count);for(let value=0;value<count;value++){const axis=value%3,a=minimum[axis]+view.skeleton.positions[lo*count+value]*scale[axis],b=minimum[axis]+view.skeleton.positions[hi*count+value]*scale[axis];values[value]=THREE.MathUtils.lerp(a,b,alpha)+view.skeletonOffset.getComponent(axis)}return values} |
| function setSkeletonPose(view,displayFrame){const values=decodeSkeletonFrame(view,displayFrame),start=new THREE.Vector3(),end=new THREE.Vector3(),direction=new THREE.Vector3();for(let joint=0;joint<view.nativeJoints.length;joint++)view.nativeJoints[joint].position.fromArray(values,joint*3);for(const bone of view.nativeBones){const parent=AISTPP_PARENTS[bone.joint];start.fromArray(values,parent*3);end.fromArray(values,bone.joint*3);direction.subVectors(end,start);const length=direction.length();bone.mesh.position.copy(start).add(end).multiplyScalar(.5);bone.mesh.scale.set(1,Math.max(length,.001),1);bone.mesh.quaternion.setFromUnitVectors(Y_AXIS,direction.normalize())}view.skeletonGroup.updateMatrixWorld(true);return values} |
| function computeGroundOffset(view){view.groundOffset=0;const d=view.motion.descriptor,candidates=[],position=new THREE.Vector3();for(let sample=0;sample<d.frames;sample++){const display=Math.min(d.display_frames-1,sample*d.stride);setPose(view,display);let jointMin=Infinity;for(const bone of view.bones)jointMin=Math.min(jointMin,bone.matrixWorld.elements[13]);candidates.push([jointMin,display])}candidates.sort((a,b)=>a[0]-b[0]);const selectedFrames=[...new Set([...candidates.slice(0,12).map(x=>x[1]),0,d.display_frames-1])];let meshMin=Infinity;const attribute=body.geometry.getAttribute("position");for(const candidate of selectedFrames){setPose(view,candidate);for(let vertex=0;vertex<attribute.count;vertex++){position.fromBufferAttribute(attribute,vertex);applyPoseCorrective(view,vertex,position);view.mesh.applyBoneTransform(vertex,position);meshMin=Math.min(meshMin,position.y)}}view.groundOffset=Number.isFinite(meshMin)?-meshMin+.002:0;return view.groundOffset} |
| function alignSkeletonToMesh(view){view.skeletonOffset.set(0,0,0);setPose(view,0);const meshRoot=new THREE.Vector3();view.bones[0].getWorldPosition(meshRoot);const nativeRoot=decodeSkeletonFrame(view,0);view.skeletonOffset.set(meshRoot.x-nativeRoot[0],meshRoot.y-nativeRoot[1],meshRoot.z-nativeRoot[2]);return view.skeletonOffset} |
| function isConditionFrame(item,value){return(item?.condition_intervals||[]).some(interval=>value>=Number(interval[0])&&value<Number(interval[1]))} |
| function updateView(view){if(!view.motion)return;scheduleFbxWarm(view,currentItem,loadGeneration);const values=setPose(view,frame),conditioned=isConditionFrame(currentItem,frame),conditionColor=manifest.condition_legend?.conditioned?.color||"#d95f02";if(view.skeleton)setSkeletonPose(view,frame);view.mesh.material.color.set(conditioned?conditionColor:(view.method.accent||"#2e78ad"));if(view.tile)view.tile.dataset.frameKind=conditioned?"condition":"generated";applyCamera(view,values[0],values[2])} |
| function resizeRenderer(){const width=gallery.clientWidth,height=gallery.clientHeight,dpr=renderer.getPixelRatio();if(canvas.width!==Math.round(width*dpr)||canvas.height!==Math.round(height*dpr))renderer.setSize(width,height,false)} |
| function renderViews(){resizeRenderer();renderer.setScissorTest(true);const canvasRect=canvas.getBoundingClientRect();renderer.clear();for(const view of views){if(!view.viewport)continue;const rect=view.viewport.getBoundingClientRect(),left=rect.left-canvasRect.left,bottom=canvasRect.bottom-rect.bottom,width=rect.width,height=rect.height;if(rect.bottom<0||rect.top>innerHeight||width<=0||height<=0)continue;renderer.setViewport(left,bottom,width,height);renderer.setScissor(left,bottom,width,height);view.camera.aspect=width/height;view.camera.updateProjectionMatrix();renderer.render(view.scene,view.camera)}renderer.setScissorTest(false)} |
| function reconstructedMotion(view){const frames=view.motion.descriptor.display_frames,motion=new Float32Array(frames*135);for(let index=0;index<frames;index++)decodeFrame(view,index,motion.subarray(index*135,(index+1)*135));return motion} |
| function npy(data,shape,descr){const encoder=new TextEncoder(),shapeText=`(${shape.join(", ")}${shape.length===1?",":""})`;let header=`{'descr': '${descr}', 'fortran_order': False, 'shape': ${shapeText}, }`;const padding=(16-((10+encoder.encode(header).length+1)%16))%16;header+=`${" ".repeat(padding)}\n`;const headerBytes=encoder.encode(header),output=new Uint8Array(10+headerBytes.length+data.byteLength);output.set([0x93,0x4e,0x55,0x4d,0x50,0x59,1,0],0);new DataView(output.buffer).setUint16(8,headerBytes.length,true);output.set(headerBytes,10);output.set(new Uint8Array(data.buffer,data.byteOffset,data.byteLength),10+headerBytes.length);return output} |
| function downloadBlob(data,name,type="application/octet-stream"){const href=URL.createObjectURL(new Blob([data],{type})),anchor=document.createElement("a");anchor.href=href;anchor.download=name;document.body.append(anchor);anchor.click();anchor.remove();setTimeout(()=>URL.revokeObjectURL(href),1000)} |
| function safeName(value){return String(value).replace(/[^a-zA-Z0-9._-]+/g,"_")} |
| async function exportNpz(view,item){const{zipSync}=await import("fflate"),frames=view.motion.descriptor.display_frames,motion=reconstructedMotion(view),fps=new Float32Array([view.motion.descriptor.fps||30]),condition=new Uint8Array(frames);for(let index=0;index<frames;index++)condition[index]=isConditionFrame(item,index)?1:0;const archive=zipSync({"motion_135.npy":npy(motion,[frames,135],"<f4"),"fps.npy":npy(fps,[1],"<f4"),"condition_mask.npy":npy(condition,[frames],"|u1")},{level:6});downloadBlob(archive,`${safeName(item.sample_id||item.case_id)}_${safeName(view.method.key)}.npz`,"application/zip")} |
| function fbxKey(view,item){return`${item.case_key||item.sample_id||item.case_id}:${view.method.key}:${view.motion?.descriptor.display_frames||0}`} |
| async function buildFbx(view,item){const{FBXExporter}=await fbxExporterPromise,frames=view.motion.descriptor.display_frames,fps=view.motion.descriptor.fps||30,times=new Float32Array(frames),positions=new Float32Array(frames*3),quaternions=Array.from({length:22},()=>new Float32Array(frames*4)),quaternion=new THREE.Quaternion();for(let index=0;index<frames;index++){const values=decodeFrame(view,index);times[index]=index/fps;positions.set([body.joints[0]+values[0],body.joints[1]+values[1],body.joints[2]+values[2]],index*3);for(let joint=0;joint<22;joint++){rot6dQuaternion(values,3+joint*6,quaternion);quaternions[joint].set(quaternion.toArray(),index*4)}}const material=new THREE.MeshStandardMaterial({color:view.method.accent||"#2e78ad",roughness:.58,metalness:.02}),skinned=buildSkinnedMesh(material),scene=new THREE.Scene(),tracks=[new THREE.VectorKeyframeTrack(`${skinned.bones[0].name}.position`,times,positions)];for(let joint=0;joint<22;joint++)tracks.push(new THREE.QuaternionKeyframeTrack(`${skinned.bones[joint].name}.quaternion`,times,quaternions[joint]));const clip=new THREE.AnimationClip(`${safeName(item.sample_id||item.case_id)}_${safeName(view.method.key)}`,frames/fps,tracks);skinned.mesh.userData={case_id:String(item.case_id),sample_id:String(item.sample_id||item.case_id),method:view.method.key,fps,ground_offset:view.groundOffset};scene.add(skinned.mesh);scene.animations=[clip];return new FBXExporter().parseSync(scene,{preset:"blender",fps,animations:[clip],creator:"Motius SMPL Gallery"})} |
| function preparedFbx(view,item){const key=fbxKey(view,item);if(fbxCache.has(key))return fbxCache.get(key);const pending=buildFbx(view,item).catch(error=>{if(fbxCache.get(key)===pending)fbxCache.delete(key);throw error});fbxCache.set(key,pending);while(fbxCache.size>12)fbxCache.delete(fbxCache.keys().next().value);return pending} |
| function scheduleFbxWarm(view,item,generation){if(!view.motion)return;const key=fbxKey(view,item);if(fbxCache.has(key)||fbxWarmAttempted.has(key)||fbxWarmQueue.some(entry=>entry.key===key))return;fbxWarmAttempted.add(key);fbxWarmQueue.push({view,item,generation,key});if(fbxWarmActive)return;fbxWarmActive=true;(async()=>{while(fbxWarmQueue.length){const entry=fbxWarmQueue.shift();await idle();if(entry.generation!==loadGeneration||entry.item!==currentItem||!entry.view.motion)continue;try{await preparedFbx(entry.view,entry.item)}catch(error){console.warn("FBX warm-up failed",error)}}fbxWarmActive=false})()} |
| async function exportFbx(view,item){const bytes=await preparedFbx(view,item);downloadBlob(bytes,`${safeName(item.sample_id||item.case_id)}_${safeName(view.method.key)}.fbx`)} |
| async function downloadView(view,item,format,button){if(!view.motion)return;const old=button.textContent;button.disabled=true;button.textContent="…";try{await new Promise(resolve=>requestAnimationFrame(resolve));if(format==="npz")await exportNpz(view,item);else await exportFbx(view,item)}catch(error){console.error(error);alert(`Export failed: ${error.message}`)}finally{button.disabled=false;button.textContent=old}} |
| function renderTiles(item){gallery.querySelectorAll(".tile").forEach(node=>node.remove());views=manifest.motion_methods.map((method,index)=>makeView(method,index));gallery.classList.remove("comparison-four");for(const view of views){const tile=document.createElement("article");tile.className="tile";tile.dataset.loadState="pending";const exports=`<button class="download" data-format="npz" disabled>NPZ</button><button class="download" data-format="fbx" disabled>FBX</button>`;tile.innerHTML=`<div class="tile-head"><div class="method"><span class="swatch" style="background:${view.method.accent}"></span>${view.method.label}</div><div class="tile-actions"><span class="motion-meta">Waiting</span><button class="download retry-load" type="button" hidden>Retry</button><button class="download camera-reset" type="button" title="Reset 3D view" aria-label="Reset 3D view">↺</button>${exports}</div></div><div class="viewport" aria-label="Interactive 3D motion view"></div>`;for(const button of tile.querySelectorAll("[data-format]")){button.addEventListener("click",event=>{event.stopPropagation();downloadView(view,item,button.dataset.format,button)});if(button.dataset.format==="fbx")button.addEventListener("pointerenter",()=>{if(view.motion&&!fbxCache.has(fbxKey(view,item)))void preparedFbx(view,item).catch(error=>console.warn("FBX pointer warm-up failed",error))})}tile.querySelector(".camera-reset").addEventListener("click",()=>{resetCamera(view);refreshCamera(view)});const output=item.outputs?.[view.method.key];if(output){const text=document.createElement("div");text.className="output-text";text.textContent=output;tile.append(text)}gallery.append(tile);view.viewport=tile.querySelector(".viewport");view.tile=tile;view.retryButton=tile.querySelector(".retry-load");attachCameraControls(view)}for(const method of manifest.output_methods||[]){const tile=document.createElement("article");tile.className="tile text-tile";tile.innerHTML=`<div class="tile-head"><div class="method"><span class="swatch" style="background:${method.accent}"></span>${method.label}</div></div><div class="text-result"></div>`;tile.querySelector(".text-result").textContent=item.outputs?.[method.key]||"No output";gallery.append(tile)}} |
| function referenceValues(item){return item.segments||item.references||[]} |
| function renderCaseMeta(item,index){$("#position").textContent=`Case ${index+1} of ${manifest.population}`;$("#case-id").textContent=item.sample_id||item.case_id;const refs=referenceValues(item);$("#captions").replaceChildren(...refs.map(value=>{const span=document.createElement("span");span.className="caption";span.textContent=typeof value==="string"?value:value.caption;if(typeof value!=="string"){span.dataset.start=value.start_frame;span.dataset.end=value.end_frame}return span}));const legend=$("#condition-legend");if(item.condition_intervals?.length){const condition=manifest.condition_legend?.conditioned||{label:"Condition frame",color:"#d95f02"},generated=manifest.condition_legend?.generated||{label:"Generated frame"};legend.replaceChildren(...[[condition.label,condition.color],[generated.label,"#66736d"]].map(([label,color])=>{const chip=document.createElement("span");chip.className="legend-chip";chip.innerHTML=`<span class="legend-dot" style="background:${color}"></span>`;chip.append(document.createTextNode(label));return chip}))}else legend.replaceChildren()} |
| function idle(){return new Promise(resolve=>"requestIdleCallback"in globalThis?requestIdleCallback(()=>resolve(),{timeout:500}):setTimeout(resolve,0))} |
| function playbackFps(){const view=views.find(value=>value.motion||value.skeleton),descriptor=view?.motion?.descriptor||view?.skeleton?.descriptor;return descriptor?.fps||30} |
| async function choose(index){if(!filtered.length)return;if(viewObserver)viewObserver.disconnect();const generation=++loadGeneration;audio.pause();selected=Math.max(0,Math.min(index,filtered.length-1));const absolute=filtered[selected];loading.hidden=false;loading.textContent="Loading case metadata";let item;try{item=await ensureCaseMotions(absolute)}catch(error){if(generation===loadGeneration){loading.textContent="Case metadata unavailable";console.error(error)}return}if(generation!==loadGeneration)return;currentItem=item;$("#case-select").value=String(absolute);document.querySelectorAll(".case-button").forEach(button=>button.classList.toggle("active",Number(button.dataset.index)===absolute));renderCaseMeta(item,absolute);renderTiles(item);const descriptors=[...Object.values(item.motions||{}),...Object.values(item.skeletons||{})];maxFrames=Math.max(1,...descriptors.map(descriptor=>descriptor.display_frames||1));frame=0;$("#timeline").max=maxFrames-1;if(item.audio){const fps=descriptors[0]?.fps||30;audioStart=Number(item.audio_start_seconds||0);audioEnd=Number(item.audio_end_seconds||audioStart+maxFrames/fps);audio.hidden=false;audio.src=url(item.audio,manifest.asset_base_url||location.href);audio.load();audio.addEventListener("loadedmetadata",()=>{audio.currentTime=audioStart},{once:true});playing=false;$("#play").textContent="▶"}else{audioStart=0;audioEnd=Infinity;audio.hidden=true;audio.removeAttribute("src");audio.load();playing=true;$("#play").textContent="Ⅱ"}loading.textContent="Loading visible motions";const hydrate=view=>{if(view.motion)return Promise.resolve(true);if(view.loadPromise)return view.loadPromise;view.tile.dataset.loadState="loading";view.retryButton.hidden=true;view.tile.querySelector(".motion-meta").textContent="Loading";view.loadPromise=(async()=>{const motionDescriptor=item.motions?.[view.method.key];if(!motionDescriptor)throw new Error(`Missing mesh data for ${view.method.key}`);await loadViewMotion(view,motionDescriptor);computeGroundOffset(view);const skeletonDescriptor=item.skeletons?.[view.method.key];if(skeletonDescriptor){await loadViewSkeleton(view,skeletonDescriptor);alignSkeletonToMesh(view)}if(generation!==loadGeneration)return false;view.tile.querySelectorAll("[data-format]").forEach(button=>button.disabled=false);const fit=motionDescriptor.fit_mpjpe_mm_mean,mode=skeletonDescriptor?"Native 24J + SMPL":"SMPL Mesh";view.tile.querySelector(".motion-meta").textContent=Number.isFinite(fit)?`${mode} · IK ${fit.toFixed(1)} mm`:mode;view.tile.dataset.loadState="ready";updateAll();return true})().catch(error=>{if(generation===loadGeneration){view.motion=null;view.skeleton=null;view.tile.dataset.loadState="error";view.tile.querySelector(".motion-meta").textContent="Load failed";view.retryButton.hidden=false;console.warn(`Motion load failed for ${view.method.key}`,error)}return false}).finally(()=>{view.loadPromise=null});return view.loadPromise};for(const view of views)view.retryButton.addEventListener("click",()=>hydrate(view));const columns=Math.max(1,Math.floor((gallery.clientWidth+12)/282)),eager=views.length<=4?views.length:Math.min(views.length,columns);await Promise.allSettled(views.slice(0,eager).map(hydrate));if(generation!==loadGeneration)return;loading.hidden=true;updateAll();history.replaceState(null,"",`?case=${encodeURIComponent(item.case_key||item.case_id)}`);const step=manifest.case_descriptor_chunks?.size||1;prefetchCaseMotions(absolute+step);const observer=new IntersectionObserver(entries=>{for(const entry of entries)if(entry.isIntersecting){const view=views.find(candidate=>candidate.tile===entry.target);observer.unobserve(entry.target);if(view)void hydrate(view)}},{root:main,rootMargin:"80px 0px",threshold:.01});viewObserver=observer;for(const view of views.slice(eager))observer.observe(view.tile)} |
| function updateAll(){for(const view of views)updateView(view);$("#timeline").value=frame;$("#frame").textContent=`${frame+1} / ${maxFrames}`;document.querySelectorAll(".caption[data-start]").forEach(node=>node.classList.toggle("active",frame>=Number(node.dataset.start)&&frame<Number(node.dataset.end)));renderViews()} |
| function debugBounds(view){const minimum=new THREE.Vector3(Infinity,Infinity,Infinity),maximum=new THREE.Vector3(-Infinity,-Infinity,-Infinity),position=new THREE.Vector3(),attribute=body.geometry.getAttribute("position");for(let vertex=0;vertex<attribute.count;vertex++){position.fromBufferAttribute(attribute,vertex);applyPoseCorrective(view,vertex,position);view.mesh.applyBoneTransform(vertex,position);view.mesh.localToWorld(position);minimum.min(position);maximum.max(position)}const result={mesh:{minimum:minimum.toArray(),maximum:maximum.toArray()}};if(view.skeletonGroup){const box=new THREE.Box3().setFromObject(view.skeletonGroup);result.skeleton={minimum:box.min.toArray(),maximum:box.max.toArray()}}return result} |
| globalThis.__motiusGalleryDebug=()=>views.map(view=>{const meshRoot=new THREE.Vector3();view.bones[0].getWorldPosition(meshRoot);return{method:view.method.label,kind:view.kind,frame,meshRoot:meshRoot.toArray(),skeletonRoot:view.skeleton?view.nativeJoints[0].position.toArray():null,camera:view.camera.position.toArray(),groundOffset:view.groundOffset,skeletonOffset:view.skeletonOffset.toArray(),bounds:debugBounds(view)}}); |
| function searchable(item){return`${item.case_key||item.case_id} ${referenceValues(item).map(x=>typeof x==="string"?x:x.caption).join(" ")} ${Object.values(item.outputs||{}).join(" ")}`.toLowerCase()} |
| function renderCaseIndex(){const list=$("#case-list");list.replaceChildren(...filtered.map(i=>{const item=manifest.cases[i],button=document.createElement("button");button.type="button";button.className="case-button";button.dataset.index=i;button.innerHTML=`<span class="case-number">${String(i+1).padStart(4,"0")}</span><span class="case-name"></span>`;button.querySelector(".case-name").textContent=item.sample_id||item.case_id;button.addEventListener("click",()=>choose(filtered.indexOf(i)));return button}));const select=$("#case-select");select.replaceChildren(...filtered.map(i=>{const option=document.createElement("option");option.value=i;option.textContent=`${i+1}. ${manifest.cases[i].sample_id||manifest.cases[i].case_id}`;return option}));$("#case-summary").textContent=`${filtered.length.toLocaleString()} / ${manifest.population.toLocaleString()} cases`} |
| function applyFilter(){const query=$("#search").value.trim().toLowerCase();filtered=manifest.cases.map((_,i)=>i).filter(i=>!query||searchable(manifest.cases[i]).includes(query));renderCaseIndex();choose(0)} |
| function animate(time){const fps=playbackFps();if(!audio.hidden&&!audio.paused){if(audio.currentTime>=audioEnd-1/fps){audio.pause();audio.currentTime=audioEnd;frame=maxFrames-1;updateAll()}else{const next=THREE.MathUtils.clamp(Math.floor((audio.currentTime-audioStart)*fps),0,maxFrames-1);if(next!==frame){frame=next;updateAll()}else renderViews()}}else if(audio.hidden&&playing&&time-lastTime>=1000/fps){frame=(frame+1)%maxFrames;lastTime=time;updateAll()}else renderViews();requestAnimationFrame(animate)} |
| async function init(){const response=await fetchWithRetry("manifest.json",{cache:"force-cache"});manifest=await response.json();if(manifest.representation!=="smpl_motion135")throw new Error(`Expected SMPL motion135 assets, got ${manifest.representation||"legacy joints"}`);const params=new URLSearchParams(location.search),requestedMethod=params.get("method");if(requestedMethod){const motions=(manifest.motion_methods||[]).filter(method=>method.key===requestedMethod),outputs=(manifest.output_methods||[]).filter(method=>method.key===requestedMethod);if(!motions.length&&!outputs.length)throw new Error(`Unknown method ${requestedMethod}`);if(motions.length)manifest.motion_methods=motions;if(outputs.length)manifest.output_methods=outputs}body=await loadBody();filtered=manifest.cases.map((_,i)=>i);renderCaseIndex();const requested=params.get("case"),start=Math.max(0,manifest.cases.findIndex(item=>(item.case_key||item.case_id)===requested||item.sample_id===requested));await choose(filtered.indexOf(start));requestAnimationFrame(animate)} |
| $("#search").addEventListener("input",applyFilter);$("#case-select").addEventListener("change",event=>choose(filtered.indexOf(Number(event.target.value))));$("#previous").addEventListener("click",()=>choose(selected-1));$("#next").addEventListener("click",()=>choose(selected+1));$("#play").addEventListener("click",()=>{if(!audio.hidden){if(audio.paused){if(audio.currentTime>=audioEnd-1e-3){audio.currentTime=audioStart;frame=0;updateAll()}audio.play();playing=true}else{audio.pause();playing=false}}else playing=!playing;$("#play").textContent=playing?"Ⅱ":"▶"});$("#timeline").addEventListener("input",event=>{frame=Number(event.target.value);if(!audio.hidden)audio.currentTime=audioStart+frame/playbackFps();updateAll()});audio.addEventListener("play",()=>{playing=true;$("#play").textContent="Ⅱ"});audio.addEventListener("pause",()=>{playing=false;$("#play").textContent="▶"});audio.addEventListener("ended",()=>{frame=maxFrames-1;updateAll()});addEventListener("resize",renderViews);main.addEventListener("scroll",renderViews,{passive:true}); |
| init().catch(error=>{loading.hidden=false;loading.textContent=error.message;console.error(error)}); |
| </script> |
| </body> |
| </html> |
|
|