HunyuanWorld-viewer / index.html
multimodalart's picture
Update index.html
0c3b932 verified
<!DOCTYPE html>
<html>
<head>
<title>Hunyuan World Navigator</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #1a1a1a;
color: white;
text-align: center;
}
#header {
padding: 20px;
background: #282828;
border-bottom: 1px solid #444;
}
#header h1 {
margin: 0 0 10px 0;
font-size: 2em;
}
#header p {
margin: 0 0 20px 0;
color: #ccc;
}
#header a {
color: #61dafb;
text-decoration: none;
}
#header a:hover {
text-decoration: underline;
}
#examples-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 20px;
gap: 20px;
background: #222;
}
.example-card {
background: #333;
border-radius: 8px;
overflow: hidden;
width: 200px;
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.example-card:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
.example-card img {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.example-card p {
margin: 0;
padding: 15px;
font-weight: bold;
}
#viewer-container {
position: relative;
width: 100%;
height: 65vh;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#upload-container {
margin-top: 15px;
}
#file-input {
display: none;
}
.upload-btn {
background: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.upload-btn:hover {
background: #45a049;
}
#loading {
display: none; /* Hidden by default */
padding: 15px;
}
#loading-text {
color: #aaa;
font-size: 18px;
margin-bottom: 10px;
}
#progress-container {
width: 80%;
max-width: 400px;
margin: 0 auto;
background-color: #555;
border-radius: 5px;
overflow: hidden;
display: none; /* Hidden by default, shown for web loads */
}
#progress-bar {
width: 0%;
height: 20px;
background-color: #4CAF50;
/* Smoother transition for the bar */
transition: width 0.2s ease-out;
}
#controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
}
.control-btn {
padding: 8px 12px;
margin-right: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
background: rgba(85, 85, 85, 0.8);
color: white;
}
.control-btn:hover {
background: rgba(102, 102, 102, 0.9);
}
#instructions {
position: absolute;
bottom: 10px;
left: 10px;
color: white;
background: rgba(0,0,0,0.5);
padding: 10px;
border-radius: 5px;
font-size: 14px;
z-index: 10;
}
</style>
</head>
<body>
<div id="header">
<h1>Hunyuan World Navigator</h1>
<p>
<a href="https://huggingface.co/tencent/HunyuanWorld-1" target="_blank" rel="noopener noreferrer">HunyuanWorld-1 on Hugging Face</a> |
<a href="https://github.com/camenduru/HunyuanWorld-1.0-jupyter" target="_blank" rel="noopener noreferrer">Generate your own on Google Colab</a>
</p>
<p>Click an example below or upload your own files to begin.</p>
<div id="upload-container">
<label for="file-input" class="upload-btn">Select Custom PLY/DRC Files</label>
<input id="file-input" type="file" accept=".ply,.drc" multiple>
</div>
</div>
<div id="examples-container"></div>
<div id="loading">
<div id="loading-text">Loading...</div>
<div id="progress-container">
<div id="progress-bar"></div>
</div>
</div>
<div id="viewer-container">
<div id="controls">
<button id="rotate-toggle" class="control-btn">Pause Rotation</button>
<button id="reset-view" class="control-btn">Reset View</button>
</div>
<div id="instructions">
Controls: WASD to move, Mouse drag to look around
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/PLYLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/loaders/DRACOLoader.js"></script>
<script>
// --- CACHE & DATA ---
const modelCache = new Map();
const baseURL = 'https://huggingface.co/datasets/multimodalart/HunyuanWorld-panoramas/resolve/main/';
const examplesData = [
{ name: 'Cyberpunk City', previewImage: 'cyberpunk/cyberpunk.webp', files: ['cyberpunk/mesh_layer0.ply', 'cyberpunk/mesh_layer1.ply'] },
{ name: 'European Town', previewImage: 'european/european.webp', files: ['european/mesh_layer0.ply', 'european/mesh_layer1.ply'] },
{ name: 'Restaurant', previewImage: 'italian/italian.webp', files: ['italian/mesh_layer0.ply', 'italian/mesh_layer1.ply', 'italian/mesh_layer2.ply', 'italian/mesh_layer3.ply'] },
{ name: 'Mountain', previewImage: 'mountain/mountain.webp', files: ['mountain/mesh_layer0.ply', 'mountain/mesh_layer1.ply'] },
{ name: 'Windows XP', previewImage: 'wxp/wxp.webp', files: ['wxp/mesh_layer0.ply', 'wxp/mesh_layer1.ply', 'wxp/mesh_layer2.ply'] },
{ name: 'Zelda', previewImage: 'zld/zld.webp', files: ['zld/mesh_layer0.ply', 'zld/mesh_layer1.ply'] }
];
const examples = examplesData.map(ex => ({
name: ex.name,
previewImage: baseURL + ex.previewImage,
files: ex.files.map(file => baseURL + file)
}));
// --- UI & DOM ELEMENTS ---
const examplesContainer = document.getElementById('examples-container');
const loadingDiv = document.getElementById('loading');
const loadingText = document.getElementById('loading-text');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
// --- UI SETUP ---
examples.forEach(example => {
const card = document.createElement('div');
card.className = 'example-card';
card.innerHTML = `<img src="${example.previewImage}" alt="${example.name}"><p>${example.name}</p>`;
card.addEventListener('click', () => loadExample(example));
examplesContainer.appendChild(card);
});
// --- THREE.JS INITIALIZATION ---
const viewerContainer = document.getElementById('viewer-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
const camera = new THREE.PerspectiveCamera(75, viewerContainer.clientWidth / viewerContainer.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(viewerContainer.clientWidth, viewerContainer.clientHeight);
viewerContainer.appendChild(renderer.domElement);
// --- LOADERS ---
const plyLoader = new THREE.PLYLoader();
const dracoLoader = new THREE.DRACOLoader();
dracoLoader.setDecoderPath('https://cdn.jsdelivr.net/npm/[email protected]/examples/js/libs/draco/');
// --- MOVEMENT & CONTROL VARIABLES ---
const moveSpeed = 0.01;
const maxDistance = 0.3;
const keys = { w: false, a: false, s: false, d: false };
let isMouseDown = false;
let previousMousePosition = { x: 0, y: 0 };
let isRotating = false;
let animationId = null;
// --- SCENE HELPER FUNCTIONS ---
function clearScene() {
scene.children.slice().forEach(child => {
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
scene.remove(child);
}
});
}
function onLoadingComplete() {
loadingDiv.style.display = 'none';
positionCamera();
isRotating = true;
document.getElementById('rotate-toggle').textContent = 'Pause Rotation';
if (!animationId) {
animate();
}
}
function positionCamera() {
scene.rotation.y = 0;
camera.position.set(0, 0, 0);
camera.quaternion.set(0, 0, 0, 1);
camera.lookAt(0, 0, -10);
}
// --- LOADING LOGIC ---
async function fetchWithProgress(url, onProgress) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status} for ${url}`);
if (!response.body) throw new Error('Response body is null');
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
onProgress(value.length);
}
let totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const buffer = new Uint8Array(totalLength);
let offset = 0;
chunks.forEach(chunk => {
buffer.set(chunk, offset);
offset += chunk.length;
});
return buffer.buffer;
}
async function loadExample(example) {
clearScene();
loadingDiv.style.display = 'block';
if (modelCache.has(example.name)) {
loadingText.textContent = 'Loading from cache...';
progressContainer.style.display = 'none';
const cachedMeshes = modelCache.get(example.name);
cachedMeshes.forEach(mesh => scene.add(mesh.clone()));
setTimeout(onLoadingComplete, 50);
return;
}
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
loadingText.textContent = 'Calculating size...';
let loadedSize = 0;
let totalSize = 0;
let progressAnimationId = null;
try {
const headPromises = example.files.map(url => fetch(url, { method: 'HEAD' }));
const responses = await Promise.all(headPromises);
totalSize = responses.reduce((acc, res) => acc + Number(res.headers.get('Content-Length') || 0), 0);
const updateProgressUI = () => {
const percent = totalSize > 0 ? (loadedSize / totalSize) * 100 : 0;
progressBar.style.width = `${percent}%`;
loadingText.textContent = `Downloading... ${Math.round(percent)}%`;
if (loadedSize < totalSize) {
progressAnimationId = requestAnimationFrame(updateProgressUI);
}
};
progressAnimationId = requestAnimationFrame(updateProgressUI);
const onProgress = (chunkSize) => { loadedSize += chunkSize; };
const contentPromises = example.files.map(url => fetchWithProgress(url, onProgress));
const buffers = await Promise.all(contentPromises);
cancelAnimationFrame(progressAnimationId);
loadingText.textContent = `Processing files...`;
const newMeshes = [];
buffers.forEach(buffer => {
const geometry = plyLoader.parse(buffer);
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, vertexColors: true });
const mesh = new THREE.Mesh(geometry, material);
mesh.rotateX(-Math.PI / 2);
mesh.rotateZ(-Math.PI / 2);
scene.add(mesh);
newMeshes.push(mesh);
});
modelCache.set(example.name, newMeshes);
onLoadingComplete();
} catch (error) {
console.error('Error loading example:', error);
alert('Failed to load example files. Check console for details.');
if (progressAnimationId) cancelAnimationFrame(progressAnimationId);
loadingDiv.style.display = 'none';
}
}
document.getElementById('file-input').addEventListener('change', function(e) {
const files = e.target.files;
if (files.length === 0) return;
loadingDiv.style.display = 'block';
loadingText.textContent = 'Loading...';
progressContainer.style.display = 'none';
clearScene();
let loadedCount = 0;
const totalFiles = files.length;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = function(event) {
try {
const buffer = event.target.result;
let geometry;
if (file.name.endsWith('.ply')) {
geometry = plyLoader.parse(buffer);
} else if (file.name.endsWith('.drc')) {
dracoLoader.parse(buffer, (decodedGeometry) => {
geometry = decodedGeometry;
if (!geometry.attributes.normal) geometry.computeVertexNormals();
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, vertexColors: true });
const mesh = new THREE.Mesh(geometry, material);
mesh.rotateX(-Math.PI / 2);
mesh.rotateZ(-Math.PI / 2);
scene.add(mesh);
loadedCount++;
if (loadedCount === totalFiles) onLoadingComplete();
});
return;
}
if (geometry) {
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, vertexColors: true });
const mesh = new THREE.Mesh(geometry, material);
mesh.rotateX(-Math.PI / 2);
mesh.rotateZ(-Math.PI / 2);
scene.add(mesh);
}
} catch (error) {
console.error('Error loading file:', file.name, error);
}
loadedCount++;
if (loadedCount === totalFiles) onLoadingComplete();
};
reader.readAsArrayBuffer(file);
});
});
// --- CONTROLS & EVENT LISTENERS ---
document.getElementById('rotate-toggle').addEventListener('click', function() {
isRotating = !isRotating;
this.textContent = isRotating ? 'Pause Rotation' : 'Start Rotation';
});
document.getElementById('reset-view').addEventListener('click', () => {
positionCamera();
if (!animationId) animate();
});
document.addEventListener('keydown', (event) => {
if (event.key.toLowerCase() in keys) {
keys[event.key.toLowerCase()] = true;
// BUG FIX: Ensure the animation loop is running when a key is pressed.
if (!animationId) {
animate();
}
}
});
document.addEventListener('keyup', (event) => {
if (event.key.toLowerCase() in keys) keys[event.key.toLowerCase()] = false;
});
renderer.domElement.addEventListener('mousedown', (event) => {
isMouseDown = true;
previousMousePosition = { x: event.clientX, y: event.clientY };
event.preventDefault();
});
document.addEventListener('mouseup', () => { isMouseDown = false; });
document.addEventListener('mousemove', (event) => {
if (isMouseDown) {
const deltaMove = { x: event.clientX - previousMousePosition.x, y: event.clientY - previousMousePosition.y };
const up = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3(1, 0, 0);
camera.rotateOnWorldAxis(up, -deltaMove.x * 0.002);
camera.rotateOnAxis(right, -deltaMove.y * 0.002);
previousMousePosition = { x: event.clientX, y: event.clientY };
}
});
renderer.domElement.addEventListener('contextmenu', (event) => event.preventDefault());
window.addEventListener('resize', function() {
camera.aspect = viewerContainer.clientWidth / viewerContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(viewerContainer.clientWidth, viewerContainer.clientHeight);
});
// --- ANIMATION LOOP ---
function animate() {
// Process movement
if (keys.w || keys.a || keys.s || keys.d) {
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
forward.y = 0; right.y = 0;
forward.normalize(); right.normalize();
const movement = new THREE.Vector3();
if (keys.w) movement.add(forward);
if (keys.s) movement.sub(forward);
if (keys.a) movement.sub(right);
if (keys.d) movement.add(right);
if (movement.length() > 0) {
movement.normalize().multiplyScalar(moveSpeed);
camera.position.add(movement);
}
}
if (camera.position.length() > maxDistance) camera.position.setLength(maxDistance);
// Process rotation
if (isRotating && scene.children.some(c => c instanceof THREE.Mesh)) scene.rotation.y += 0.0005;
// Render the scene
renderer.render(scene, camera);
// BUG FIX: Request the next frame at the *end* of the function for robustness.
animationId = requestAnimationFrame(animate);
}
// Start the initial animation loop
animate();
</script>
</body>
</html>