Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>AIMoCap FBX Viewer</title> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #040815; | |
| color: #dbeafe; | |
| font-family: Arial, sans-serif; | |
| } | |
| #app { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| background: | |
| radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 34%), | |
| radial-gradient(circle at bottom, rgba(56, 189, 248, 0.1), transparent 42%), | |
| linear-gradient(180deg, #0b1220, #020617 58%); | |
| } | |
| #status { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| color: #cbd5f5; | |
| text-align: center; | |
| z-index: 2; | |
| pointer-events: none; | |
| } | |
| #status.is-hidden { | |
| display: none; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| .legend { | |
| position: absolute; | |
| right: 14px; | |
| bottom: 12px; | |
| color: rgba(191, 219, 254, 0.74); | |
| font-size: 11px; | |
| letter-spacing: 0.04em; | |
| z-index: 1; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="status">Loading AIMoCap FBX preview...</div> | |
| <div class="legend">Orbit: drag / Zoom: wheel</div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from "/hf-assets/vendor/three/three.module.js"; | |
| import { OrbitControls } from "/hf-assets/vendor/three/controls/OrbitControls.js"; | |
| import { FBXLoader } from "/hf-assets/vendor/three/loaders/FBXLoader.js"; | |
| const app = document.getElementById("app"); | |
| const statusEl = document.getElementById("status"); | |
| const initialParams = new URLSearchParams(window.location.search); | |
| const viewerNonce = initialParams.get("viewerNonce") || ""; | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); | |
| renderer.setPixelRatio(window.devicePixelRatio || 1); | |
| renderer.setSize(app.clientWidth, app.clientHeight); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 0.78; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| app.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(36, app.clientWidth / app.clientHeight, 0.05, 1000); | |
| camera.position.set(2.8, 2.15, 5.8); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.06; | |
| controls.target.set(0, 1.05, 0); | |
| controls.minDistance = 0.35; | |
| controls.maxDistance = 52; | |
| controls.maxPolarAngle = Math.PI - 0.06; | |
| controls.minPolarAngle = 0.06; | |
| const ambient = new THREE.AmbientLight(0xffffff, 0.54); | |
| scene.add(ambient); | |
| const hemi = new THREE.HemisphereLight(0xe8eef5, 0xa9b4c2, 0.72); | |
| scene.add(hemi); | |
| const key = new THREE.DirectionalLight(0xe7edf5, 0.98); | |
| key.position.set(3.8, 5.8, 4.6); | |
| key.castShadow = true; | |
| key.shadow.mapSize.width = 2048; | |
| key.shadow.mapSize.height = 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.32); | |
| fill.position.set(-3.4, 2.8, 4.2); | |
| scene.add(fill); | |
| const rim = new THREE.DirectionalLight(0xc7d6eb, 0.18); | |
| rim.position.set(-2.8, 4.2, -4.8); | |
| scene.add(rim); | |
| const top = new THREE.PointLight(0xd9e6f4, 0.08, 12); | |
| top.position.set(0, 2.9, 1.2); | |
| scene.add(top); | |
| const floor = new THREE.Mesh( | |
| new THREE.CircleGeometry(20, 96), | |
| new THREE.MeshStandardMaterial({ | |
| color: 0xc4ccd7, | |
| roughness: 0.95, | |
| metalness: 0.02, | |
| transparent: true, | |
| opacity: 0.98, | |
| }) | |
| ); | |
| floor.rotation.x = -Math.PI / 2; | |
| floor.position.y = -0.02; | |
| floor.receiveShadow = true; | |
| scene.add(floor); | |
| const grid = new THREE.GridHelper(16, 16, 0x2f445f, 0x182433); | |
| grid.material.opacity = 0.12; | |
| grid.material.transparent = true; | |
| scene.add(grid); | |
| const shadowCatcher = new THREE.Mesh( | |
| new THREE.CircleGeometry(1.75, 48), | |
| new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.08, 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: 0xe2e8ef }) | |
| ); | |
| backdrop.position.set(0, 4, -8.5); | |
| scene.add(backdrop); | |
| const videoEl = document.createElement("video"); | |
| videoEl.preload = "metadata"; | |
| videoEl.muted = true; | |
| videoEl.playsInline = true; | |
| videoEl.crossOrigin = "anonymous"; | |
| videoEl.style.display = "none"; | |
| document.body.appendChild(videoEl); | |
| const loader = new FBXLoader(); | |
| let root = null; | |
| let mixer = null; | |
| let clipDuration = 0; | |
| let currentTime = 0; | |
| let playing = false; | |
| let playbackAnchorPerf = performance.now(); | |
| let playbackAnchorTime = 0; | |
| let loadToken = 0; | |
| let cameraDistanceScale = 1.72; | |
| let videoPlane = null; | |
| let videoTexture = null; | |
| let videoMaterial = null; | |
| function disposeVideoPlane() { | |
| if (videoPlane) { | |
| scene.remove(videoPlane); | |
| videoPlane.geometry?.dispose?.(); | |
| videoPlane = null; | |
| } | |
| if (videoMaterial) { | |
| videoMaterial.dispose?.(); | |
| videoMaterial = null; | |
| } | |
| if (videoTexture) { | |
| videoTexture.dispose?.(); | |
| videoTexture = null; | |
| } | |
| videoEl.pause(); | |
| videoEl.removeAttribute("src"); | |
| videoEl.load(); | |
| } | |
| function updateVideoPlaneGeometry() { | |
| if (!videoPlane || !videoEl.videoWidth || !videoEl.videoHeight) { | |
| return; | |
| } | |
| const aspect = videoEl.videoWidth / Math.max(videoEl.videoHeight, 1); | |
| const width = 1.9; | |
| const height = width / Math.max(aspect, 0.001); | |
| const geometry = new THREE.PlaneGeometry(width, height); | |
| videoPlane.geometry?.dispose?.(); | |
| videoPlane.geometry = geometry; | |
| videoPlane.position.set(2.1, 1.28, -1.48); | |
| videoPlane.rotation.y = -0.28; | |
| } | |
| function ensureVideoPlane(videoSrc) { | |
| disposeVideoPlane(); | |
| if (!videoSrc) { | |
| return; | |
| } | |
| videoTexture = new THREE.VideoTexture(videoEl); | |
| videoTexture.colorSpace = THREE.SRGBColorSpace; | |
| videoTexture.minFilter = THREE.LinearFilter; | |
| videoTexture.magFilter = THREE.LinearFilter; | |
| videoMaterial = new THREE.MeshBasicMaterial({ | |
| map: videoTexture, | |
| side: THREE.DoubleSide, | |
| toneMapped: false, | |
| }); | |
| videoPlane = new THREE.Mesh(new THREE.PlaneGeometry(1.9, 1.08), videoMaterial); | |
| videoPlane.position.set(2.1, 1.28, -1.48); | |
| videoPlane.rotation.y = -0.28; | |
| scene.add(videoPlane); | |
| videoEl.src = videoSrc; | |
| videoEl.load(); | |
| } | |
| function syncVideoPlayback() { | |
| if (!videoEl.src) { | |
| return; | |
| } | |
| if (videoEl.readyState >= 1 && Number.isFinite(currentTime) && Math.abs((videoEl.currentTime || 0) - currentTime) > 0.14) { | |
| try { | |
| videoEl.currentTime = currentTime; | |
| } catch (_error) { | |
| // no-op | |
| } | |
| } | |
| if (playing) { | |
| videoEl.play().catch(() => null); | |
| } else { | |
| videoEl.pause(); | |
| } | |
| } | |
| videoEl.addEventListener("loadedmetadata", () => { | |
| updateVideoPlaneGeometry(); | |
| syncVideoPlayback(); | |
| }); | |
| videoEl.addEventListener("canplay", () => { | |
| syncVideoPlayback(); | |
| }); | |
| function postToParent(payload) { | |
| window.parent.postMessage({ source: "fbx-viewer", viewerNonce, ...payload }, "*"); | |
| } | |
| function setStatus(message) { | |
| if (!message) { | |
| statusEl.textContent = ""; | |
| statusEl.classList.add("is-hidden"); | |
| postToParent({ type: "status", phase: "ready", message: "FBX preview is ready." }); | |
| return; | |
| } | |
| statusEl.textContent = message; | |
| statusEl.classList.remove("is-hidden"); | |
| postToParent({ type: "status", phase: "loading", message }); | |
| } | |
| function getTerminalClipTime(duration) { | |
| if (!Number.isFinite(duration) || duration <= 0) { | |
| return 0; | |
| } | |
| return Math.max(duration - 1 / 60, 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, getTerminalClipTime(duration)); | |
| } | |
| function applyPlaybackState(time, isPlaying) { | |
| currentTime = getNormalizedPlaybackTime(Number(time || 0), clipDuration); | |
| playing = !!isPlaying; | |
| playbackAnchorTime = currentTime; | |
| playbackAnchorPerf = performance.now(); | |
| if (mixer) { | |
| mixer.setTime(currentTime); | |
| } | |
| syncVideoPlayback(); | |
| } | |
| function clearCurrentModel() { | |
| if (root) { | |
| scene.remove(root); | |
| root.traverse((child) => { | |
| if (child.geometry?.dispose) { | |
| child.geometry.dispose(); | |
| } | |
| if (Array.isArray(child.material)) { | |
| child.material.forEach((material) => material?.dispose?.()); | |
| } else { | |
| child.material?.dispose?.(); | |
| } | |
| }); | |
| } | |
| root = null; | |
| mixer = null; | |
| clipDuration = 0; | |
| currentTime = 0; | |
| disposeVideoPlane(); | |
| } | |
| function normalizeModelToViewport(object) { | |
| object.updateMatrixWorld(true); | |
| const originalBox = new THREE.Box3().setFromObject(object); | |
| const originalSize = originalBox.getSize(new THREE.Vector3()); | |
| const originalCenter = originalBox.getCenter(new THREE.Vector3()); | |
| const scaleFactor = 1.82 / Math.max(originalSize.y, 0.001); | |
| object.scale.setScalar(scaleFactor); | |
| object.position.x = -originalCenter.x * scaleFactor; | |
| object.position.z = -originalCenter.z * scaleFactor; | |
| object.position.y = -originalBox.min.y * scaleFactor; | |
| object.updateMatrixWorld(true); | |
| const alignedBox = new THREE.Box3().setFromObject(object); | |
| floor.position.y = Math.max(-0.02, alignedBox.min.y - 0.01); | |
| grid.position.y = floor.position.y; | |
| shadowCatcher.position.y = floor.position.y + 0.002; | |
| shadowCatcher.scale.set(1.08, 1, 0.78); | |
| backdrop.position.y = 1.45; | |
| } | |
| function boostMaterial(material) { | |
| const sourceColor = material?.color ? material.color.clone() : new THREE.Color(0xd4dce6); | |
| const boostedColor = new THREE.Color(0xc7d2de).lerp(sourceColor, 0.12); | |
| const upgraded = new THREE.MeshStandardMaterial({ | |
| color: boostedColor, | |
| map: material?.map || null, | |
| transparent: !!material?.transparent, | |
| opacity: material?.opacity ?? 1, | |
| side: THREE.DoubleSide, | |
| roughness: material?.map ? 0.72 : 0.62, | |
| metalness: 0.01, | |
| emissive: new THREE.Color(0x111722), | |
| emissiveIntensity: material?.map ? 0.035 : 0.05, | |
| }); | |
| upgraded.skinning = true; | |
| upgraded.needsUpdate = true; | |
| return upgraded; | |
| } | |
| function fitCamera(object, fit = "wider") { | |
| const box = new THREE.Box3().setFromObject(object); | |
| const size = box.getSize(new THREE.Vector3()); | |
| const target = new THREE.Vector3(0, box.min.y + size.y * 0.56, 0.06); | |
| const verticalDistance = size.y / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2)); | |
| const horizontalDistance = size.x / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect); | |
| cameraDistanceScale = fit === "wide" ? 1.42 : fit === "default" ? 1 : 1.72; | |
| const distance = Math.max(verticalDistance, horizontalDistance) * 1.02 * cameraDistanceScale; | |
| controls.target.copy(target); | |
| camera.position.set( | |
| target.x + size.x * 0.07, | |
| target.y + size.y * 0.02, | |
| target.z + distance * 1.06 | |
| ); | |
| camera.near = Math.max(0.03, distance / 150); | |
| camera.far = Math.max(100, distance * 12); | |
| camera.updateProjectionMatrix(); | |
| controls.minDistance = Math.max(distance * 0.28, 0.35); | |
| controls.maxDistance = Math.max(distance * 4.2, 10); | |
| controls.minPolarAngle = 0.04; | |
| controls.maxPolarAngle = Math.PI - 0.04; | |
| camera.lookAt(target); | |
| controls.update(); | |
| } | |
| async function loadModel(src, options = {}) { | |
| const token = ++loadToken; | |
| setStatus("Loading AIMoCap FBX preview..."); | |
| clearCurrentModel(); | |
| return await new Promise((resolve, reject) => { | |
| loader.load( | |
| src, | |
| (object) => { | |
| if (token !== loadToken) { | |
| resolve(false); | |
| return; | |
| } | |
| object.traverse((child) => { | |
| if (child.isMesh) { | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| const materials = Array.isArray(child.material) ? child.material : [child.material]; | |
| child.material = Array.isArray(child.material) | |
| ? materials.map((material) => boostMaterial(material)) | |
| : boostMaterial(materials[0]); | |
| } | |
| }); | |
| root = object; | |
| scene.add(object); | |
| if (Array.isArray(object.animations) && object.animations.length > 0) { | |
| mixer = new THREE.AnimationMixer(object); | |
| object.animations.forEach((clip) => { | |
| clipDuration = Math.max(clipDuration, Number(clip?.duration || 0)); | |
| const action = mixer.clipAction(clip); | |
| action.clampWhenFinished = true; | |
| action.setLoop(THREE.LoopOnce, 1); | |
| action.play(); | |
| }); | |
| } | |
| normalizeModelToViewport(object); | |
| fitCamera(object, options.cameraFit || "wider"); | |
| ensureVideoPlane(options.videoSrc || ""); | |
| applyPlaybackState(options.time || 0, !!options.autoplay); | |
| setStatus(""); | |
| postToParent({ type: "ready", duration: clipDuration || 0 }); | |
| resolve(true); | |
| }, | |
| undefined, | |
| (error) => { | |
| if (token !== loadToken) { | |
| resolve(false); | |
| return; | |
| } | |
| const message = error instanceof Error ? error.message : "Failed to load FBX preview."; | |
| setStatus("Failed to load FBX preview."); | |
| postToParent({ type: "error", message }); | |
| reject(error); | |
| } | |
| ); | |
| }); | |
| } | |
| function readInitialLoadParams() { | |
| const params = new URLSearchParams(window.location.search); | |
| const src = params.get("src") || ""; | |
| const videoSrc = params.get("videoSrc") || ""; | |
| const autoplay = params.get("autoplay") === "1"; | |
| const playingParam = params.get("playing"); | |
| const time = Number(params.get("time") || 0); | |
| const cameraFit = params.get("cameraFit") || "wider"; | |
| return { | |
| src, | |
| videoSrc, | |
| autoplay, | |
| playing: playingParam === null ? autoplay : playingParam === "1", | |
| time, | |
| cameraFit, | |
| }; | |
| } | |
| async function handleLoadMessage(data) { | |
| if (!data?.src) { | |
| setStatus("No FBX preview is available for this result yet."); | |
| return; | |
| } | |
| await loadModel(data.src, { | |
| cameraFit: data.cameraFit || "wider", | |
| autoplay: !!data.autoplay, | |
| time: data.time || 0, | |
| videoSrc: data.videoSrc || "", | |
| }); | |
| } | |
| window.addEventListener("message", async (event) => { | |
| const data = event?.data; | |
| if (!data || typeof data !== "object") return; | |
| if (data.viewerNonce && data.viewerNonce !== viewerNonce) return; | |
| if (data.type === "fbx-load") { | |
| try { | |
| await handleLoadMessage(data); | |
| } catch { | |
| return; | |
| } | |
| return; | |
| } | |
| if (data.type === "fbx-sync") { | |
| applyPlaybackState(data.time || 0, !!data.playing); | |
| } | |
| }); | |
| const initial = readInitialLoadParams(); | |
| if (initial.src) { | |
| handleLoadMessage({ | |
| type: "fbx-load", | |
| src: initial.src, | |
| videoSrc: initial.videoSrc, | |
| autoplay: initial.playing, | |
| time: initial.time, | |
| cameraFit: initial.cameraFit, | |
| }).catch(() => null); | |
| } else { | |
| setStatus("No FBX preview is available for this result yet."); | |
| } | |
| function render() { | |
| requestAnimationFrame(render); | |
| if (mixer) { | |
| if (playing && clipDuration > 0) { | |
| currentTime = playbackAnchorTime + (performance.now() - playbackAnchorPerf) / 1000; | |
| currentTime = getNormalizedPlaybackTime(currentTime, clipDuration); | |
| if (currentTime >= getTerminalClipTime(clipDuration)) { | |
| playing = false; | |
| playbackAnchorTime = getTerminalClipTime(clipDuration); | |
| } | |
| } | |
| mixer.setTime(currentTime); | |
| } | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| render(); | |
| postToParent({ type: "boot" }); | |
| window.addEventListener("resize", () => { | |
| const width = app.clientWidth; | |
| const height = app.clientHeight; | |
| camera.aspect = width / Math.max(height, 1); | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(width, height); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |