|
<!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; |
|
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"> |
|
|
|
</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> |
|
|
|
</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> |
|
|
|
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'] } |
|
]; |
|
|
|
|
|
const examples = examplesData.map(ex => ({ |
|
name: ex.name, |
|
previewImage: baseURL + ex.previewImage, |
|
files: ex.files.map(file => baseURL + file) |
|
})); |
|
|
|
|
|
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); |
|
}); |
|
|
|
|
|
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); |
|
|
|
|
|
const plyLoader = new THREE.PLYLoader(); |
|
const dracoLoader = new THREE.DRACOLoader(); |
|
dracoLoader.setDecoderPath('https://cdn.jsdelivr.net/npm/[email protected]/examples/js/libs/draco/'); |
|
|
|
|
|
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; |
|
|
|
|
|
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); |
|
camera.lookAt(0, 0, -10); |
|
} |
|
|
|
|
|
|
|
|
|
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'; |
|
}); |
|
} |
|
|
|
|
|
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' }); |
|
dracoLoader.parse(event.target.result, (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); |
|
}); |
|
}); |
|
|
|
|
|
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); |
|
}); |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
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(); |
|
</script> |
|
</body> |
|
</html> |