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; | |
| } | |
| 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/three@0.132.2/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/PLYLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/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/three@0.132.2/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> |