import React, { useState, useEffect, useRef, useMemo } from 'react'; import * as THREE from 'three'; import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { Upload, Trash2, Download, Play, Box, Loader2, CheckCircle2, AlertCircle, Film, Info, Maximize, Pause, ChevronUp, ChevronDown } from 'lucide-react'; const APP_TITLE = "Mixamo Animation Merger"; const App = () => { const mountRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [activeAnimation, setActiveAnimation] = useState(null); const [masterModel, setMasterModel] = useState(null); const [status, setStatus] = useState({ type: 'info', message: 'Ready to load Mixamo FBX files.' }); // Timeline state const [timelineTime, setTimelineTime] = useState(0); const [isPaused, setIsPaused] = useState(false); const isScrubbing = useRef(false); // Three.js Scene State const sceneRef = useRef({ scene: null, camera: null, renderer: null, controls: null, mixer: null, clock: new THREE.Clock(), currentModel: null, currentAction: null }); useEffect(() => { if (!mountRef.current) return; const width = mountRef.current.clientWidth; const height = mountRef.current.clientHeight; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0f172a); // Near plane reduced to 0.001 to allow extreme close-ups const camera = new THREE.PerspectiveCamera(45, width / height, 0.001, 2000); camera.position.set(2, 2, 5); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); renderer.outputColorSpace = THREE.SRGBColorSpace; mountRef.current.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; controls.minDistance = 0.01; controls.maxDistance = 100; const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 10, 7.5); scene.add(dirLight); const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4); hemiLight.position.set(0, 20, 0); scene.add(hemiLight); const grid = new THREE.GridHelper(20, 100, 0x334155, 0x1e293b); scene.add(grid); sceneRef.current = { ...sceneRef.current, scene, camera, renderer, controls }; const animate = () => { requestAnimationFrame(animate); const delta = sceneRef.current.clock.getDelta(); if (sceneRef.current.mixer && !isScrubbing.current) { sceneRef.current.mixer.update(delta); if (sceneRef.current.currentAction) { setTimelineTime(sceneRef.current.currentAction.time); } } if (sceneRef.current.controls) sceneRef.current.controls.update(); if (sceneRef.current.renderer) sceneRef.current.renderer.render(scene, camera); }; animate(); const handleResize = () => { if (!mountRef.current) return; const w = mountRef.current.clientWidth; const h = mountRef.current.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (renderer.domElement && renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } renderer.dispose(); }; }, []); const fitCameraToObject = (object) => { const box = new THREE.Box3().setFromObject(object); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const fov = sceneRef.current.camera.fov * (Math.PI / 180); let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); cameraZ *= 1.5; // Padding const { camera, controls } = sceneRef.current; // Position camera camera.position.set(center.x, center.y + size.y / 4, center.z + cameraZ); controls.target.copy(center); // Scale-aware constraints controls.minDistance = maxDim * 0.01; // Allow zooming into 1% of model size controls.maxDistance = maxDim * 15; camera.updateProjectionMatrix(); controls.update(); }; const setView = (type) => { if (!masterModel?.object) return; const box = new THREE.Box3().setFromObject(masterModel.object); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const fov = sceneRef.current.camera.fov * (Math.PI / 180); let dist = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5; const { camera, controls } = sceneRef.current; controls.target.copy(center); switch(type) { case 'front': camera.position.set(center.x, center.y + size.y/4, center.z + dist); break; case 'top': camera.position.set(center.x, center.y + dist, center.z); break; case 'left': camera.position.set(center.x - dist, center.y + size.y/4, center.z); break; case 'right': camera.position.set(center.x + dist, center.y + size.y/4, center.z); break; } controls.update(); }; const handleFileUpload = async (e) => { const uploadedFiles = Array.from(e.target.files).filter(f => f.name.toLowerCase().endsWith('.fbx')); if (uploadedFiles.length === 0) return; setIsProcessing(true); setStatus({ type: 'info', message: 'Processing FBX files...' }); const loader = new FBXLoader(); try { let startIdx = 0; let localMaster = masterModel; if (!localMaster) { const file = uploadedFiles[0]; const url = URL.createObjectURL(file); const object = await loader.loadAsync(url); object.scale.setScalar(0.01); if (object.animations?.length > 0) { object.animations[0].name = file.name.replace('.fbx', ''); } sceneRef.current.scene.add(object); sceneRef.current.currentModel = object; sceneRef.current.mixer = new THREE.AnimationMixer(object); localMaster = { name: file.name, object, animations: object.animations || [] }; setMasterModel(localMaster); startIdx = 1; // Auto-fit immediately on load fitCameraToObject(object); } const newClips = []; for (let i = startIdx; i < uploadedFiles.length; i++) { const file = uploadedFiles[i]; const url = URL.createObjectURL(file); const tempObject = await loader.loadAsync(url); if (tempObject.animations?.length > 0) { const clip = tempObject.animations[0]; clip.name = file.name.replace('.fbx', ''); clip.tracks.forEach(track => { const parts = track.name.split('.'); let boneName = parts[0]; const property = parts[1]; if (boneName.includes(':')) boneName = boneName.split(':').pop(); track.name = `${boneName}.${property}`; }); newClips.push(clip); } tempObject.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); if (Array.isArray(child.material)) child.material.forEach(m => m.dispose()); else if (child.material) child.material.dispose(); } }); } if (newClips.length > 0) { const updatedAnims = [...localMaster.animations, ...newClips]; localMaster.animations = updatedAnims; localMaster.object.animations = updatedAnims; setMasterModel({ ...localMaster }); setStatus({ type: 'success', message: `Added ${newClips.length} tracks.` }); } } catch (error) { console.error(error); setStatus({ type: 'error', message: 'Error processing FBX files.' }); } finally { setIsProcessing(false); e.target.value = null; } }; const playAnimation = (clipName) => { if (!sceneRef.current.mixer || !masterModel) return; if (activeAnimation === clipName) { if (sceneRef.current.currentAction) sceneRef.current.currentAction.fadeOut(0.2); masterModel.object.traverse(c => { if (c.isSkinnedMesh) c.skeleton.pose(); }); sceneRef.current.currentAction = null; setActiveAnimation(null); setIsPaused(false); return; } const clip = masterModel.animations.find(a => a.name === clipName); if (!clip) return; const action = sceneRef.current.mixer.clipAction(clip); if (sceneRef.current.currentAction) { action.reset().setLoop(THREE.LoopRepeat).play().crossFadeFrom(sceneRef.current.currentAction, 0.2, true); } else { action.reset().setLoop(THREE.LoopRepeat).play(); } sceneRef.current.currentAction = action; setActiveAnimation(clipName); setIsPaused(false); }; const togglePause = () => { if (!sceneRef.current.currentAction) return; const paused = !isPaused; setIsPaused(paused); sceneRef.current.currentAction.paused = paused; }; const handleScrub = (e) => { const val = parseFloat(e.target.value); setTimelineTime(val); if (sceneRef.current.currentAction) { sceneRef.current.currentAction.time = val; if (isScrubbing.current) { sceneRef.current.mixer.update(0); } } }; const removeClip = (index) => { const anims = [...masterModel.animations]; const removedName = anims[index].name; anims.splice(index, 1); if (activeAnimation === removedName) { if (sceneRef.current.currentAction) sceneRef.current.currentAction.stop(); sceneRef.current.currentAction = null; setActiveAnimation(null); } const updated = { ...masterModel, animations: anims }; updated.object.animations = anims; setMasterModel(updated); }; const moveClip = (index, dir) => { if ((index === 0 && dir === -1) || (index === masterModel.animations.length - 1 && dir === 1)) return; const anims = [...masterModel.animations]; const item = anims.splice(index, 1)[0]; anims.splice(index + dir, 0, item); const updated = { ...masterModel, animations: anims }; updated.object.animations = anims; setMasterModel(updated); }; const exportGLB = () => { if (!masterModel) return; setIsProcessing(true); const exporter = new GLTFExporter(); exporter.parse(masterModel.object, (result) => { const blob = new Blob([result], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = "consolidated.glb"; link.click(); setIsProcessing(false); setStatus({ type: 'success', message: 'GLB exported!' }); }, (e) => { console.error(e); setIsProcessing(false); }, { binary: true, animations: masterModel.animations, trs: true, onlyVisible: true }); }; const currentDuration = sceneRef.current.currentAction?.getClip().duration || 0; return (
Mixamo Pipeline Tool