Spaces:
Sleeping
Sleeping
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>3D VRM Viewer Iframe</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background-color: #ffe082; | |
| overflow: hidden; | |
| font-family: sans-serif; | |
| } | |
| #c { | |
| width: 100vw; | |
| height: 100vh; | |
| display: block; | |
| } | |
| #speakDot { | |
| position: fixed; | |
| bottom: 100px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| display: flex; | |
| gap: 6px; | |
| z-index: 100; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| #speakDot.on { | |
| opacity: 1; | |
| } | |
| .sdot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: #6cf; | |
| animation: sdotBounce 0.6s infinite alternate; | |
| } | |
| .sdot:nth-child(2) { animation-delay: 0.15s; } | |
| .sdot:nth-child(3) { animation-delay: 0.3s; } | |
| @keyframes sdotBounce { | |
| from { transform: scaleY(1); } | |
| to { transform: scaleY(2.2); } | |
| } | |
| #load { | |
| position: fixed; | |
| top: 15px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(15, 18, 32, 0.92); | |
| padding: 8px 20px; | |
| border-radius: 14px; | |
| font-size: 12px; | |
| color: #6cf; | |
| z-index: 101; | |
| backdrop-filter: blur(8px); | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| pointer-events: none; | |
| white-space: nowrap; | |
| border: 1px solid rgba(108, 204, 255, 0.2); | |
| } | |
| #load.on { | |
| opacity: 1; | |
| } | |
| #micBtn { | |
| position: fixed; | |
| left: 15px; | |
| bottom: 15px; | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 50%; | |
| border: none; | |
| font-size: 18px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(15, 18, 32, 0.92); | |
| color: #6cf; | |
| border: 1px solid rgba(108, 204, 255, 0.3); | |
| backdrop-filter: blur(12px); | |
| z-index: 102; | |
| transition: all 0.12s; | |
| } | |
| </style> | |
| <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/", | |
| "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3.2.0/lib/three-vrm.module.min.js", | |
| "@pixiv/three-vrm-animation": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm-animation@3.2.0/lib/three-vrm-animation.module.min.js" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <div id="c"></div> | |
| <div id="speakDot"> | |
| <div class="sdot"></div> | |
| <div class="sdot"></div> | |
| <div class="sdot"></div> | |
| </div> | |
| <div id="load">thinking…</div> | |
| <button id="micBtn">🎙️</button> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; | |
| import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // Parse query params passed down by Gradio | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const vrmPath = urlParams.get('vrm_path') || ''; | |
| const animationDir = urlParams.get('animation_dir') || ''; | |
| let availableVrmas = []; | |
| try { | |
| const animationsParam = urlParams.get('animations'); | |
| if (animationsParam) { | |
| availableVrmas = JSON.parse(decodeURIComponent(animationsParam)); | |
| } | |
| } catch(e) { | |
| console.error("Error parsing animations parameter:", e); | |
| } | |
| // Setup Scene, Camera and Renderer | |
| const container = document.getElementById('c'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xffe082); | |
| const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); | |
| camera.position.set(0, 1.4, 1.8); | |
| let renderer; | |
| try { | |
| renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| container.appendChild(renderer.domElement); | |
| } catch (err) { | |
| console.error("WebGL initialization failed:", err); | |
| const warn = document.createElement('div'); | |
| warn.style.position = 'absolute'; | |
| warn.style.top = '50%'; | |
| warn.style.left = '50%'; | |
| warn.style.transform = 'translate(-50%, -50%)'; | |
| warn.style.color = '#ff4b4b'; | |
| warn.style.fontSize = '18px'; | |
| warn.style.textAlign = 'center'; | |
| warn.innerHTML = "<strong>WebGL Error</strong><br>Your browser does not support WebGL."; | |
| document.body.appendChild(warn); | |
| } | |
| const orbit = new OrbitControls(camera, renderer.domElement); | |
| orbit.target.set(0, 1.3, 0); | |
| orbit.enableDamping = true; | |
| orbit.update(); | |
| // Lighting | |
| scene.add(new THREE.AmbientLight(0xfff4e0, 0.5)); | |
| const keyLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
| keyLight.position.set(2, 4, 3); | |
| scene.add(keyLight); | |
| let currentVrm = null; | |
| let mixer = null; | |
| let audioContext = null, analyser = null, dataArray = null; | |
| let isSpeaking = false; | |
| let blinkT = 0, nextBlink = 3, blinkV = 0; | |
| let activeExpressions = { 'neutral': 1.0 }; | |
| let currentVrmaAction = null; | |
| // GLTF Loader Setup | |
| const loader = new GLTFLoader(); | |
| try { | |
| loader.register(p => new VRMLoaderPlugin(p)); | |
| } catch (e) { | |
| console.error("VRMLoaderPlugin registration failed:", e); | |
| } | |
| try { | |
| loader.register(p => new VRMAnimationLoaderPlugin(p)); | |
| } catch (e) { | |
| console.error("VRMAnimationLoaderPlugin registration failed:", e); | |
| } | |
| // VRMA Playback with robust fallback URLs | |
| function playVrma(vrmaName) { | |
| if (!currentVrm || !mixer) return; | |
| let finalVrma = vrmaName; | |
| if (!availableVrmas.includes(vrmaName)) { | |
| if (availableVrmas.length > 0) { | |
| finalVrma = availableVrmas[0]; | |
| } | |
| } | |
| const urls = [ | |
| `/file=animation/${finalVrma}`, | |
| `/gradio_api/file=animation/${finalVrma}`, | |
| `/file=${animationDir}/${finalVrma}`, | |
| `/gradio_api/file=${animationDir}/${finalVrma}` | |
| ]; | |
| const loadVrmaWithFallback = (vrmaUrls) => { | |
| if (vrmaUrls.length === 0) { | |
| console.warn("All VRMA animation URLs failed to load:", finalVrma); | |
| return; | |
| } | |
| const url = vrmaUrls.shift(); | |
| loader.load(url, (gltf) => { | |
| const vrmAnimations = gltf.userData.vrmAnimations; | |
| if (vrmAnimations && vrmAnimations.length > 0) { | |
| if (currentVrmaAction) { | |
| currentVrmaAction.stop(); | |
| mixer.uncacheClip(currentVrmaAction.getClip()); | |
| } | |
| const clip = createVRMAnimationClip(vrmAnimations[0], currentVrm); | |
| currentVrmaAction = mixer.clipAction(clip); | |
| currentVrmaAction.play(); | |
| console.log("Successfully loaded VRMA from:", url); | |
| } else { | |
| loadVrmaWithFallback(vrmaUrls); | |
| } | |
| }, undefined, (err) => { | |
| loadVrmaWithFallback(vrmaUrls); | |
| }); | |
| }; | |
| loadVrmaWithFallback(urls); | |
| } | |
| // Load VRM Model with safe-guards and fallbacks | |
| const loadVrmWithFallback = (urls) => { | |
| if (urls.length === 0) { | |
| console.error("All VRM model URLs failed to load."); | |
| return; | |
| } | |
| const url = urls.shift(); | |
| console.log("Attempting to load VRM model from:", url); | |
| loader.load(url, (gltf) => { | |
| const vrm = gltf.userData.vrm; | |
| if (!vrm) { | |
| console.error("GLTF loaded but userData.vrm is undefined at:", url); | |
| loadVrmWithFallback(urls); | |
| return; | |
| } | |
| if (typeof VRMUtils !== 'undefined' && VRMUtils.rotateVRM0) { | |
| VRMUtils.rotateVRM0(vrm); | |
| } else { | |
| if (vrm.meta && vrm.meta.metaVersion === '0') { | |
| vrm.scene.rotation.y = Math.PI; | |
| } | |
| } | |
| scene.add(vrm.scene); | |
| currentVrm = vrm; | |
| mixer = new THREE.AnimationMixer(vrm.scene); | |
| if (vrm.expressionManager) { | |
| vrm.expressionManager.setValue('neutral', 1.0); | |
| } else if (vrm.blendShapeProxy) { | |
| vrm.blendShapeProxy.setValue('neutral', 1.0); | |
| } | |
| let head = null; | |
| if (vrm.humanoid) { | |
| if (typeof vrm.humanoid.getRawBoneNode === 'function') { | |
| head = vrm.humanoid.getRawBoneNode('head'); | |
| } else if (typeof vrm.humanoid.getBoneNode === 'function') { | |
| head = vrm.humanoid.getBoneNode('head'); | |
| } | |
| } | |
| if (head) { | |
| const worldPos = new THREE.Vector3(); | |
| head.getWorldPosition(worldPos); | |
| orbit.target.copy(worldPos); | |
| orbit.update(); | |
| } | |
| // Play starting animation | |
| if (availableVrmas.length > 0) { | |
| playVrma(availableVrmas[0]); | |
| } | |
| console.log("Successfully loaded VRM model from:", url); | |
| }, undefined, (e) => { | |
| console.warn("Failed to load VRM from:", url, e); | |
| loadVrmWithFallback(urls); | |
| }); | |
| }; | |
| const modelUrls = [ | |
| '/file=model/Ani.vrm', | |
| '/gradio_api/file=model/Ani.vrm', | |
| `/file=${vrmPath}`, | |
| `/gradio_api/file=${vrmPath}` | |
| ]; | |
| loadVrmWithFallback(modelUrls); | |
| function initAudioSync(audioElement) { | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 256; | |
| dataArray = new Uint8Array(analyser.frequencyBinCount); | |
| } | |
| if (audioContext.state === 'suspended') audioContext.resume(); | |
| try { | |
| const source = audioContext.createMediaElementSource(audioElement); | |
| source.connect(analyser); | |
| analyser.connect(audioContext.destination); | |
| } catch(e) { | |
| console.warn("Audio sync connection ignored (possibly already connected):", e); | |
| } | |
| } | |
| // Animation Loop | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 0.1); | |
| if (currentVrm) { | |
| if (mixer) mixer.update(dt); | |
| // Set active facial expressions (happy, sad, etc.) | |
| if (activeExpressions) { | |
| for (const [key, val] of Object.entries(activeExpressions)) { | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue(key, val); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue(key, val); | |
| } | |
| } | |
| } | |
| // Speaking / Lip Sync | |
| if (isSpeaking && analyser) { | |
| analyser.getByteFrequencyData(dataArray); | |
| let sum = 0; | |
| for (let i = 0; i < 32; i++) sum += dataArray[i]; | |
| const level = sum / 32 / 255; | |
| const open = Math.min(level * 4.5, 1.2); | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue('aa', open); | |
| currentVrm.expressionManager.setValue('oh', open * 0.4); | |
| currentVrm.expressionManager.setValue('ee', open * 0.2); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue('aa', open); | |
| currentVrm.blendShapeProxy.setValue('oh', open * 0.4); | |
| currentVrm.blendShapeProxy.setValue('ee', open * 0.2); | |
| } | |
| } else { | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue('aa', 0); | |
| currentVrm.expressionManager.setValue('oh', 0); | |
| currentVrm.expressionManager.setValue('ee', 0); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue('aa', 0); | |
| currentVrm.blendShapeProxy.setValue('oh', 0); | |
| currentVrm.blendShapeProxy.setValue('ee', 0); | |
| } | |
| } | |
| // Eye Blinking | |
| const isEmotionActive = activeExpressions && (activeExpressions.happy > 0.5 || activeExpressions.surprised > 0.5 || activeExpressions.relaxed > 0.5); | |
| if (!isSpeaking && !isEmotionActive) { | |
| blinkT += dt; | |
| if (blinkT > nextBlink) { | |
| if (blinkV === 0) blinkV = 1; | |
| if (blinkV === 1) { | |
| let v = 0; | |
| if (currentVrm.expressionManager) { | |
| v = currentVrm.expressionManager.getValue('blink') || 0; | |
| } else if (currentVrm.blendShapeProxy) { | |
| v = currentVrm.blendShapeProxy.getValue('blink') || 0; | |
| } | |
| v += dt * 12; | |
| if (v >= 1) { v = 1; blinkV = -1; } | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue('blink', v); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue('blink', v); | |
| } | |
| } else { | |
| let v = 1; | |
| if (currentVrm.expressionManager) { | |
| v = currentVrm.expressionManager.getValue('blink') || 1; | |
| } else if (currentVrm.blendShapeProxy) { | |
| v = currentVrm.blendShapeProxy.getValue('blink') || 1; | |
| } | |
| v -= dt * 12; | |
| if (v <= 0) { v = 0; blinkV = 0; blinkT = 0; nextBlink = 2 + Math.random() * 5; } | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue('blink', v); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue('blink', v); | |
| } | |
| } | |
| } | |
| } else { | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue('blink', 0); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue('blink', 0); | |
| } | |
| } | |
| currentVrm.update(dt); | |
| } | |
| orbit.update(); | |
| if (renderer) renderer.render(scene, camera); | |
| } | |
| animate(); | |
| // Listen for parent messages (from Gradio host) | |
| window.addEventListener('message', (e) => { | |
| if (e.data.type === 'vrm_update') { | |
| const { audio_url, data } = e.data; | |
| const audio = new Audio(audio_url); | |
| initAudioSync(audio); | |
| isSpeaking = true; | |
| document.getElementById('speakDot').classList.add('on'); | |
| audio.play().catch(err => console.log("Audio play deferred or failed:", err)); | |
| audio.onended = () => { | |
| isSpeaking = false; | |
| document.getElementById('speakDot').classList.remove('on'); | |
| }; | |
| if (data.vrma) { | |
| playVrma(data.vrma); | |
| } | |
| if (currentVrm && data.expressions) { | |
| activeExpressions = {}; | |
| ['happy','sad','angry','surprised','relaxed','neutral'].forEach(ex => { | |
| if (currentVrm.expressionManager) { | |
| currentVrm.expressionManager.setValue(ex, 0); | |
| } else if (currentVrm.blendShapeProxy) { | |
| currentVrm.blendShapeProxy.setValue(ex, 0); | |
| } | |
| }); | |
| for (const [key, val] of Object.entries(data.expressions)) { | |
| activeExpressions[key] = val; | |
| } | |
| if (Object.keys(activeExpressions).length === 0) { | |
| activeExpressions['neutral'] = 1.0; | |
| } | |
| } | |
| } else if (e.data.type === 'set_loading') { | |
| const loadOverlay = document.getElementById('load'); | |
| if (e.data.val) { | |
| loadOverlay.classList.add('on'); | |
| } else { | |
| loadOverlay.classList.remove('on'); | |
| } | |
| } | |
| }); | |
| // Speech Recognition / Voice Mic Handler | |
| const micBtn = document.getElementById('micBtn'); | |
| if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { | |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| const recognition = new SpeechRecognition(); | |
| recognition.continuous = false; | |
| recognition.interimResults = false; | |
| recognition.lang = 'en-US'; | |
| recognition.onstart = () => { | |
| micBtn.style.background = 'rgba(255, 75, 75, 0.9)'; | |
| micBtn.style.color = '#fff'; | |
| const loadOverlay = document.getElementById('load'); | |
| loadOverlay.textContent = "Listening..."; | |
| loadOverlay.classList.add('on'); | |
| }; | |
| recognition.onend = () => { | |
| micBtn.style.background = 'rgba(15, 18, 32, 0.92)'; | |
| micBtn.style.color = '#6cf'; | |
| const loadOverlay = document.getElementById('load'); | |
| loadOverlay.classList.remove('on'); | |
| loadOverlay.textContent = "thinking…"; | |
| }; | |
| recognition.onresult = (event) => { | |
| const transcript = event.results[0][0].transcript; | |
| // Dispatch text back to Gradio host parent window | |
| window.parent.postMessage({ | |
| type: 'mic_transcript', | |
| text: transcript | |
| }, '*'); | |
| }; | |
| micBtn.addEventListener('click', () => { | |
| try { | |
| recognition.start(); | |
| } catch(e) { | |
| recognition.stop(); | |
| } | |
| }); | |
| } else { | |
| micBtn.style.display = 'none'; | |
| } | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| if (renderer) renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |