Spaces:
Running
Running
<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; /* Adjusted height for the viewer */ | |
} | |
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; | |
margin-top: 10px; | |
color: #aaa; | |
font-size: 18px; | |
} | |
#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"> | |
<!-- Examples will be dynamically inserted here --> | |
</div> | |
<div id="loading">Loading...</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> | |
<!-- Canvas will be appended here by Three.js --> | |
</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> | |
// --- DATA FOR EXAMPLES --- | |
const baseURL = 'https://huggingface.co/datasets/multimodalart/HunyuanWorld-panoramas/resolve/main/'; | |
const examplesData = [ | |
{ name: 'Cyberpunk', 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: 'Italian Village', 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: 'WXP', previewImage: 'wxp/wxp.webp', files: ['wxp/mesh_layer0.ply', 'wxp/mesh_layer1.ply', 'wxp/mesh_layer2.ply'] }, | |
{ name: 'ZLD', previewImage: 'zld/zld.webp', files: ['zld/mesh_layer0.ply', 'zld/mesh_layer1.ply'] } | |
]; | |
// Prepend the base URL to all example file paths | |
const examples = examplesData.map(ex => ({ | |
name: ex.name, | |
previewImage: baseURL + ex.previewImage, | |
files: ex.files.map(file => baseURL + file) | |
})); | |
// --- UI SETUP --- | |
const examplesContainer = document.getElementById('examples-container'); | |
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; // Start paused | |
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() { | |
document.getElementById('loading').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); // Reset camera rotation | |
camera.lookAt(0, 0, -10); | |
} | |
// --- LOADING LOGIC --- | |
// Load pre-defined examples from server | |
function loadExample(example) { | |
document.getElementById('loading').style.display = 'block'; | |
clearScene(); | |
const promises = example.files.map(url => | |
fetch(url).then(res => { | |
if (!res.ok) throw new Error(`Failed to fetch ${url}`); | |
return res.arrayBuffer(); | |
}) | |
); | |
Promise.all(promises) | |
.then(buffers => { | |
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); | |
}); | |
onLoadingComplete(); | |
}) | |
.catch(error => { | |
console.error('Error loading example:', error); | |
alert('Failed to load example files. Check console for details.'); | |
document.getElementById('loading').style.display = 'none'; | |
}); | |
} | |
// Load custom files from user's computer | |
document.getElementById('file-input').addEventListener('change', function(e) { | |
const files = e.target.files; | |
if (files.length === 0) return; | |
document.getElementById('loading').style.display = 'block'; | |
clearScene(); | |
let loadedCount = 0; | |
const totalFiles = files.length; | |
Array.from(files).forEach(file => { | |
const reader = new FileReader(); | |
reader.onload = function(event) { | |
try { | |
let geometry; | |
if (file.name.endsWith('.ply')) { | |
geometry = plyLoader.parse(event.target.result); | |
} else if (file.name.endsWith('.drc')) { | |
dracoLoader.setDecoderConfig({ type: 'js' }); // Ensure decoder is set | |
dracoLoader.parse(event.target.result, (decodedGeometry) => { | |
geometry = decodedGeometry; | |
// Draco decoding is async, handle mesh creation inside callback | |
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; // Exit onload as Draco is async | |
} | |
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', function() { | |
positionCamera(); | |
if (!animationId) animate(); | |
}); | |
document.addEventListener('keydown', (event) => { | |
if (event.key.toLowerCase() in keys) keys[event.key.toLowerCase()] = true; | |
if (!animationId && Object.values(keys).some(k => k)) 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() { | |
animationId = requestAnimationFrame(animate); | |
let hasMoved = false; | |
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); | |
hasMoved = true; | |
} | |
} | |
// Limit movement | |
if (camera.position.length() > maxDistance) { | |
camera.position.setLength(maxDistance); | |
} | |
if (isRotating && scene.children.some(c => c instanceof THREE.Mesh)) { | |
scene.rotation.y += 0.0005; | |
} | |
renderer.render(scene, camera); | |
} | |
animate(); // Start the animation loop | |
</script> | |
</body> | |
</html> |