Files
PozzettiAndrea-ComfyUI-Geom…/javascript/viewer_fbx_animation.html
T
PozzettiAndrea 414f759791 Vendor frontend JS into javascript/; drop the prestartup copy dance
The viewer JS/HTML/bundles are now committed in javascript/ (declared via
pyproject [tool.comfy] web = "javascript") instead of being copied from the
shared comfy-3d-viewers pip package at prestartup. Consequences:

- requirements.txt is now just comfy-env (host env installs nothing else,
  per the never-touch-the-host-env principle)
- prestartup_script.py no longer copies viewers or web_src; it keeps only
  setup_env() and the input-asset copies
- WEB_DIRECTORY attribute removed; the pyproject declaration is the single
  source (served at /extensions/comfyui-geometrypack/, keyed by project name)
- web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked
  source, edited directly
- viewer_pbr.html hardcoded-fallback URL updated to the new mount name

Iframe URLs auto-detect the mount from import.meta.url, so the folder rename
and mount-name change are transparent at runtime.
2026-08-12 17:37:09 +02:00

317 lines
11 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
#canvas-container { width: 100%; height: 100%; }
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 18px;
z-index: 50;
}
</style>
</head>
<body>
<div id="loading">Loading animated FBX...</div>
<div id="canvas-container"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
let scene, camera, renderer, controls;
let currentModel = null;
let skeletonHelper = null;
let modelBoundingBox = null;
let mixer = null;
let currentAction = null;
let animations = [];
let clock = new THREE.Clock();
let isPlaying = false;
let showSkeleton = true;
let showMesh = true;
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(0, 1.5, 3);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
document.getElementById('canvas-container').appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 7.5);
directionalLight.castShadow = true;
scene.add(directionalLight);
const gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(1);
scene.add(axesHelper);
animate();
window.addEventListener('resize', onWindowResize);
window.addEventListener('message', handleMessage);
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
}
function handleMessage(event) {
const { type, ...data } = event.data;
switch(type) {
case 'LOAD_FBX': loadFBX(data.path); break;
case 'PLAY_PAUSE': togglePlayPause(); break;
case 'RESET': resetAnimation(); break;
case 'SET_TIMELINE': setTimeline(data.progress); break;
case 'SET_SPEED': setSpeed(data.speed); break;
case 'SET_LOOP': setLoop(data.loop); break;
case 'CHANGE_ANIMATION': playAnimation(data.index); break;
case 'TOGGLE_SKELETON': toggleSkeleton(data.visible); break;
case 'TOGGLE_MESH': toggleMesh(data.visible); break;
case 'TOGGLE_XRAY': toggleXRay(data.xray); break;
case 'RESET_CAMERA': resetCamera(); break;
}
}
function loadFBX(path) {
const loader = new FBXLoader();
const url = (path.startsWith('http') || path.startsWith('blob:')) ? path : window.parent.location.origin + path;
loader.load(url, (fbx) => {
if (currentModel) scene.remove(currentModel);
if (skeletonHelper) scene.remove(skeletonHelper);
if (mixer) mixer.stopAllAction();
currentModel = fbx;
scene.add(fbx);
fbx.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
child.visible = showMesh;
}
});
let skinnedMesh = null;
fbx.traverse((child) => {
if (child.isSkinnedMesh && !skinnedMesh) skinnedMesh = child;
});
if (skinnedMesh && skinnedMesh.skeleton) {
skeletonHelper = new THREE.SkeletonHelper(fbx);
skeletonHelper.material.linewidth = 2;
skeletonHelper.visible = showSkeleton;
scene.add(skeletonHelper);
}
animations = fbx.animations || [];
if (animations.length > 0) {
setupAnimations();
} else {
notifyParent({ type: 'NO_ANIMATIONS' });
}
centerModel(fbx);
document.getElementById('loading').style.display = 'none';
}, (xhr) => {
const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
document.getElementById('loading').textContent = 'Loading... ' + percent + '%';
}, (error) => {
document.getElementById('loading').textContent = 'Error loading FBX';
});
}
function setupAnimations() {
mixer = new THREE.AnimationMixer(currentModel);
const animationNames = animations.map((clip, i) => ({
index: i,
name: clip.name || ('Animation ' + (i + 1)),
duration: clip.duration
}));
notifyParent({ type: 'ANIMATIONS_LOADED', animations: animationNames });
playAnimation(0);
}
function playAnimation(index) {
if (mixer && animations[index]) {
if (currentAction) currentAction.stop();
currentAction = mixer.clipAction(animations[index]);
currentAction.setLoop(THREE.LoopRepeat);
currentAction.timeScale = 1.0;
currentAction.play();
isPlaying = true;
notifyParent({ type: 'ANIMATION_CHANGED', index: index, duration: currentAction.getClip().duration });
}
}
function togglePlayPause() {
if (!currentAction) return;
if (isPlaying) {
currentAction.paused = true;
isPlaying = false;
} else {
currentAction.paused = false;
isPlaying = true;
}
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying });
}
function resetAnimation() {
if (currentAction) {
currentAction.reset();
currentAction.play();
isPlaying = true;
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying: true });
}
}
function setTimeline(progress) {
if (currentAction) {
const duration = currentAction.getClip().duration;
currentAction.time = progress * duration;
}
}
function setSpeed(speed) {
if (currentAction) currentAction.timeScale = speed;
}
function setLoop(loop) {
if (currentAction) currentAction.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce);
}
function toggleSkeleton(visible) {
showSkeleton = visible;
if (skeletonHelper) skeletonHelper.visible = visible;
}
function toggleMesh(visible) {
showMesh = visible;
if (currentModel) {
currentModel.traverse((child) => {
if (child.isMesh) child.visible = visible;
});
}
}
function toggleXRay(xray) {
if (skeletonHelper) {
skeletonHelper.material.depthTest = !xray;
skeletonHelper.material.depthWrite = !xray;
}
}
function centerModel(model) {
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
model.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5;
camera.position.set(0, size.y / 2, cameraZ);
camera.lookAt(0, size.y / 2, 0);
controls.target.set(0, size.y / 2, 0);
controls.update();
modelBoundingBox = box;
}
function resetCamera() {
if (modelBoundingBox) {
const size = modelBoundingBox.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5;
camera.position.set(0, size.y / 2, cameraZ);
camera.lookAt(0, size.y / 2, 0);
controls.target.set(0, size.y / 2, 0);
controls.update();
}
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function notifyParent(data) {
window.parent.postMessage(data, '*');
}
let lastTimeUpdate = 0;
function sendTimeUpdate() {
if (!currentAction) return;
const currentTime = currentAction.time;
const duration = currentAction.getClip().duration;
const progress = (currentTime / duration) * 100;
const fps = 30;
const currentFrame = Math.floor(currentTime * fps);
const totalFrames = Math.floor(duration * fps);
const now = Date.now();
if (now - lastTimeUpdate > 50) {
notifyParent({
type: 'TIME_UPDATE',
time: currentTime,
duration: duration,
progress: progress,
frame: currentFrame,
totalFrames: totalFrames
});
lastTimeUpdate = now;
}
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer && isPlaying && !currentAction?.paused) {
mixer.update(delta);
sendTimeUpdate();
}
controls.update();
renderer.render(scene, camera);
}
init();
console.log('[FBX Animation Viewer] Loaded successfully');
</script>
</body>
</html>