Spaces:
Running
Running
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <style> | |
| html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; } | |
| body { background: #d8e0ea; color: #d9edf7; font: 13px/1.4 Inter, sans-serif; } | |
| #app { width: 100%; height: 100%; position: relative; } | |
| #debug-status { | |
| display: none; | |
| position: absolute; | |
| left: 12px; | |
| top: 12px; | |
| z-index: 20; | |
| max-width: 420px; | |
| padding: 8px 10px; | |
| border-radius: 10px; | |
| background: rgba(240, 245, 250, 0.84); | |
| color: #334155; | |
| font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| white-space: pre-wrap; | |
| pointer-events: none; | |
| } | |
| canvas { display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <div id="debug-status">boot: script tag loaded</div> | |
| <script type="module"> | |
| let activeLoadToken = 0; | |
| const debugStatus = document.getElementById('debug-status'); | |
| const initialParams = new URLSearchParams(window.location.search); | |
| const viewerNonce = initialParams.get('viewerNonce') || ''; | |
| const assetVersion = initialParams.get('v') || ''; | |
| function setDebugStatus(message) { | |
| if (debugStatus) { | |
| debugStatus.textContent = message; | |
| } | |
| } | |
| function post(type, payload = {}) { | |
| window.parent.postMessage({ source: 'robot-motion-viewer', viewerNonce, type, ...payload }, '*'); | |
| } | |
| function withAssetVersion(url) { | |
| const raw = String(url || '').trim(); | |
| if (!raw || !assetVersion) return raw; | |
| try { | |
| const resolved = new URL(raw, window.location.origin); | |
| resolved.searchParams.set('v', assetVersion); | |
| if (resolved.origin === window.location.origin) { | |
| return resolved.pathname + resolved.search + resolved.hash; | |
| } | |
| return resolved.toString(); | |
| } catch { | |
| return raw; | |
| } | |
| } | |
| window.addEventListener('message', (event) => { | |
| const payload = event.data || {}; | |
| if (payload.viewerNonce && payload.viewerNonce !== viewerNonce) { | |
| return; | |
| } | |
| if (payload.type === 'robot-ping') { | |
| setDebugStatus('boot: ping received'); | |
| post('boot'); | |
| } | |
| }); | |
| window.addEventListener('error', (event) => { | |
| const message = event?.error?.message || event?.message || 'Unknown robot viewer error'; | |
| setDebugStatus('error: ' + message); | |
| post('error', { message }); | |
| }); | |
| window.addEventListener('unhandledrejection', (event) => { | |
| const reason = event?.reason; | |
| const message = | |
| reason instanceof Error | |
| ? reason.message | |
| : typeof reason === 'string' | |
| ? reason | |
| : JSON.stringify(reason || 'Unknown rejection'); | |
| setDebugStatus('rejection: ' + message); | |
| post('error', { message }); | |
| }); | |
| setDebugStatus('boot: importing three'); | |
| const THREE = await import('/hf-assets/vendor/three/three.module.js'); | |
| setDebugStatus('boot: importing orbit controls'); | |
| const { OrbitControls } = await import('/hf-assets/vendor/three/controls/OrbitControls.js'); | |
| setDebugStatus('boot: importing STL loader'); | |
| const { STLLoader } = await import('/hf-assets/vendor/three/loaders/STLLoader.js'); | |
| setDebugStatus('boot: importing mujoco'); | |
| const { default: loadMujoco } = await import('/hf-assets/vendor/mujoco/mujoco.js'); | |
| setDebugStatus('boot: runtime imports ready'); | |
| const root = document.getElementById('app'); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); | |
| renderer.setPixelRatio(window.devicePixelRatio || 1); | |
| renderer.setSize(root.clientWidth, root.clientHeight); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 0.92; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.domElement.style.opacity = '0'; | |
| renderer.domElement.style.transition = 'opacity 180ms ease'; | |
| root.appendChild(renderer.domElement); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xd8e0ea); | |
| scene.fog = new THREE.Fog(0xcfd7e2, 9, 24); | |
| const camera = new THREE.PerspectiveCamera(34, root.clientWidth / root.clientHeight, 0.05, 1000); | |
| camera.position.set(2.4, 1.5, 3.8); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.07; | |
| controls.target.set(0, 0.9, 0); | |
| controls.minDistance = 0.7; | |
| controls.maxDistance = 12; | |
| controls.maxPolarAngle = Math.PI - 0.08; | |
| controls.minPolarAngle = 0.08; | |
| scene.add(new THREE.AmbientLight(0xffffff, 0.54)); | |
| const hemi = new THREE.HemisphereLight(0xe8eff7, 0xb3bfce, 0.66); | |
| scene.add(hemi); | |
| const key = new THREE.DirectionalLight(0xe7edf5, 1.15); | |
| key.position.set(3.8, 5.8, 4.6); | |
| key.castShadow = true; | |
| key.shadow.mapSize.set(2048, 2048); | |
| key.shadow.camera.near = 0.5; | |
| key.shadow.camera.far = 30; | |
| key.shadow.bias = -0.00015; | |
| scene.add(key); | |
| const fill = new THREE.DirectionalLight(0x8cb4d9, 0.34); | |
| fill.position.set(-3.4, 2.8, 4.2); | |
| scene.add(fill); | |
| const rim = new THREE.DirectionalLight(0xc7d6eb, 0.2); | |
| rim.position.set(-2.8, 4.2, -4.8); | |
| scene.add(rim); | |
| const top = new THREE.PointLight(0xffffff, 0.14, 12); | |
| top.position.set(0, 2.9, 1.2); | |
| scene.add(top); | |
| const grid = new THREE.GridHelper(16, 16, 0x96aac0, 0xc2ccd8); | |
| grid.material.opacity = 0.28; | |
| grid.material.transparent = true; | |
| scene.add(grid); | |
| const floor = new THREE.Mesh( | |
| new THREE.CircleGeometry(5.4, 64), | |
| new THREE.MeshStandardMaterial({ color: 0xe4e9f0, roughness: 0.95, metalness: 0.02 }) | |
| ); | |
| floor.rotation.x = -Math.PI / 2; | |
| floor.position.y = -0.01; | |
| floor.receiveShadow = true; | |
| scene.add(floor); | |
| const shadowCatcher = new THREE.Mesh( | |
| new THREE.CircleGeometry(1.32, 48), | |
| new THREE.MeshBasicMaterial({ color: 0x465a72, transparent: true, opacity: 0.035, depthWrite: false }) | |
| ); | |
| shadowCatcher.rotation.x = -Math.PI / 2; | |
| shadowCatcher.position.y = 0.002; | |
| scene.add(shadowCatcher); | |
| const backdrop = new THREE.Mesh( | |
| new THREE.PlaneGeometry(24, 16), | |
| new THREE.MeshBasicMaterial({ color: 0xe1e8f1 }) | |
| ); | |
| backdrop.position.set(0, 4, -8.5); | |
| scene.add(backdrop); | |
| const mujocoRoot = new THREE.Group(); | |
| mujocoRoot.rotation.x = -Math.PI / 2; | |
| scene.add(mujocoRoot); | |
| const loader = new STLLoader(); | |
| const geometryCache = new Map(); | |
| const materialCache = new Map(); | |
| const jsonCache = new Map(); | |
| const textCache = new Map(); | |
| const bufferCache = new Map(); | |
| const modelPackageCache = new Map(); | |
| const motionCache = new Map(); | |
| const renderables = []; | |
| const tempMatrix = new THREE.Matrix4(); | |
| const tempQuaternion = new THREE.Quaternion(); | |
| const tempPosition = new THREE.Vector3(); | |
| const tempBox = new THREE.Box3(); | |
| const tempSize = new THREE.Vector3(); | |
| const tempCenter = new THREE.Vector3(); | |
| let mujoco = null; | |
| let model = null; | |
| let data = null; | |
| let vfs = null; | |
| let motion = null; | |
| let robotMeta = null; | |
| let visualMeta = null; | |
| let videoPlane = null; | |
| let videoElement = null; | |
| let videoTexture = null; | |
| let frameCount = 0; | |
| let fps = 30; | |
| let syncedTime = 0; | |
| let builtFrame = -1; | |
| let qposLayout = { rootPosStart: 0, rootQuatStart: 3, dofStart: 7 }; | |
| let rootQuatOrder = 'xyzw'; | |
| let playbackAnchorTime = 0; | |
| let playbackAnchorPerf = 0; | |
| let currentPlaybackTime = 0; | |
| let externalPlaying = false; | |
| let forcedRenderSize = null; | |
| let currentModelPackageKey = ''; | |
| const videoPlaneBottomY = 0.02; | |
| let videoPlaneDepth = -2.25; | |
| let videoPlaneHeight = 1.72; | |
| let videoPlaneAspect = 16 / 9; | |
| function clamp(value, min, max) { | |
| return Math.max(min, Math.min(max, value)); | |
| } | |
| function getTerminalPlaybackTime(duration) { | |
| if (!Number.isFinite(duration) || duration <= 0) { | |
| return 0; | |
| } | |
| return Math.max(duration - 1 / Math.max(fps, 1), 0); | |
| } | |
| function getNormalizedPlaybackTime(time, duration) { | |
| const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0; | |
| if (!Number.isFinite(duration) || duration <= 0) { | |
| return safeTime; | |
| } | |
| return Math.min(safeTime, getTerminalPlaybackTime(duration)); | |
| } | |
| function quatAngleRadians(a, b) { | |
| const ax = a[0] || 0; | |
| const ay = a[1] || 0; | |
| const az = a[2] || 0; | |
| const aw = a[3] || 1; | |
| const bx = b[0] || 0; | |
| const by = b[1] || 0; | |
| const bz = b[2] || 0; | |
| const bw = b[3] || 1; | |
| const dot = Math.abs(ax * bx + ay * by + az * bz + aw * bw); | |
| return 2 * Math.acos(clamp(dot, -1, 1)); | |
| } | |
| function stabilizeLeadingFrames(nextMotion) { | |
| if (!nextMotion) return nextMotion; | |
| const rootRot = Array.isArray(nextMotion.root_rot_xyzw) ? nextMotion.root_rot_xyzw : []; | |
| const rootPos = Array.isArray(nextMotion.root_pos) ? nextMotion.root_pos : []; | |
| const dofPos = Array.isArray(nextMotion.dof_pos) ? nextMotion.dof_pos : []; | |
| const frameCount = Math.min(rootRot.length, rootPos.length, dofPos.length); | |
| if (frameCount < 3) return nextMotion; | |
| const inspectCount = Math.min(6, frameCount - 1); | |
| let stableIndex = -1; | |
| for (let index = 1; index < inspectCount; index += 1) { | |
| const delta = quatAngleRadians(rootRot[index], rootRot[index + 1]); | |
| if (delta < THREE.MathUtils.degToRad(10)) { | |
| stableIndex = index; | |
| break; | |
| } | |
| } | |
| if (stableIndex < 1) return nextMotion; | |
| const leadDelta = quatAngleRadians(rootRot[0], rootRot[stableIndex]); | |
| const stableDelta = quatAngleRadians(rootRot[stableIndex], rootRot[Math.min(stableIndex + 1, frameCount - 1)]); | |
| if (leadDelta < THREE.MathUtils.degToRad(25) || stableDelta > THREE.MathUtils.degToRad(12)) { | |
| return nextMotion; | |
| } | |
| const stableRootRot = [...rootRot[stableIndex]]; | |
| const stableRootPos = [...rootPos[stableIndex]]; | |
| const stableDofPos = [...dofPos[stableIndex]]; | |
| for (let index = 0; index < stableIndex; index += 1) { | |
| rootRot[index] = [...stableRootRot]; | |
| rootPos[index] = [...stableRootPos]; | |
| dofPos[index] = [...stableDofPos]; | |
| } | |
| return nextMotion; | |
| } | |
| function setStatus(phase, message) { | |
| setDebugStatus(phase + ': ' + message); | |
| if (phase === 'ready') { | |
| renderer.domElement.style.opacity = '1'; | |
| } else { | |
| renderer.domElement.style.opacity = '0'; | |
| } | |
| post('status', { phase, message }); | |
| } | |
| function setError(error) { | |
| const message = error instanceof Error ? error.message : String(error || 'Unknown error'); | |
| post('error', { message }); | |
| } | |
| function resizeRenderer() { | |
| const width = forcedRenderSize?.width || root.clientWidth; | |
| const height = forcedRenderSize?.height || root.clientHeight; | |
| camera.aspect = width / height; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(width, height, false); | |
| } | |
| function getMaterial(color) { | |
| const key = color || '#c2d4e8'; | |
| if (!materialCache.has(key)) { | |
| materialCache.set( | |
| key, | |
| new THREE.MeshStandardMaterial({ | |
| color: new THREE.Color(key), | |
| metalness: 0.22, | |
| roughness: 0.58, | |
| }) | |
| ); | |
| } | |
| return materialCache.get(key); | |
| } | |
| function toArrayBuffer(bytes) { | |
| if (bytes instanceof ArrayBuffer) { | |
| return bytes; | |
| } | |
| if (ArrayBuffer.isView(bytes)) { | |
| return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); | |
| } | |
| return bytes; | |
| } | |
| function nextFrameYield() { | |
| return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); | |
| } | |
| function decodeBase64ToArrayBuffer(text) { | |
| const normalized = String(text || '').replace(/\s+/g, ''); | |
| const binary = window.atob(normalized); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let index = 0; index < binary.length; index += 1) { | |
| bytes[index] = binary.charCodeAt(index); | |
| } | |
| return bytes.buffer; | |
| } | |
| function normalizeMeshAssetFileName(rawName, fallbackMeshName = '') { | |
| const candidate = String(rawName || '').trim(); | |
| const stripped = candidate.replace(/\.b64\.txt$/i, ''); | |
| if (/\.stl$/i.test(stripped)) { | |
| return stripped; | |
| } | |
| const meshName = String(fallbackMeshName || '').trim(); | |
| if (meshName) { | |
| return meshName + '.STL'; | |
| } | |
| return stripped; | |
| } | |
| async function loadGeometry(src, rawBuffer = null) { | |
| if (geometryCache.has(src)) return geometryCache.get(src); | |
| const geometryPromise = (async () => { | |
| const parsedBuffer = rawBuffer ? toArrayBuffer(rawBuffer) : null; | |
| if (parsedBuffer) { | |
| const geometry = loader.parse(parsedBuffer); | |
| geometry.computeVertexNormals(); | |
| return geometry; | |
| } | |
| if (/\.stl\.b64\.txt(?:[?#].*)?$/i.test(String(src || ''))) { | |
| const encoded = await fetchText(src, 'force-cache'); | |
| const geometry = loader.parse(decodeBase64ToArrayBuffer(encoded)); | |
| geometry.computeVertexNormals(); | |
| return geometry; | |
| } | |
| if (/\.stl(?:[?#].*)?$/i.test(String(src || ''))) { | |
| const response = await fetch(src, { cache: 'force-cache' }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch ' + src + ' (' + response.status + ')'); | |
| } | |
| const geometry = loader.parse(await response.arrayBuffer()); | |
| geometry.computeVertexNormals(); | |
| return geometry; | |
| } | |
| return await new Promise((resolve, reject) => { | |
| reject(new Error('Unsupported geometry format: ' + src)); | |
| }); | |
| })(); | |
| geometryCache.set(src, geometryPromise); | |
| try { | |
| return await geometryPromise; | |
| } catch (error) { | |
| geometryCache.delete(src); | |
| throw error; | |
| } | |
| } | |
| async function fetchBuffer(url, cacheMode = 'force-cache') { | |
| if (bufferCache.has(url)) return bufferCache.get(url); | |
| const bufferPromise = (async () => { | |
| if (/\.stl\.b64\.txt(?:[?#].*)?$/i.test(String(url || ''))) { | |
| const encoded = await fetchText(url, cacheMode); | |
| return new Uint8Array(decodeBase64ToArrayBuffer(encoded)); | |
| } | |
| const response = await fetch(url, { cache: cacheMode }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); | |
| } | |
| return new Uint8Array(await response.arrayBuffer()); | |
| })(); | |
| bufferCache.set(url, bufferPromise); | |
| try { | |
| return await bufferPromise; | |
| } catch (error) { | |
| bufferCache.delete(url); | |
| throw error; | |
| } | |
| } | |
| function clearSceneMeshes() { | |
| while (mujocoRoot.children.length) { | |
| mujocoRoot.remove(mujocoRoot.children[0]); | |
| } | |
| renderables.length = 0; | |
| builtFrame = -1; | |
| } | |
| function clearVideoPlane() { | |
| if (videoPlane) { | |
| scene.remove(videoPlane); | |
| videoPlane.geometry?.dispose?.(); | |
| videoPlane.material?.dispose?.(); | |
| videoPlane = null; | |
| } | |
| if (videoTexture) { | |
| videoTexture.dispose?.(); | |
| videoTexture = null; | |
| } | |
| if (videoElement) { | |
| try { | |
| videoElement.pause(); | |
| } catch {} | |
| videoElement.src = ''; | |
| videoElement.load?.(); | |
| videoElement = null; | |
| } | |
| } | |
| function applyVideoPlaneLayout() { | |
| if (!videoPlane) return; | |
| videoPlane.geometry.dispose?.(); | |
| videoPlane.geometry = new THREE.PlaneGeometry(videoPlaneHeight * videoPlaneAspect, videoPlaneHeight); | |
| videoPlane.position.set(0, videoPlaneBottomY + videoPlaneHeight / 2, videoPlaneDepth); | |
| } | |
| function ensureVideoPlane(src) { | |
| clearVideoPlane(); | |
| if (!src) return; | |
| videoElement = document.createElement('video'); | |
| videoElement.src = src; | |
| videoElement.crossOrigin = 'anonymous'; | |
| videoElement.muted = true; | |
| videoElement.loop = false; | |
| videoElement.playsInline = true; | |
| videoElement.preload = 'auto'; | |
| videoTexture = new THREE.VideoTexture(videoElement); | |
| videoTexture.colorSpace = THREE.SRGBColorSpace; | |
| videoTexture.minFilter = THREE.LinearFilter; | |
| videoTexture.magFilter = THREE.LinearFilter; | |
| videoTexture.generateMipmaps = false; | |
| videoPlane = new THREE.Mesh( | |
| new THREE.PlaneGeometry(videoPlaneHeight * videoPlaneAspect, videoPlaneHeight), | |
| new THREE.MeshBasicMaterial({ | |
| map: videoTexture, | |
| transparent: false, | |
| opacity: 1, | |
| toneMapped: false, | |
| side: THREE.DoubleSide, | |
| }) | |
| ); | |
| applyVideoPlaneLayout(); | |
| scene.add(videoPlane); | |
| videoElement.addEventListener( | |
| 'loadedmetadata', | |
| () => { | |
| if (!videoPlane || !videoElement || !videoElement.videoWidth || !videoElement.videoHeight) return; | |
| videoPlaneAspect = videoElement.videoWidth / videoElement.videoHeight; | |
| applyVideoPlaneLayout(); | |
| }, | |
| { once: true } | |
| ); | |
| } | |
| function fitPresentationToRobot() { | |
| if (!renderables.length) return; | |
| tempBox.setFromObject(mujocoRoot); | |
| if (tempBox.isEmpty()) return; | |
| tempBox.getSize(tempSize); | |
| tempBox.getCenter(tempCenter); | |
| const robotHeight = Math.max(tempSize.y, 0.9); | |
| const robotWidth = Math.max(tempSize.x, 0.45); | |
| const robotDepth = Math.max(tempSize.z, 0.35); | |
| const eyeLine = tempBox.min.y + robotHeight * 0.48; | |
| const targetZ = tempCenter.z - robotDepth * 0.1; | |
| controls.target.set(tempCenter.x, eyeLine, targetZ); | |
| const baseDistance = Math.max( | |
| Number(robotMeta?.cameraDistance || 2.1) * 1.15, | |
| robotHeight * 1.75, | |
| robotWidth * 2.8, | |
| robotDepth * 3.6 | |
| ); | |
| camera.position.set( | |
| controls.target.x + baseDistance * 0.12, | |
| controls.target.y + robotHeight * 0.46, | |
| controls.target.z + baseDistance * 1.32 | |
| ); | |
| camera.near = 0.05; | |
| camera.far = 80; | |
| camera.updateProjectionMatrix(); | |
| camera.lookAt(controls.target); | |
| videoPlaneHeight = THREE.MathUtils.clamp(robotHeight * 1.22, 1.58, 1.96); | |
| videoPlaneDepth = tempCenter.z - THREE.MathUtils.clamp(robotDepth * 1.9 + 1.15, 1.65, 2.45); | |
| applyVideoPlaneLayout(); | |
| } | |
| function disposeRuntime() { | |
| try { data?.delete?.(); } catch {} | |
| try { model?.delete?.(); } catch {} | |
| try { vfs?.delete?.(); } catch {} | |
| data = null; | |
| model = null; | |
| vfs = null; | |
| } | |
| async function ensureMujoco() { | |
| if (mujoco) return mujoco; | |
| setStatus('runtime', 'Loading robot runtime...'); | |
| mujoco = await loadMujoco({ | |
| locateFile(path) { | |
| return '/hf-assets/vendor/mujoco/' + path; | |
| }, | |
| }); | |
| return mujoco; | |
| } | |
| async function fetchJson(url, cacheMode = 'force-cache') { | |
| if (jsonCache.has(url)) return jsonCache.get(url); | |
| const response = await fetch(url, { cache: cacheMode }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); | |
| } | |
| const value = await response.json(); | |
| jsonCache.set(url, value); | |
| return value; | |
| } | |
| async function fetchText(url, cacheMode = 'force-cache') { | |
| if (textCache.has(url)) return textCache.get(url); | |
| const response = await fetch(url, { cache: cacheMode }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); | |
| } | |
| const value = await response.text(); | |
| textCache.set(url, value); | |
| return value; | |
| } | |
| async function prewarmModelPackage(metaSrc) { | |
| if (!metaSrc) return null; | |
| if (modelPackageCache.has(metaSrc)) return modelPackageCache.get(metaSrc); | |
| const packagePromise = (async () => { | |
| const nextRobotMeta = await fetchJson(withAssetVersion(metaSrc), 'force-cache'); | |
| const packageUrl = withAssetVersion(nextRobotMeta.packageUrl); | |
| if (packageUrl) { | |
| try { | |
| const bundledPackage = await fetchJson(packageUrl, 'force-cache'); | |
| const xmlText = typeof bundledPackage?.xmlText === 'string' ? bundledPackage.xmlText : ''; | |
| if (!xmlText) { | |
| throw new Error('Robot model package is missing xmlText.'); | |
| } | |
| const visualGeoms = Array.isArray(bundledPackage?.visualGeoms) | |
| ? bundledPackage.visualGeoms.map((entry) => ({ | |
| ...entry, | |
| rawSrc: String(entry?.src || '').trim(), | |
| src: withAssetVersion(entry?.src), | |
| })) | |
| : []; | |
| const meshBase64BySrc = {}; | |
| if (bundledPackage && typeof bundledPackage.meshBase64BySrc === 'object' && bundledPackage.meshBase64BySrc) { | |
| Object.assign(meshBase64BySrc, bundledPackage.meshBase64BySrc); | |
| } | |
| const meshChunkUrls = Array.isArray(bundledPackage?.meshChunkUrls) | |
| ? bundledPackage.meshChunkUrls.map((src) => withAssetVersion(src)).filter(Boolean) | |
| : []; | |
| for (const chunkUrl of meshChunkUrls) { | |
| const chunkPayload = await fetchJson(chunkUrl, 'force-cache'); | |
| if (chunkPayload && typeof chunkPayload.meshBase64BySrc === 'object' && chunkPayload.meshBase64BySrc) { | |
| Object.assign(meshBase64BySrc, chunkPayload.meshBase64BySrc); | |
| } | |
| } | |
| const uniqueMeshUrls = [...new Set(visualGeoms.map((entry) => entry.src).filter(Boolean))]; | |
| const meshBuffers = new Map(); | |
| for (let index = 0; index < uniqueMeshUrls.length; index += 1) { | |
| const meshUrl = uniqueMeshUrls[index]; | |
| const visualEntry = visualGeoms.find((entry) => entry.src === meshUrl); | |
| const rawSrc = String(visualEntry?.rawSrc || '').trim(); | |
| const encodedMesh = typeof meshBase64BySrc[rawSrc] === 'string' ? meshBase64BySrc[rawSrc] : ''; | |
| if (!encodedMesh) { | |
| throw new Error('Robot model package is missing mesh payload for ' + rawSrc); | |
| } | |
| const meshBuffer = new Uint8Array(decodeBase64ToArrayBuffer(encodedMesh)); | |
| meshBuffers.set(meshUrl, meshBuffer); | |
| await loadGeometry(meshUrl, meshBuffer); | |
| if ((index + 1) % 2 === 0) { | |
| await nextFrameYield(); | |
| } | |
| } | |
| return { | |
| key: metaSrc, | |
| robotMeta: nextRobotMeta, | |
| visualMeta: { | |
| version: bundledPackage?.version || nextRobotMeta?.version || 1, | |
| visualGeoms, | |
| }, | |
| xmlText, | |
| visualGeoms, | |
| meshBuffers, | |
| }; | |
| } catch (error) { | |
| console.warn('[robot-viewer] Falling back to legacy G1 model chain.', error); | |
| } | |
| } | |
| const xmlUrl = withAssetVersion(nextRobotMeta.xmlUrl); | |
| if (!xmlUrl) throw new Error('Robot meta is missing xmlUrl.'); | |
| const xmlText = await fetchText(xmlUrl, 'force-cache'); | |
| let nextVisualMeta = nextRobotMeta; | |
| if (!Array.isArray(nextRobotMeta.visualGeoms)) { | |
| const visualMetaUrl = nextRobotMeta.modelMetaUrl; | |
| if (!visualMetaUrl) throw new Error('Robot meta is missing modelMetaUrl.'); | |
| nextVisualMeta = await fetchJson(withAssetVersion(visualMetaUrl), 'force-cache'); | |
| } | |
| const visualGeoms = Array.isArray(nextVisualMeta.visualGeoms) | |
| ? nextVisualMeta.visualGeoms.map((entry) => ({ | |
| ...entry, | |
| src: withAssetVersion(entry.src), | |
| })) | |
| : []; | |
| const uniqueMeshUrls = [...new Set(visualGeoms.map((entry) => entry.src).filter(Boolean))]; | |
| const meshBuffers = new Map(); | |
| for (let index = 0; index < uniqueMeshUrls.length; index += 1) { | |
| const meshUrl = uniqueMeshUrls[index]; | |
| const meshBuffer = await fetchBuffer(meshUrl, 'force-cache'); | |
| meshBuffers.set(meshUrl, meshBuffer); | |
| await loadGeometry(meshUrl, meshBuffer); | |
| if ((index + 1) % 2 === 0) { | |
| await nextFrameYield(); | |
| } | |
| } | |
| return { | |
| key: metaSrc, | |
| robotMeta: nextRobotMeta, | |
| visualMeta: nextVisualMeta, | |
| xmlText, | |
| visualGeoms, | |
| meshBuffers, | |
| }; | |
| })(); | |
| modelPackageCache.set(metaSrc, packagePromise); | |
| try { | |
| return await packagePromise; | |
| } catch (error) { | |
| modelPackageCache.delete(metaSrc); | |
| throw error; | |
| } | |
| } | |
| async function loadMotionData(motionSrc) { | |
| if (!motionSrc) throw new Error('Robot motion data is missing.'); | |
| if (motionCache.has(motionSrc)) return motionCache.get(motionSrc); | |
| const motionPromise = fetchJson(motionSrc, 'no-store').then((value) => stabilizeLeadingFrames(value)); | |
| motionCache.set(motionSrc, motionPromise); | |
| try { | |
| return await motionPromise; | |
| } catch (error) { | |
| motionCache.delete(motionSrc); | |
| throw error; | |
| } | |
| } | |
| async function ensureModelRuntime(metaSrc, packageData, loadToken) { | |
| const runtime = await ensureMujoco(); | |
| if (loadToken !== activeLoadToken) return; | |
| if (currentModelPackageKey === metaSrc && model && data && renderables.length > 0) { | |
| robotMeta = packageData.robotMeta; | |
| visualMeta = packageData.visualMeta; | |
| return; | |
| } | |
| disposeRuntime(); | |
| clearSceneMeshes(); | |
| if (loadToken !== activeLoadToken) return; | |
| vfs = new runtime.MjVFS(); | |
| for (const entry of packageData.visualGeoms) { | |
| const meshUrl = String(entry.src || '').trim(); | |
| if (!meshUrl) continue; | |
| const meshBuffer = packageData.meshBuffers.get(meshUrl); | |
| if (!meshBuffer) continue; | |
| let meshFileName = ''; | |
| try { | |
| const meshUrlObject = new URL(meshUrl, window.location.origin); | |
| meshFileName = meshUrlObject.pathname.split('/').pop() || ''; | |
| } catch { | |
| meshFileName = meshUrl.split('/').pop() || ''; | |
| } | |
| meshFileName = normalizeMeshAssetFileName(meshFileName, entry.meshName); | |
| if (!meshFileName) continue; | |
| vfs.addBuffer('meshes/' + meshFileName, meshBuffer); | |
| } | |
| if (loadToken !== activeLoadToken) return; | |
| setStatus('motion', 'Preparing robot model...'); | |
| model = runtime.MjModel.from_xml_string(packageData.xmlText, vfs); | |
| data = new runtime.MjData(model); | |
| robotMeta = packageData.robotMeta; | |
| visualMeta = packageData.visualMeta; | |
| for (let index = 0; index < packageData.visualGeoms.length; index += 1) { | |
| const entry = packageData.visualGeoms[index]; | |
| const geometry = await loadGeometry(entry.src, packageData.meshBuffers.get(entry.src) || null); | |
| if (loadToken !== activeLoadToken) return; | |
| const group = new THREE.Group(); | |
| const mesh = new THREE.Mesh(geometry, getMaterial(entry.color)); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| const meshOffsetQuat = new THREE.Quaternion(...(entry.meshOffsetQuatXyzw || [0, 0, 0, 1])).invert(); | |
| const meshOffsetPos = new THREE.Vector3(...(entry.meshOffsetPos || [0, 0, 0])) | |
| .applyQuaternion(meshOffsetQuat) | |
| .multiplyScalar(-1); | |
| mesh.position.copy(meshOffsetPos); | |
| mesh.quaternion.copy(meshOffsetQuat); | |
| mesh.scale.set(...(entry.scale || [1, 1, 1])); | |
| group.add(mesh); | |
| mujocoRoot.add(group); | |
| renderables.push({ | |
| geomIndex: Number(entry.geomIndex), | |
| group, | |
| }); | |
| if ((index + 1) % 4 === 0) { | |
| await nextFrameYield(); | |
| if (loadToken !== activeLoadToken) return; | |
| } | |
| } | |
| currentModelPackageKey = metaSrc; | |
| } | |
| async function prepareModel(metaSrc, motionSrc, videoSrc, loadToken) { | |
| setStatus('assets', 'Loading robot assets...'); | |
| const [packageData, nextMotion] = await Promise.all([ | |
| prewarmModelPackage(metaSrc), | |
| loadMotionData(motionSrc), | |
| ]); | |
| if (loadToken !== activeLoadToken || !packageData) return; | |
| motion = nextMotion; | |
| frameCount = Number(motion.frameCount || 0); | |
| fps = Number(motion.fps || 30); | |
| rootQuatOrder = motion.rootQuatOrder || 'xyzw'; | |
| qposLayout = { | |
| rootPosStart: Number(motion.qposLayout?.rootPosStart ?? 0), | |
| rootQuatStart: Number(motion.qposLayout?.rootQuatStart ?? 3), | |
| dofStart: Number(motion.qposLayout?.dofStart ?? 7), | |
| }; | |
| await ensureModelRuntime(metaSrc, packageData, loadToken); | |
| if (loadToken !== activeLoadToken) return; | |
| ensureVideoPlane(videoSrc); | |
| applyFrame(0, true); | |
| fitPresentationToRobot(); | |
| setStatus('ready', 'Robot preview is ready.'); | |
| post('ready'); | |
| } | |
| function setRootQuat(qpos, rootQuat) { | |
| if (rootQuatOrder === 'xyzw') { | |
| qpos[qposLayout.rootQuatStart + 0] = rootQuat[3]; | |
| qpos[qposLayout.rootQuatStart + 1] = rootQuat[0]; | |
| qpos[qposLayout.rootQuatStart + 2] = rootQuat[1]; | |
| qpos[qposLayout.rootQuatStart + 3] = rootQuat[2]; | |
| } else { | |
| qpos[qposLayout.rootQuatStart + 0] = rootQuat[0]; | |
| qpos[qposLayout.rootQuatStart + 1] = rootQuat[1]; | |
| qpos[qposLayout.rootQuatStart + 2] = rootQuat[2]; | |
| qpos[qposLayout.rootQuatStart + 3] = rootQuat[3]; | |
| } | |
| } | |
| function writeFrameToQpos(frameFloat) { | |
| if (!data || !motion || !Array.isArray(motion.root_pos) || !Array.isArray(motion.root_rot_xyzw) || !Array.isArray(motion.dof_pos)) { | |
| return false; | |
| } | |
| const safeFrame = Math.max(0, Math.min(frameCount - 1, frameFloat)); | |
| const frame0 = Math.floor(safeFrame); | |
| const frame1 = Math.min(frame0 + 1, frameCount - 1); | |
| const alpha = Math.max(0, Math.min(1, safeFrame - frame0)); | |
| const rootPos0 = motion.root_pos[frame0]; | |
| const rootPos1 = motion.root_pos[frame1] || rootPos0; | |
| const rootQuat0 = motion.root_rot_xyzw[frame0]; | |
| const rootQuat1 = motion.root_rot_xyzw[frame1] || rootQuat0; | |
| const dofPos0 = motion.dof_pos[frame0]; | |
| const dofPos1 = motion.dof_pos[frame1] || dofPos0; | |
| if (!rootPos0 || !rootQuat0 || !dofPos0) return false; | |
| const qpos = data.qpos; | |
| qpos[qposLayout.rootPosStart + 0] = rootPos0[0] + (rootPos1[0] - rootPos0[0]) * alpha; | |
| qpos[qposLayout.rootPosStart + 1] = rootPos0[1] + (rootPos1[1] - rootPos0[1]) * alpha; | |
| qpos[qposLayout.rootPosStart + 2] = rootPos0[2] + (rootPos1[2] - rootPos0[2]) * alpha; | |
| tempQuaternion.set(rootQuat0[0], rootQuat0[1], rootQuat0[2], rootQuat0[3]); | |
| const targetQuat = new THREE.Quaternion(rootQuat1[0], rootQuat1[1], rootQuat1[2], rootQuat1[3]); | |
| tempQuaternion.slerp(targetQuat, alpha); | |
| setRootQuat(qpos, [tempQuaternion.x, tempQuaternion.y, tempQuaternion.z, tempQuaternion.w]); | |
| for (let i = 0; i < dofPos0.length; i += 1) { | |
| const nextValue = dofPos0[i] + ((dofPos1[i] ?? dofPos0[i]) - dofPos0[i]) * alpha; | |
| qpos[qposLayout.dofStart + i] = nextValue; | |
| } | |
| mujoco.mj_forward(model, data); | |
| return true; | |
| } | |
| function applyFrame(frameFloat, force = false) { | |
| if (!data || !model || !motion || !frameCount) return; | |
| if (!force && Math.abs(frameFloat - builtFrame) < 0.001) return; | |
| if (!writeFrameToQpos(frameFloat)) return; | |
| const geomXpos = data.geom_xpos; | |
| const geomXmat = data.geom_xmat; | |
| for (const entry of renderables) { | |
| const posOffset = entry.geomIndex * 3; | |
| const matOffset = entry.geomIndex * 9; | |
| tempMatrix.set( | |
| geomXmat[matOffset + 0], geomXmat[matOffset + 1], geomXmat[matOffset + 2], 0, | |
| geomXmat[matOffset + 3], geomXmat[matOffset + 4], geomXmat[matOffset + 5], 0, | |
| geomXmat[matOffset + 6], geomXmat[matOffset + 7], geomXmat[matOffset + 8], 0, | |
| 0, 0, 0, 1 | |
| ); | |
| tempQuaternion.setFromRotationMatrix(tempMatrix); | |
| entry.group.position.set( | |
| geomXpos[posOffset + 0], | |
| geomXpos[posOffset + 1], | |
| geomXpos[posOffset + 2] | |
| ); | |
| entry.group.quaternion.copy(tempQuaternion); | |
| } | |
| builtFrame = frameFloat; | |
| } | |
| function syncPlayback(time, isPlaying) { | |
| const duration = frameCount > 0 ? frameCount / Math.max(fps, 1) : 0; | |
| const nextTime = getNormalizedPlaybackTime(Number(time || 0), duration); | |
| const nextPlaying = !!isPlaying; | |
| const drift = Math.abs(nextTime - currentPlaybackTime); | |
| if (nextPlaying === externalPlaying) { | |
| if (nextPlaying && drift < 0.08) { | |
| return; | |
| } | |
| if (!nextPlaying && drift < 0.002) { | |
| return; | |
| } | |
| } | |
| currentPlaybackTime = nextTime; | |
| playbackAnchorTime = nextTime; | |
| playbackAnchorPerf = performance.now(); | |
| externalPlaying = nextPlaying; | |
| if (videoElement) { | |
| const drift = Math.abs((videoElement.currentTime || 0) - nextTime); | |
| if (drift > 0.08) { | |
| try { | |
| videoElement.currentTime = nextTime; | |
| } catch {} | |
| } | |
| if (nextPlaying) { | |
| const playPromise = videoElement.play?.(); | |
| if (playPromise?.catch) playPromise.catch(() => {}); | |
| } else { | |
| try { | |
| videoElement.pause?.(); | |
| } catch {} | |
| } | |
| } | |
| } | |
| async function handleLoadRequest(motionSrc, metaSrc, videoSrc) { | |
| activeLoadToken += 1; | |
| await prepareModel(metaSrc, motionSrc, videoSrc, activeLoadToken); | |
| } | |
| async function handlePrewarmRequest(modelSrcs) { | |
| const uniqueModelSrcs = [...new Set((Array.isArray(modelSrcs) ? modelSrcs : []).filter(Boolean))]; | |
| if (uniqueModelSrcs.length === 0) return; | |
| await Promise.allSettled(uniqueModelSrcs.map((modelSrc) => prewarmModelPackage(modelSrc))); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (data && frameCount > 0) { | |
| const duration = frameCount / Math.max(fps, 1); | |
| if (externalPlaying) { | |
| currentPlaybackTime = playbackAnchorTime + (performance.now() - playbackAnchorPerf) / 1000; | |
| } | |
| if (externalPlaying && duration > 0 && currentPlaybackTime >= duration) { | |
| currentPlaybackTime = getTerminalPlaybackTime(duration); | |
| } | |
| const nextFrame = currentPlaybackTime * fps; | |
| applyFrame(nextFrame); | |
| } | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| window.addEventListener('message', async (event) => { | |
| const payload = event.data || {}; | |
| if (payload.viewerNonce && payload.viewerNonce !== viewerNonce) { | |
| return; | |
| } | |
| try { | |
| if (payload.type === 'robot-load' && payload.src && payload.modelSrc) { | |
| await handleLoadRequest(payload.src, payload.modelSrc, payload.videoSrc || ''); | |
| } else if (payload.type === 'robot-prewarm' && Array.isArray(payload.modelSrcs)) { | |
| await handlePrewarmRequest(payload.modelSrcs); | |
| } else if (payload.type === 'robot-sync') { | |
| syncedTime = Number(payload.time || 0); | |
| syncPlayback(syncedTime, !!payload.playing); | |
| } else if (payload.type === 'robot-recording-config') { | |
| if (Number.isFinite(payload.width) && Number.isFinite(payload.height) && payload.width > 0 && payload.height > 0) { | |
| forcedRenderSize = { width: Number(payload.width), height: Number(payload.height) }; | |
| } else { | |
| forcedRenderSize = null; | |
| } | |
| resizeRenderer(); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| setError(error); | |
| } | |
| }); | |
| window.addEventListener('resize', resizeRenderer); | |
| post('boot'); | |
| const params = new URLSearchParams(window.location.search); | |
| const directMotionSrc = params.get('src'); | |
| const directModelSrc = params.get('modelSrc'); | |
| const directVideoSrc = params.get('videoSrc') || ''; | |
| const directTime = Number(params.get('time') || 0); | |
| const directPlaying = params.get('playing') === '1' || params.get('playing') === 'true'; | |
| if (directMotionSrc && directModelSrc) { | |
| syncPlayback(directTime, directPlaying); | |
| try { | |
| await handleLoadRequest(directMotionSrc, directModelSrc, directVideoSrc); | |
| } catch (error) { | |
| console.error(error); | |
| setError(error); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |