OpenCompanion / viewer.html
OrbitMC's picture
Upload viewer.html
018703e verified
Raw
History Blame Contribute Delete
21.4 kB
<!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';
// 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>