| <!DOCTYPE html> |
| <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'; |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| |
| |
| 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; |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| const clock = new THREE.Clock(); |
| function animate() { |
| requestAnimationFrame(animate); |
| const dt = Math.min(clock.getDelta(), 0.1); |
| |
| if (currentVrm) { |
| if (mixer) mixer.update(dt); |
| |
| |
| 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); |
| } |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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(); |
| |
| |
| 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'); |
| } |
| } |
| }); |
| |
| |
| 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; |
| |
| 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> |
|
|