AIMoCap / assets /runtime /hfspace-fbx-viewer-runtime.html
animtex's picture
Publish compiled HF Space artifact
41c0a8c
Raw
History Blame Contribute Delete
18.7 kB
<!doctype html>
<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>