AIMoCap / assets /runtime /robot-motion-viewer-runtime.html
animtex's picture
Publish compiled HF Space artifact
8a1463a
Raw
History Blame Contribute Delete
38.7 kB
<!doctype html>
<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>