| | <!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; |
| | 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; |
| | } |
| | #progress-bar { |
| | width: 0%; |
| | height: 20px; |
| | background-color: #4CAF50; |
| | |
| | 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> |
| | |
| | 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) |
| | })); |
| | |
| | |
| | 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'); |
| | |
| | |
| | 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/three@0.132.2/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() { |
| | 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); |
| | } |
| | |
| | |
| | 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); |
| | }); |
| | }); |
| | |
| | |
| | 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; |
| | |
| | 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); |
| | }); |
| | |
| | |
| | function animate() { |
| | |
| | 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); |
| | |
| | |
| | if (isRotating && scene.children.some(c => c instanceof THREE.Mesh)) scene.rotation.y += 0.0005; |
| | |
| | |
| | renderer.render(scene, camera); |
| | |
| | |
| | animationId = requestAnimationFrame(animate); |
| | } |
| | |
| | |
| | animate(); |
| | </script> |
| | </body> |
| | </html> |