| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>FlashWorld Demo</title> |
| <meta name="description" content=""> |
| <style> |
| body { |
| margin: 0; |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: #1a1a1a; |
| color: #ffffff; |
| overflow: hidden; |
| } |
| |
| .main-container { |
| display: flex; |
| height: 100vh; |
| flex-direction: column; |
| } |
| |
| .header { |
| background: rgba(0, 0, 0, 0.8); |
| padding: 15px 20px; |
| text-align: center; |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
| flex-shrink: 0; |
| } |
| |
| .header h1 { |
| margin: 0; |
| color: white; |
| font-size: 1.8em; |
| font-weight: 600; |
| margin-bottom: 8px; |
| } |
| .header-title-wrap { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| position: relative; |
| } |
| |
| .header-links { |
| display: flex; |
| justify-content: center; |
| gap: 20px; |
| margin-top: 8px; |
| } |
| |
| .header-links a { |
| color: #60a5fa; |
| text-decoration: none; |
| font-size: 0.9em; |
| padding: 5px 10px; |
| border: 1px solid #60a5fa; |
| border-radius: 5px; |
| transition: all 0.3s ease; |
| } |
| |
| .header-links a:hover { |
| background: #60a5fa; |
| color: white; |
| } |
| |
| .content-container { |
| display: flex; |
| flex: 1; |
| overflow: visible; |
| min-height: 0; |
| } |
| |
| .left-panel { |
| width: 280px; |
| background: rgba(0, 0, 0, 0.7); |
| border-right: 1px solid rgba(255, 255, 255, 0.1); |
| padding: 20px; |
| overflow-y: auto; |
| overflow-x: visible; |
| flex-shrink: 0; |
| min-height: 0; |
| -webkit-overflow-scrolling: touch; |
| } |
| |
| .center-panel { |
| flex: 1; |
| position: relative; |
| background: #000; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 1; |
| } |
| |
| .right-panel { |
| width: 300px; |
| background: rgba(0, 0, 0, 0.7); |
| border-left: 1px solid rgba(255, 255, 255, 0.1); |
| padding: 20px; |
| overflow-y: auto; |
| flex-shrink: 0; |
| z-index: 1; |
| } |
| |
| .guidance { |
| color: #e5e7eb; |
| } |
| |
| .guidance h2 { |
| color: #ffffff; |
| margin-top: 0; |
| font-size: 1.3em; |
| border-bottom: 2px solid #60a5fa; |
| padding-bottom: 8px; |
| margin-bottom: 20px; |
| } |
| |
| .gui-container h2{ |
| color: #ffffff; |
| margin-top: 0; |
| font-size: 1.3em; |
| border-bottom: 2px solid #60fae5; |
| padding-bottom: 8px; |
| margin-bottom: 20px; |
| } |
| |
| .step { |
| margin: 12px 0; |
| padding: 12px; |
| background: rgba(96, 165, 250, 0.1); |
| border-radius: 6px; |
| border-left: 3px solid #60a5fa; |
| } |
| |
| .step h3 { |
| margin: 0 0 8px 0; |
| color: #ffffff; |
| font-size: 1em; |
| } |
| |
| .step p { |
| margin: 4px 0; |
| line-height: 1.4; |
| font-size: 0.85em; |
| color: #d1d5db; |
| } |
| |
| .controls-info { |
| background: rgba(168, 85, 247, 0.1); |
| border-left: 3px solid #a855f7; |
| } |
| |
| .keyboard-shortcuts { |
| background: rgba(34, 197, 94, 0.1); |
| border-left: 3px solid #22c55e; |
| } |
| |
| .loading { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| min-width: 300px; |
| min-height: 200px; |
| transform: translate(-50%, -50%); |
| background: rgba(0, 0, 0, 0.9); |
| color: white; |
| padding: 20px; |
| border-radius: 10px; |
| display: none; |
| z-index: 1000; |
| text-align: center; |
| vertical-align: middle; |
| } |
| |
| .generation-info { |
| background: rgba(34, 197, 94, 0.1); |
| border: 1px solid #22c55e; |
| border-radius: 8px; |
| padding: 15px; |
| margin: 10px 0; |
| color: #22c55e; |
| font-family: 'Courier New', monospace; |
| font-size: 0.9em; |
| } |
| |
| .progress-container { |
| width: 100%; |
| background: rgba(255, 255, 255, 0.1); |
| border-radius: 10px; |
| overflow: hidden; |
| margin: 10px 0; |
| position: relative; |
| } |
| |
| .progress-bar { |
| height: 20px; |
| background: linear-gradient(90deg, #60a5fa, #3b82f6); |
| width: 0%; |
| transition: width 0.3s ease; |
| border-radius: 10px; |
| position: relative; |
| } |
| |
| .progress-text { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| color: white; |
| font-weight: bold; |
| font-size: 0.8em; |
| white-space: nowrap; |
| } |
| |
| |
| .info-tip { |
| display: inline-block; |
| position: relative; |
| margin-left: 8px; |
| width: 16px; |
| height: 16px; |
| line-height: 16px; |
| text-align: center; |
| border-radius: 50%; |
| background: #3b82f6; |
| color: #fff; |
| font-size: 12px; |
| cursor: default; |
| user-select: none; |
| z-index: 100000; |
| } |
| .info-tip .tooltip { |
| display: none; |
| position: absolute; |
| left: 0; |
| top: calc(100% + 8px); |
| transform: none; |
| background: rgba(0,0,0,0.95); |
| color: #e5e7eb; |
| border: 1px solid rgba(255,255,255,0.2); |
| border-radius: 8px; |
| padding: 10px 12px; |
| font-size: 12px; |
| width: 480px; |
| white-space: normal; |
| z-index: 999999; |
| box-shadow: 0 8px 24px rgba(0,0,0,0.6); |
| text-align: left; |
| } |
| .info-tip:hover .tooltip { |
| display: block; |
| } |
| |
| .status-bar { |
| background: rgba(0, 0, 0, 0.9); |
| color: #60a5fa; |
| padding: 8px 15px; |
| font-family: 'Courier New', monospace; |
| font-size: 0.8em; |
| border-top: 1px solid rgba(255, 255, 255, 0.1); |
| flex-shrink: 0; |
| } |
| |
| .canvas-container { |
| width: 100%; |
| height: 100%; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| background: |
| repeating-linear-gradient( |
| 45deg, |
| #1a1a1a 0px, |
| #1a1a1a 10px, |
| #2a2a2a 10px, |
| #2a2a2a 20px |
| ); |
| position: relative; |
| } |
| |
| .canvas-wrapper { |
| position: relative; |
| border: 2px solid #444; |
| background: #111; |
| box-shadow: |
| 0 0 20px rgba(0, 0, 0, 0.5), |
| inset 0 0 10px rgba(0, 0, 0, 0.3); |
| border-radius: 4px; |
| } |
| |
| .canvas-wrapper canvas { |
| display: block; |
| border-radius: 2px; |
| } |
| |
| |
| .canvas-wrapper:hover { |
| border-color: #666; |
| box-shadow: |
| 0 0 30px rgba(0, 0, 0, 0.7), |
| inset 0 0 15px rgba(0, 0, 0, 0.4); |
| } |
| |
| |
| .progress-container { |
| width: 100%; |
| height: 18px; |
| background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); |
| border: 1px solid rgba(255,255,255,0.12); |
| border-radius: 999px; |
| overflow: hidden; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.35) inset; |
| position: relative; |
| } |
| .progress-bar { |
| height: 100%; |
| background: linear-gradient(90deg, #60a5fa, #8b5cf6); |
| box-shadow: 0 0 10px rgba(96,165,250,0.65); |
| position: relative; |
| transition: width .15s ease; |
| } |
| .progress-text { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| font-size: 11px; |
| color: #f8fafc; |
| text-shadow: 0 1px 2px rgba(0,0,0,0.5); |
| pointer-events: none; |
| white-space: nowrap; |
| } |
| |
| .status-badges { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| margin-top: 8px; |
| } |
| .badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| padding: 6px 10px; |
| border-radius: 8px; |
| font-size: 12px; |
| border: 1px solid rgba(255,255,255,0.12); |
| background: rgba(255,255,255,0.06); |
| } |
| .badge .dot { width: 8px; height: 8px; border-radius: 999px; } |
| .badge.queue .dot { background: #f59e0b; } |
| .badge.running .dot { background: #22c55e; } |
| .badge.time .dot { background: #60a5fa; } |
| .badge.bytes .dot { background: #a78bfa; } |
| |
| .details-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 6px 12px; |
| margin-top: 8px; |
| font-size: 12px; |
| color: #cbd5e1; |
| } |
| .details-grid div { opacity: 0.9; } |
| |
| |
| .canvas-wrapper.resizing { |
| border-color: #60a5fa; |
| box-shadow: |
| 0 0 25px rgba(96, 165, 250, 0.3), |
| inset 0 0 10px rgba(96, 165, 250, 0.1); |
| } |
| |
| .canvas-wrapper.resizing::after { |
| content: "Resizing..."; |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| color: #60a5fa; |
| font-size: 12px; |
| font-weight: bold; |
| z-index: 10; |
| pointer-events: none; |
| } |
| |
| |
| .gui-panel { |
| background: rgba(0, 0, 0, 0.8); |
| border-radius: 8px; |
| padding: 15px; |
| min-height: 400px; |
| } |
| |
| .gui-panel .lil-gui { |
| --background-color: rgba(0, 0, 0, 0.8); |
| --text-color: #ffffff; |
| --title-background-color: rgba(96, 165, 250, 0.2); |
| --title-text-color: #ffffff; |
| --widget-color: rgba(96, 165, 250, 0.3); |
| --hover-color: rgba(96, 165, 250, 0.5); |
| } |
| |
| |
| .lil-gui { |
| position: relative !important; |
| z-index: 1000 !important; |
| } |
| |
| |
| .examples-section { |
| margin-top: 16px; |
| padding-top: 12px; |
| border-top: 1px solid rgba(255, 255, 255, 0.1); |
| } |
| .examples-section h3 { |
| margin: 0 0 8px 0; |
| color: #ffffff; |
| font-size: 1em; |
| } |
| .examples-grid { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 8px; |
| } |
| .example-item { |
| position: relative; |
| width: 100%; |
| padding-top: 100%; |
| background: rgba(255,255,255,0.06); |
| border: 1px solid rgba(255,255,255,0.12); |
| border-radius: 6px; |
| overflow: hidden; |
| cursor: pointer; |
| transition: transform 0.15s ease, box-shadow 0.15s ease; |
| } |
| .example-item:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 16px rgba(0,0,0,0.35); |
| } |
| .example-item img { |
| position: absolute; |
| top: 0; left: 0; right: 0; bottom: 0; |
| width: 100%; height: 100%; object-fit: cover; |
| } |
| .example-item .label { |
| position: absolute; |
| bottom: 0; left: 0; right: 0; |
| background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.65) 100%); |
| color: #e5e7eb; |
| font-size: 11px; |
| padding: 4px 6px; |
| text-align: center; |
| } |
| |
| @media (max-width: 1200px) { |
| .left-panel { |
| width: 250px; |
| } |
| |
| .right-panel { |
| width: 280px; |
| } |
| } |
| |
| @media (max-width: 768px) { |
| .content-container { |
| flex-direction: column; |
| } |
| |
| .left-panel, .right-panel { |
| width: 100%; |
| height: auto; |
| max-height: 200px; |
| } |
| |
| .center-panel { |
| flex: 1; |
| min-height: 400px; |
| } |
| } |
| </style> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.178.0/three.module.js", |
| "@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/0.1.9/spark.module.js", |
| "lil-gui": "https://cdn.jsdelivr.net/npm/lil-gui@0.20/+esm" |
| } |
| } |
| </script> |
| </head> |
| <body> |
| <div class="main-container"> |
| |
| <header class="header"> |
| <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"> |
| <h1 style="margin: 0; flex: 1; text-align: left;"> |
| <span class="header-title-wrap">FlashWorld Spark Demo |
| <span class="info-tip">! |
| <span class="tooltip" style="max-width: 260px; text-align: left;">Front-end real-time rendering in Spark uses compressed and pruned Gaussians. Visual quality in this demo may be lower than offline/back-end rendering. |
| </span> |
| </span> |
| </span> |
| </h1> |
| <div class="header-links" style="margin-left: 20px;"> |
| <a href="https://arxiv.org/pdf/2510.13678" target="_blank">Paper</a> |
| <a href="https://github.com/imlixinyang/FlashWorld" target="_blank">Code</a> |
| <a href="https://imlixinyang.github.io/FlashWorld-Project-Page/" target="_blank">Project Page</a> |
| </div> |
| </div> |
| </header> |
|
|
| |
| <div class="content-container"> |
| |
| <div class="left-panel"> |
| <div class="guidance"> |
| |
| |
| <div class="step"> |
| <h3>1. Configure</h3> |
| <p>Set FOV and Resolution and Click "Fix Configurations"</p> |
| <p><strong>Important</strong>: You need to specify your Hugging Face Access Token with READ permission to use the online free ZeroGPU service.</p> |
| </div> |
|
|
|
|
| <div class="step"> |
| <h3>2. Set Camera Trajectory</h3> |
| <p><b>Manual:</b> Navigate with mouse and keyboard, press <kbd>Space</kbd> to record</p> |
| <p><b>Template:</b> Select template type and click "Generate Trajectory"</p> |
| <p><b>JSON:</b> Load trajectory from JSON file</p> |
| </div> |
|
|
| <div class="step"> |
| <h3>3. Add Prompts</h3> |
| <p>Upload image or enter text description</p> |
| </div> |
|
|
| <div class="step"> |
| <h3>4. Generate</h3> |
| <p>Click "Generate!" to create your scene</p> |
| </div> |
|
|
| <div class="step controls-info"> |
| <h3>Controls</h3> |
| <p><strong>Mouse/QE:</strong> Rotate view</p> |
| <p><strong>WASD/RF:</strong> Move</p> |
| <p><strong>Space:</strong> Record camera</p> |
| </div> |
|
|
| </div> |
|
|
| |
| <div id="examples-section" class="examples-section"> |
| <h3>Examples</h3> |
| <div id="examples-grid" class="examples-grid"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="center-panel"> |
| <div class="canvas-container" id="canvas-container"> |
| <div class="canvas-wrapper" id="canvas-wrapper"> |
| <div class="loading" id="loading"> |
| <h3>🎬 Generating Scene...</h3> |
| <p>Please wait while we create your 3D scene</p> |
| <div id="generation-info" class="generation-info" style="display: none;"> |
| <div><strong>Generation Time:</strong> <span id="generation-time">-</span> seconds</div> |
| <div><strong>File Size:</strong> <span id="file-size">-</span> MB</div> |
| </div> |
| <div id="download-progress" style="display: none;"> |
| <div class="progress-container"> |
| <div class="progress-bar" id="progress-bar"></div> |
| <div class="progress-text" id="progress-text">0%</div> |
| </div> |
| <div class="status-badges" id="status-badges" style="display: none;"> |
| <div class="badge queue" id="badge-queue"><span class="dot"></span><span id="badge-queue-text">Queue</span></div> |
| <div class="badge running" id="badge-running" style="display: none;"><span class="dot"></span><span id="badge-running-text">Running</span></div> |
| <div class="badge time" id="badge-time" style="display: none;"><span class="dot"></span><span id="badge-time-text">00:00</span></div> |
| </div> |
| <div id="queue-details" class="details-grid" style="display: none;"></div> |
| <div id="download-details" class="details-grid" style="display: none;"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="right-panel"> |
| <div class="gui-container"> |
| |
| <div class="gui-panel" id="gui-container"> |
| |
| </div> |
| </div> |
| |
| |
| <div id="image-preview-area" style="padding: 10px; display: none;"> |
| <div style="font-size: 12px; color: #ccc; margin-bottom: 8px; text-align: left;">Input Image Preview</div> |
| <div style="text-align: center;"> |
| <img id="preview-img" style="max-width: 100%; max-height: 200px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);" /> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="status-bar" id="status-bar"> |
| Ready to generate 3D scenes | Cameras: 0 | Status: Waiting for input |
| </div> |
| </div> |
|
|
| |
| <input id="file-input" type="file" accept=".jpg,.png,.jpeg" multiple="true" style="display: none;" /> |
| <input id="json-input" type="file" accept=".json" multiple="false" style="display: none;" /> |
|
|
| <script type="module"> |
| |
| |
| |
| import * as THREE from "three"; |
| import { SplatMesh, SparkControls, textSplats } from "@sparkjsdev/spark"; |
| import GUI from "lil-gui"; |
| |
| |
| const scene = new THREE.Scene(); |
| const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); |
| camera.position.set(0, 0, 1.5); |
| const renderer = new THREE.WebGLRenderer(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| |
| |
| function initializeRenderer() { |
| const canvasWrapper = document.getElementById('canvas-wrapper'); |
| if (canvasWrapper) { |
| canvasWrapper.appendChild(renderer.domElement); |
| |
| |
| updateCanvasSize(); |
| console.log('Canvas initialized in wrapper'); |
| } else { |
| console.error('Canvas wrapper not found'); |
| } |
| } |
| |
| |
| function updateCanvasSize() { |
| const canvasWrapper = document.getElementById('canvas-wrapper'); |
| if (!canvasWrapper) return; |
| |
| |
| canvasWrapper.classList.add('resizing'); |
| |
| |
| const resolution = guiOptions.Resolution.split('x'); |
| const width = parseInt(resolution[2]) || 704; |
| const height = parseInt(resolution[1]) || 480; |
| |
| |
| renderer.setSize(width, height); |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| |
| |
| canvasWrapper.style.width = width + 'px'; |
| canvasWrapper.style.height = height + 'px'; |
| |
| |
| setTimeout(() => { |
| canvasWrapper.classList.remove('resizing'); |
| }, 300); |
| |
| console.log('Canvas size updated:', width, 'x', height); |
| } |
| |
| const controls = new SparkControls({ canvas: renderer.domElement }); |
| |
| |
| const cameraSplats = []; |
| const cameraParams = []; |
| const interpolatedCamerasSplats = []; |
| |
| |
| let fixGenerationFOV = false; |
| let inputImageBase64 = null; |
| let inputImageResolution = null; |
| let currentGeneratedSplat = null; |
| let currentDownloadedBlob = null; |
| |
| |
| const loadingElement = document.getElementById('loading'); |
| const statusBar = document.getElementById('status-bar'); |
| |
| |
| let gui = null; |
| |
| |
| function updateStatus(message, cameraCount = null) { |
| const cameraText = cameraCount !== null ? `Cameras: ${cameraCount}` : `Cameras: ${cameraParams.length}`; |
| statusBar.textContent = `${message} | ${cameraText} | Status: ${fixGenerationFOV ? 'Ready to record' : 'Configure settings'}`; |
| |
| |
| updateSaveTrajectoryButton(); |
| } |
| |
| |
| function updateSaveTrajectoryButton() { |
| if (window.saveTrajectoryController) { |
| if (cameraParams.length >= 2) { |
| window.saveTrajectoryController.enable(); |
| } else { |
| window.saveTrajectoryController.disable(); |
| } |
| } |
| } |
| |
| |
| function fetchWithAuth(url, options = {}) { |
| const mergedOptions = { ...options }; |
| const headers = new Headers(options && options.headers ? options.headers : undefined); |
| if (guiOptions && guiOptions.HF_TOKEN && String(guiOptions.HF_TOKEN).trim().length > 0) { |
| headers.set('Authorization', `Bearer ${guiOptions.HF_TOKEN}`); |
| } |
| mergedOptions.headers = headers; |
| return fetch(url, mergedOptions); |
| } |
| |
| |
| function showLoading(show) { |
| loadingElement.style.display = show ? 'block' : 'none'; |
| } |
| |
| |
| function showGenerationInfo(generationTime, fileSize) { |
| const generationInfo = document.getElementById('generation-info'); |
| const generationTimeElement = document.getElementById('generation-time'); |
| const fileSizeElement = document.getElementById('file-size'); |
| |
| generationTimeElement.textContent = generationTime.toFixed(2); |
| fileSizeElement.textContent = (fileSize / (1024 * 1024)).toFixed(2); |
| generationInfo.style.display = 'block'; |
| } |
| |
| |
| function showDownloadProgress() { |
| const downloadProgress = document.getElementById('download-progress'); |
| downloadProgress.style.display = 'block'; |
| const qd = document.getElementById('queue-details'); |
| const dd = document.getElementById('download-details'); |
| const badges = document.getElementById('status-badges'); |
| if (qd) qd.style.display = 'none'; |
| if (dd) dd.style.display = 'none'; |
| if (badges) badges.style.display = 'none'; |
| } |
| |
| |
| function updateProgressBar(percentage) { |
| const progressBar = document.getElementById('progress-bar'); |
| const progressText = document.getElementById('progress-text'); |
| |
| progressBar.style.width = percentage + '%'; |
| progressText.textContent = `${Math.round(percentage)}%`; |
| } |
| |
| |
| function setProgressLabel(text) { |
| const progressText = document.getElementById('progress-text'); |
| if (progressText) progressText.textContent = text; |
| } |
| |
| |
| |
| |
| let queuePollTimer = null; |
| let currentTaskId = null; |
| let initialQueuePosition = null; |
| let latestGenerationTime = null; |
| let lastDownloadPct = 0; |
| let lastDownloadUpdateTs = 0; |
| |
| function showQueueWaiting(position, runningCount, queuedCount) { |
| |
| showDownloadProgress(); |
| if (initialQueuePosition === null) { |
| |
| const initPos = (typeof position === 'number') ? position : 0; |
| initialQueuePosition = Math.max(initPos, 1); |
| } |
| const percent = initialQueuePosition && initialQueuePosition > 0 |
| ? Math.max(0, Math.min(100, ((initialQueuePosition - (position || 0)) / initialQueuePosition) * 100)) |
| : 0; |
| updateProgressBar(percent); |
| const totalWaiting = (position || 0) + (queuedCount || 0); |
| if (position !== null && position !== undefined) { |
| const pctText = `${Math.round(percent)}%`; |
| if (totalWaiting > 0) { |
| setProgressLabel(`Queued ${position}/${totalWaiting} (${pctText})`); |
| } else { |
| setProgressLabel(`Queued ${position} (${pctText})`); |
| } |
| } else { |
| setProgressLabel('Queued'); |
| } |
| } |
| |
| async function pollTaskUntilReady(taskId) { |
| currentTaskId = taskId; |
| initialQueuePosition = null; |
| if (queuePollTimer) { |
| clearInterval(queuePollTimer); |
| queuePollTimer = null; |
| } |
| const queueStartTs = Date.now(); |
| |
| const pollOnce = async () => { |
| try { |
| const resp = await fetchWithAuth(`${guiOptions.BackendAddress}/task/${taskId}`); |
| if (!resp.ok) return; |
| const info = await resp.json(); |
| if (!info || !info.success) return; |
| |
| const pos = info.queue && typeof info.queue.position === 'number' ? info.queue.position : 0; |
| const running = info.queue ? info.queue.running_count : 0; |
| const queued = info.queue ? info.queue.queued_count : 0; |
| if (info.status === 'queued' || info.status === 'running') { |
| |
| if (info.status === 'queued') { |
| showQueueWaiting(pos, running, queued); |
| } else { |
| |
| updateProgressBar(100); |
| showDownloadProgress(); |
| setProgressLabel('Generating...'); |
| } |
| } |
| |
| if (info.status === 'completed' && info.download_url) { |
| clearInterval(queuePollTimer); |
| queuePollTimer = null; |
| latestGenerationTime = typeof info.generation_time === 'number' ? info.generation_time : null; |
| |
| updateStatus('Downloading generated scene...', cameraParams.length); |
| const response = await fetchWithAuth(guiOptions.BackendAddress + info.download_url); |
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); |
| const contentLength = response.headers.get('content-length'); |
| const total = parseInt(contentLength || '0', 10); |
| |
| showGenerationInfo(latestGenerationTime || 0, total); |
| let loaded = 0; |
| const reader = response.body.getReader(); |
| const chunks = []; |
| updateProgressBar(0); |
| setProgressLabel('Downloading 0%'); |
| lastDownloadPct = 0; |
| lastDownloadUpdateTs = 0; |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| chunks.push(value); |
| loaded += value.length; |
| if (total) { |
| const pct = Math.min(100, (loaded / total) * 100); |
| const now = Date.now(); |
| const rounded = Math.round(pct); |
| |
| if (rounded > Math.round(lastDownloadPct) || (now - lastDownloadUpdateTs) > 200) { |
| lastDownloadPct = Math.max(lastDownloadPct, pct); |
| updateProgressBar(lastDownloadPct); |
| setProgressLabel(`Downloading ${Math.round(lastDownloadPct)}%`); |
| lastDownloadUpdateTs = now; |
| } |
| } |
| } |
| |
| if (instructionSplat) { |
| scene.remove(instructionSplat); |
| console.log('Instruction splat removed'); |
| instructionSplat = null; |
| } |
| |
| const blob = new Blob(chunks); |
| const url = URL.createObjectURL(blob); |
| |
| |
| currentDownloadedBlob = blob; |
| |
| |
| updateStatus('Loading generated scene...', cameraParams.length); |
| |
| const GeneratedSplat = new SplatMesh({ url }); |
| scene.add(GeneratedSplat); |
| currentGeneratedSplat = GeneratedSplat; |
| updateStatus('Scene generated successfully!', cameraParams.length); |
| |
| showGenerationInfo(latestGenerationTime || 0, total || blob.size); |
| |
| |
| if (window.downloadController) { |
| window.downloadController.enable(); |
| } |
| |
| try { |
| if (info.file_id) { |
| const resp = await fetchWithAuth(`${guiOptions.BackendAddress}/delete/${info.file_id}`, { method: 'POST' }); |
| if (!resp.ok) console.warn('Delete notify failed'); |
| } |
| } catch (e) { |
| console.warn('Delete notify error', e); |
| } |
| hideDownloadProgress(); |
| showLoading(false); |
| } else if (info.status === 'failed') { |
| clearInterval(queuePollTimer); |
| queuePollTimer = null; |
| throw new Error(info.error || 'Generation failed'); |
| } |
| } catch (e) { |
| console.debug('Polling error:', e); |
| } |
| }; |
| |
| await pollOnce(); |
| queuePollTimer = setInterval(pollOnce, 2000); |
| } |
| |
| |
| function hideDownloadProgress() { |
| const downloadProgress = document.getElementById('download-progress'); |
| downloadProgress.style.display = 'none'; |
| } |
| |
| |
| let userCameraState = null; |
| |
| |
| function getInterpolatedCameraAtTime(t) { |
| if (cameraParams.length === 0) { |
| return camera; |
| } |
| |
| if (cameraParams.length === 1) { |
| return cameraParams[0]; |
| } |
| |
| |
| const clampedT = Math.max(0, Math.min(1, t)); |
| |
| |
| const cameraIndex = clampedT * (cameraParams.length - 1); |
| const startIndex = Math.min(Math.floor(cameraIndex), cameraParams.length - 2); |
| const endIndex = startIndex + 1; |
| const startCamera = cameraParams[startIndex]; |
| const endCamera = cameraParams[endIndex]; |
| |
| |
| const _t = cameraIndex - startIndex; |
| |
| |
| return interpolateTwoCameras(startCamera, endCamera, _t); |
| } |
| |
| function setCameraByScrub(t) { |
| if (cameraParams.length === 0) return; |
| const clampedT = Math.max(0, Math.min(1, t)); |
| const camT = getInterpolatedCameraAtTime(clampedT); |
| camera.position.copy(camT.position); |
| camera.quaternion.copy(camT.quaternion); |
| camera.fov = camT.fov; |
| camera.updateProjectionMatrix(); |
| } |
| |
| |
| const supportedResolutions = [ |
| { frame: 24, width: 704, height: 480 }, |
| { frame: 24, width: 480, height: 704 } |
| ]; |
| |
| |
| const guiOptions = { |
| |
| BackendAddress: `https://imlixinyang-flashworld-demo.hf.space`, |
| HF_TOKEN: "", |
| FOV: 60, |
| LoadFromJson: () => { |
| const jsonInput = document.querySelector("#json-input"); |
| if (jsonInput) jsonInput.click(); |
| }, |
| LoadTrajectoryFromJson: () => { |
| if (!fixGenerationFOV) { |
| updateStatus('Warning: Please fix configuration first before loading trajectory', cameraParams.length); |
| return; |
| } |
| |
| window.loadTrajectoryOnly = true; |
| const jsonInput = document.querySelector("#json-input"); |
| if (jsonInput) jsonInput.click(); |
| }, |
| fixGenerationFOV: () => { |
| |
| if (window.fixGenerationFOVController) window.fixGenerationFOVController.disable(); |
| fixGenerationFOV = true; |
| |
| const new_camera = new THREE.PerspectiveCamera(guiOptions.FOV, guiOptions.Resolution.split('x')[2] / guiOptions.Resolution.split('x')[1]); |
| new_camera.position.set(0, 0, 0); |
| new_camera.quaternion.set(0, 0, 0, 1); |
| new_camera.updateProjectionMatrix(); |
| |
| const cameraSplat = createCameraSplat(new_camera); |
| cameraSplats.push(cameraSplat); |
| cameraParams.push({ |
| position: new_camera.position.clone(), |
| quaternion: new_camera.quaternion.clone(), |
| fov: new_camera.fov, |
| aspect: new_camera.aspect, |
| }); |
| scene.add(cameraSplat); |
| |
| updateStatus('Camera settings fixed. Press Space to record cameras.', cameraParams.length); |
| }, |
| Resolution: `${supportedResolutions[0].frame}x${supportedResolutions[0].height}x${supportedResolutions[0].width}`, |
| VisualizeCameraSplats: true, |
| VisualizeInterpolatedCameras: true, |
| inputImagePrompt: () => { |
| const fileInput = document.querySelector("#file-input"); |
| if (fileInput) { |
| |
| fileInput.click(); |
| } |
| }, |
| imageIndex: 0, |
| inputTextPrompt: "", |
| |
| LoadAllFromJson: () => { |
| |
| window.loadTrajectoryOnly = false; |
| const jsonInput = document.querySelector("#json-input"); |
| if (jsonInput) jsonInput.click(); |
| }, |
| SaveAllToJson: () => { |
| |
| const [nStr, hStr, wStr] = guiOptions.Resolution.split('x'); |
| const n = parseInt(nStr), h = parseInt(hStr), w = parseInt(wStr); |
| const fovDeg = guiOptions.FOV; |
| const fy = 0.5 / Math.tan(0.5 * fovDeg * Math.PI / 180) * h; |
| const fx = fy; |
| const cx = 0.5 * w; |
| const cy = 0.5 * h; |
| |
| const payload = { |
| image_prompt: inputImageBase64 ? inputImageBase64 : null, |
| text_prompt: guiOptions.inputTextPrompt || "", |
| image_index: guiOptions.imageIndex || 0, |
| resolution: [n, h, w], |
| cameras: cameraParams.map(cam => ({ |
| position: [cam.position.x, cam.position.y, cam.position.z], |
| quaternion: [cam.quaternion.w, cam.quaternion.x, cam.quaternion.y, cam.quaternion.z], |
| fx, fy, cx, cy |
| })) |
| }; |
| |
| const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `scene_all_${Date.now()}.json`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| updateStatus('All data saved to JSON.', cameraParams.length); |
| }, |
| |
| |
| trajectoryMode: "Manual", |
| templateType: "Move Forward", |
| cameraTrajectory: "Manual", |
| trajectorySettings: { |
| angle: 180, |
| tilt: 15 |
| }, |
| generateTrajectory: () => { |
| generateCameraTrajectory(guiOptions.templateType); |
| }, |
| saveTrajectoryToJson: () => { |
| |
| |
| const [nStr, hStr, wStr] = guiOptions.Resolution.split('x'); |
| const n = parseInt(nStr), h = parseInt(hStr), w = parseInt(wStr); |
| const payload = { |
| |
| |
| |
| |
| cameras: cameraParams.map(cam => ({ |
| position: [cam.position.x, cam.position.y, cam.position.z], |
| quaternion: [cam.quaternion.w, cam.quaternion.x, cam.quaternion.y, cam.quaternion.z] |
| })) |
| }; |
| |
| const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `trajectory_${Date.now()}.json`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| updateStatus('Trajectory saved to JSON.', cameraParams.length); |
| }, |
| clearAllCameras: () => { |
| if (cameraParams.length <= 1) { |
| updateStatus('No cameras to clear (first camera is always preserved)', cameraParams.length); |
| return; |
| } |
| |
| |
| const firstCamera = cameraParams[0]; |
| const firstSplat = cameraSplats[0]; |
| |
| |
| for (let i = cameraSplats.length - 1; i >= 1; i--) { |
| scene.remove(cameraSplats[i]); |
| } |
| |
| |
| cameraSplats.length = 1; |
| cameraParams.length = 1; |
| |
| |
| interpolatedCamerasSplats.forEach(splat => scene.remove(splat)); |
| interpolatedCamerasSplats.length = 0; |
| |
| updateStatus('Cameras cleared (first camera preserved). Ready to add more cameras.', 1); |
| console.log('Cameras cleared, first camera preserved'); |
| }, |
| |
| playbackT: 0, |
| |
| downloadGeneratedFile: () => { |
| if (!currentDownloadedBlob) { |
| updateStatus('No generated file available to download', cameraParams.length); |
| return; |
| } |
| |
| updateStatus(`Downloading SPZ file (${(currentDownloadedBlob.size / 1024 / 1024).toFixed(2)} MB)...`, cameraParams.length); |
| |
| |
| const url = URL.createObjectURL(currentDownloadedBlob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `${Date.now()}.spz`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| |
| updateStatus('SPZ file downloaded successfully!', cameraParams.length); |
| }, |
| |
| generate: () => { |
| |
| if (cameraParams.length < 2) { |
| console.error('Need at least 2 cameras to generate. Please press Space to record more cameras.'); |
| updateStatus('Error: Need at least 2 cameras', cameraParams.length); |
| return; |
| } |
| |
| updateStatus('Preparing generation...', cameraParams.length); |
| |
| |
| if (currentGeneratedSplat) { |
| scene.remove(currentGeneratedSplat); |
| currentGeneratedSplat = null; |
| console.log('Previous generated scene removed'); |
| } |
| |
| |
| currentDownloadedBlob = null; |
| |
| |
| if (window.downloadController) { |
| window.downloadController.disable(); |
| } |
| |
| |
| const generationTimeElement = document.getElementById('generation-time'); |
| const fileSizeElement = document.getElementById('file-size'); |
| const progressBar = document.getElementById('progress-bar'); |
| const progressText = document.getElementById('progress-text'); |
| |
| if (generationTimeElement) generationTimeElement.textContent = '-'; |
| if (fileSizeElement) fileSizeElement.textContent = '-'; |
| if (progressBar) progressBar.style.width = '0%'; |
| if (progressText) progressText.textContent = '0%'; |
| |
| |
| const generationInfo = document.getElementById('generation-info'); |
| const downloadProgress = document.getElementById('download-progress'); |
| if (generationInfo) generationInfo.style.display = 'none'; |
| if (downloadProgress) downloadProgress.style.display = 'none'; |
| |
| showLoading(true); |
| |
| |
| const interpolatedCameras = interpolateCameras(cameraParams, parseInt(guiOptions.Resolution.split('x')[0])); |
| interpolatedCameras.forEach(cam => { |
| const interpolatedCameraSplat = createCameraSplat(cam, [0.5, 0.5, 0.5]); |
| interpolatedCamerasSplats.push(interpolatedCameraSplat); |
| scene.add(interpolatedCameraSplat); |
| }); |
| |
| console.log('Sending request to backend...'); |
| console.log('Interpolated cameras:', interpolatedCameras.length); |
| updateStatus('Sending request to backend...', cameraParams.length); |
| |
| |
| const requestUrl = guiOptions.BackendAddress + '/gradio_api/call/gradio_generate'; |
| const requestData = { |
| image_prompt: inputImageBase64 ? inputImageBase64 : "", |
| text_prompt: guiOptions.inputTextPrompt, |
| image_index: 0, |
| resolution: [ |
| parseInt(guiOptions.Resolution.split('x')[0]), |
| parseInt(guiOptions.Resolution.split('x')[1]), |
| parseInt(guiOptions.Resolution.split('x')[2]) |
| ], |
| cameras: interpolatedCameras.map(cam => ({ |
| position: [cam.position.x, cam.position.y, cam.position.z], |
| quaternion: [cam.quaternion.w, cam.quaternion.x, cam.quaternion.y, cam.quaternion.z], |
| fx: 0.5 / Math.tan(0.5 * cam.fov * Math.PI / 180) * parseInt(guiOptions.Resolution.split('x')[1]), |
| fy: 0.5 / Math.tan(0.5 * cam.fov * Math.PI / 180) * parseInt(guiOptions.Resolution.split('x')[1]), |
| cx: inputImageBase64 && inputImageResolution |
| ? 0.5 * inputImageResolution.width |
| : 0.5 * parseInt(guiOptions.Resolution.split('x')[2]), |
| cy: inputImageBase64 && inputImageResolution |
| ? 0.5 * inputImageResolution.height |
| : 0.5 * parseInt(guiOptions.Resolution.split('x')[1]), |
| })) |
| }; |
| |
| fetchWithAuth(requestUrl, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| mode: 'cors', |
| body: JSON.stringify({ data: [JSON.stringify(requestData)] }) |
| }) |
| .then(response => response.json()) |
| .then(data => { |
| |
| if (!data || !data.event_id) { |
| throw new Error('Invalid Gradio response format - no event_id'); |
| } |
| return fetchWithAuth(guiOptions.BackendAddress + `/gradio_api/call/gradio_generate/${data.event_id}`) |
| .then(resp => { |
| if (!resp.ok) throw new Error(`HTTP error! status: ${resp.status}`); |
| return resp.text(); |
| }) |
| .then(sseText => { |
| const lines = sseText.split('\n'); |
| let eventType = null; |
| let dataContent = null; |
| for (const line of lines) { |
| if (line.startsWith('event: ')) eventType = line.substring(7); |
| else if (line.startsWith('data: ')) dataContent = line.substring(6); |
| } |
| if (eventType !== 'complete' || !dataContent) { |
| throw new Error('Gradio SSE response not complete or missing data'); |
| } |
| const resultData = JSON.parse(dataContent); |
| if (!resultData || resultData.length === 0) { |
| throw new Error('Invalid Gradio generation result format'); |
| } |
| const responseData = JSON.parse(resultData[0]); |
| if (!responseData.success) { |
| throw new Error('Gradio generation failed: ' + (responseData.error || 'Unknown error')); |
| } |
| |
| |
| showGenerationInfo(responseData.generation_time, responseData.file_size); |
| showDownloadProgress(); |
| updateStatus('Downloading generated scene...', cameraParams.length); |
| |
| |
| return fetchWithAuth(guiOptions.BackendAddress + '/gradio_api/call/download_file', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ data: [responseData.file_id] }) |
| }) |
| .then(r => r.json()) |
| .then(downloadEvent => { |
| return fetchWithAuth(guiOptions.BackendAddress + `/gradio_api/call/download_file/${downloadEvent.event_id}`) |
| .then(r => { |
| if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`); |
| return r.text(); |
| }) |
| .then(downloadSseText => { |
| const lines = downloadSseText.split('\n'); |
| let eventType = null; |
| let dataContent = null; |
| for (const line of lines) { |
| if (line.startsWith('event: ')) eventType = line.substring(7); |
| else if (line.startsWith('data: ')) dataContent = line.substring(6); |
| } |
| if (eventType !== 'complete' || !dataContent) { |
| throw new Error('Gradio download SSE response not complete or missing data'); |
| } |
| const fileData = JSON.parse(dataContent); |
| if (!fileData || fileData.length === 0 || !fileData[0].url) { |
| throw new Error('Invalid file data format from Gradio'); |
| } |
| return fileData[0].url; |
| }); |
| }); |
| }) |
| .then(fileUrl => { |
| return fetchWithAuth(fileUrl).then(response => { |
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); |
| const contentLength = response.headers.get('content-length'); |
| const total = parseInt(contentLength || '0', 10); |
| let loaded = 0; |
| const reader = response.body.getReader(); |
| const chunks = []; |
| function pump() { |
| return reader.read().then(({ done, value }) => { |
| if (done) return new Blob(chunks); |
| chunks.push(value); |
| loaded += value.length; |
| if (total) updateProgressBar((loaded / total) * 100); |
| return pump(); |
| }); |
| } |
| return pump().then(blob => { |
| |
| currentDownloadedBlob = blob; |
| |
| const url = URL.createObjectURL(blob); |
| return { url, __deleteAfterDownloadFileId: (typeof responseData !== 'undefined' ? responseData.file_id : null) }; |
| }); |
| }); |
| }); |
| }) |
| .then(data => { |
| if (data && data.url) { |
| updateStatus('Loading 3D scene...', cameraParams.length); |
| if (instructionSplat) { |
| scene.remove(instructionSplat); |
| console.log('Instruction splat removed'); |
| } |
| const GeneratedSplat = new SplatMesh({ url: data.url }); |
| scene.add(GeneratedSplat); |
| currentGeneratedSplat = GeneratedSplat; |
| console.log('3D scene loaded successfully!'); |
| updateStatus('Scene generated successfully!', cameraParams.length); |
| |
| |
| if (window.downloadController) { |
| window.downloadController.enable(); |
| } |
| |
| hideDownloadProgress(); |
| showLoading(false); |
| |
| |
| if (data.__deleteAfterDownloadFileId) { |
| fetchWithAuth(guiOptions.BackendAddress + '/delete/' + data.__deleteAfterDownloadFileId, { method: 'POST' }) |
| .then(() => console.log('Delete notify sent')) |
| .catch(err => console.warn('Delete notify failed', err)); |
| } |
| } |
| }) |
| .catch(error => { |
| console.error('Error:', error); |
| updateStatus('Generation failed: ' + error.message, cameraParams.length); |
| hideDownloadProgress(); |
| showLoading(false); |
| }); |
| } |
| }; |
| |
| |
| |
| |
| const EXAMPLE_FILES = Array.from({ length: 8 }, (_, i) => `examples/${i + 1}.json`); |
| |
| function processJsonLoad(jsonData, loadTrajectoryOnlyProvided) { |
| |
| const loadTrajectoryOnly = !!loadTrajectoryOnlyProvided; |
| |
| |
| cameraSplats.forEach(splat => scene.remove(splat)); |
| cameraSplats.length = 0; |
| cameraParams.length = 0; |
| interpolatedCamerasSplats.forEach(splat => scene.remove(splat)); |
| interpolatedCamerasSplats.length = 0; |
| |
| try { |
| const imagePrompt = jsonData.image_prompt || jsonData.imagePrompt || null; |
| const textPrompt = jsonData.text_prompt || jsonData.textPrompt || ""; |
| const cameras = jsonData.cameras || []; |
| const resolution = jsonData.resolution || [16, 480, 640]; |
| const imageIndex = jsonData.image_index || jsonData.imageIndex || 0; |
| |
| |
| if (!loadTrajectoryOnly && imagePrompt) { |
| inputImageBase64 = imagePrompt; |
| const previewArea = document.getElementById('image-preview-area'); |
| const previewImg = document.getElementById('preview-img'); |
| if (previewImg && previewArea) { |
| previewImg.src = inputImageBase64; |
| previewArea.style.display = 'block'; |
| } |
| } |
| |
| if (!loadTrajectoryOnly) { |
| guiOptions.inputTextPrompt = textPrompt; |
| guiOptions.imageIndex = imageIndex; |
| syncGuiPromptControls(); |
| } |
| |
| |
| if (!loadTrajectoryOnly && Array.isArray(resolution) && resolution.length === 3 && cameras && cameras.length > 0) { |
| const H = resolution[1]; |
| const firstCam = cameras[0]; |
| if (firstCam && typeof firstCam.fy === 'number' && isFinite(firstCam.fy) && firstCam.fy > 0) { |
| const inferredFov = 2 * Math.atan(0.5 * H / firstCam.fy) * 180 / Math.PI; |
| guiOptions.FOV = inferredFov; |
| } |
| } |
| |
| if (cameras && cameras.length > 0) { |
| let jsonFirstPosition = null; |
| let jsonFirstQuaternion = null; |
| const firstCameraData = cameras[0]; |
| if (Array.isArray(firstCameraData?.position) && firstCameraData.position.length === 3) { |
| jsonFirstPosition = new THREE.Vector3( |
| firstCameraData.position[0], |
| firstCameraData.position[1], |
| firstCameraData.position[2] |
| ); |
| } |
| if (Array.isArray(firstCameraData?.quaternion) && firstCameraData.quaternion.length === 4) { |
| jsonFirstQuaternion = new THREE.Quaternion( |
| firstCameraData.quaternion[1], |
| firstCameraData.quaternion[2], |
| firstCameraData.quaternion[3], |
| firstCameraData.quaternion[0] |
| ); |
| } |
| |
| cameras.forEach((cameraData) => { |
| let aspect = 1.0; |
| if (Array.isArray(resolution) && resolution.length === 3) { |
| aspect = resolution[2] / resolution[1]; |
| } else { |
| aspect = guiOptions.Resolution.split('x')[2] / guiOptions.Resolution.split('x')[1]; |
| } |
| |
| let fov = 60; |
| if (loadTrajectoryOnly) { |
| fov = guiOptions.FOV; |
| } else { |
| if (Array.isArray(resolution) && resolution.length === 3 && typeof cameraData.fy === 'number' && cameraData.fy > 0) { |
| const H = resolution[1]; |
| fov = 2 * Math.atan(0.5 * H / cameraData.fy) * 180 / Math.PI; |
| guiOptions.FOV = fov; |
| } else { |
| fov = guiOptions.FOV; |
| } |
| } |
| |
| const cam = new THREE.PerspectiveCamera(fov, aspect); |
| if (Array.isArray(cameraData.position) && cameraData.position.length === 3) { |
| cam.position.set(cameraData.position[0], cameraData.position[1], cameraData.position[2]); |
| } |
| if (Array.isArray(cameraData.quaternion) && cameraData.quaternion.length === 4) { |
| cam.quaternion.set( |
| cameraData.quaternion[1], |
| cameraData.quaternion[2], |
| cameraData.quaternion[3], |
| cameraData.quaternion[0] |
| ); |
| } |
| |
| if (jsonFirstPosition && jsonFirstQuaternion) { |
| const jsonFirstC2W = new THREE.Matrix4(); |
| jsonFirstC2W.compose(jsonFirstPosition, jsonFirstQuaternion, new THREE.Vector3(1, 1, 1)); |
| const currentC2W = new THREE.Matrix4(); |
| currentC2W.compose(cam.position, cam.quaternion, new THREE.Vector3(1, 1, 1)); |
| const refW2C = jsonFirstC2W.clone().invert(); |
| const relativeTransform = refW2C.clone().multiply(currentC2W); |
| const fixedC2W = new THREE.Matrix4(); |
| fixedC2W.compose(new THREE.Vector3(0, 0, 0), new THREE.Quaternion(0, 0, 0, 1), new THREE.Vector3(1, 1, 1)); |
| const newTransform = fixedC2W.clone().multiply(relativeTransform); |
| const newPosition = new THREE.Vector3(); |
| const newQuaternion = new THREE.Quaternion(); |
| const newScale = new THREE.Vector3(); |
| newTransform.decompose(newPosition, newQuaternion, newScale); |
| cam.position.copy(newPosition); |
| cam.quaternion.copy(newQuaternion); |
| } |
| |
| cam.fov = fov; |
| cam.aspect = aspect; |
| cam.updateProjectionMatrix(); |
| |
| const cameraSplat = createCameraSplat(cam); |
| cameraSplats.push(cameraSplat); |
| cameraParams.push({ |
| position: cam.position.clone(), |
| quaternion: cam.quaternion.clone(), |
| fov: cam.fov, |
| aspect: cam.aspect, |
| }); |
| scene.add(cameraSplat); |
| }); |
| } |
| |
| if (!loadTrajectoryOnly && Array.isArray(resolution) && resolution.length === 3) { |
| guiOptions.Resolution = `${resolution[0]}x${resolution[1]}x${resolution[2]}`; |
| } |
| |
| if (loadTrajectoryOnly) { |
| updateStatus(`Trajectory loaded: ${jsonData.cameras ? jsonData.cameras.length : 0} cameras`, cameraParams.length); |
| } else { |
| updateStatus(`JSON loaded: ${jsonData.cameras ? jsonData.cameras.length : 0} cameras`, cameraParams.length); |
| } |
| } catch (error) { |
| console.error("JSON data processing error:", error); |
| } |
| } |
| |
| async function renderExamples() { |
| const grid = document.getElementById('examples-grid'); |
| if (!grid) return; |
| grid.innerHTML = ''; |
| const items = []; |
| for (const path of EXAMPLE_FILES) { |
| try { |
| const resp = await fetch(path); |
| if (!resp.ok) continue; |
| const data = await resp.json(); |
| const thumb = data.image_prompt || data.imagePrompt || null; |
| items.push({ path, data, thumb }); |
| } catch (_) { } |
| } |
| items.slice(0, 10).forEach((item, idx) => { |
| const div = document.createElement('div'); |
| div.className = 'example-item'; |
| div.title = item.path; |
| if (item.thumb) { |
| const img = document.createElement('img'); |
| img.src = item.thumb; |
| div.appendChild(img); |
| } else { |
| const label = document.createElement('div'); |
| label.className = 'label'; |
| label.textContent = `Example ${idx + 1}`; |
| div.appendChild(label); |
| } |
| div.addEventListener('click', () => { |
| |
| processJsonLoad(item.data, false); |
| }); |
| grid.appendChild(div); |
| }); |
| } |
| |
| |
| function initializeApp() { |
| try { |
| |
| console.log('Initializing app...'); |
| console.log('Center panel:', document.querySelector('.center-panel')); |
| console.log('GUI container:', document.getElementById('gui-container')); |
| console.log('Right panel:', document.querySelector('.right-panel')); |
| |
| initializeRenderer(); |
| initializeGUI(); |
| console.log('App initialization complete'); |
| } catch (error) { |
| console.error('App initialization failed:', error); |
| } |
| } |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', () => { initializeApp(); renderExamples(); }); |
| } else { |
| initializeApp(); |
| renderExamples(); |
| } |
| |
| |
| |
| |
| |
| |
| function interpolateTwoCameras(startCamera, endCamera, _t) { |
| const interpolatedCamera = new THREE.PerspectiveCamera(startCamera.fov, startCamera.aspect); |
| |
| |
| if (_t < 1e-6) { |
| interpolatedCamera.position.copy(startCamera.position); |
| interpolatedCamera.quaternion.copy(startCamera.quaternion); |
| } |
| |
| else if (_t > 1 - 1e-6) { |
| interpolatedCamera.position.copy(endCamera.position); |
| interpolatedCamera.quaternion.copy(endCamera.quaternion); |
| } |
| |
| else { |
| interpolatedCamera.position.copy(startCamera.position).lerp(endCamera.position, _t); |
| interpolatedCamera.quaternion.copy(startCamera.quaternion).slerp(endCamera.quaternion, _t); |
| } |
| |
| return interpolatedCamera; |
| } |
| |
| function interpolateCameras(cameras, M) { |
| const interpolatedCameras = []; |
| |
| if (cameras.length === 0) { |
| return interpolatedCameras; |
| } |
| |
| if (cameras.length === 1) { |
| |
| for (let i = 0; i < M; i++) { |
| interpolatedCameras.push(cameras[0]); |
| } |
| return interpolatedCameras; |
| } |
| |
| for (let i = 0; i < M; i++) { |
| const t = i / (M - 1); |
| const startIndex = Math.min(Math.floor(t * (cameras.length - 1)), cameras.length - 2); |
| const endIndex = startIndex + 1; |
| const startCamera = cameras[startIndex]; |
| const endCamera = cameras[endIndex]; |
| const _t = t * (cameras.length - 1) - startIndex; |
| const interpolatedCamera = interpolateTwoCameras(startCamera, endCamera, _t); |
| interpolatedCameras.push(interpolatedCamera); |
| } |
| return interpolatedCameras; |
| } |
| |
| |
| function syncGuiPromptControls() { |
| try { |
| if (window.inputTextPromptController) { |
| window.inputTextPromptController.setValue(guiOptions.inputTextPrompt); |
| if (typeof window.inputTextPromptController.updateDisplay === 'function') { |
| window.inputTextPromptController.updateDisplay(); |
| } |
| } |
| if (window.imageIndexController) { |
| window.imageIndexController.setValue(guiOptions.imageIndex); |
| if (typeof window.imageIndexController.updateDisplay === 'function') { |
| window.imageIndexController.updateDisplay(); |
| } |
| } |
| } catch (e) { |
| console.debug('syncGuiPromptControls error:', e); |
| } |
| |
| requestAnimationFrame(() => { |
| try { |
| if (window.inputTextPromptController && typeof window.inputTextPromptController.updateDisplay === 'function') { |
| window.inputTextPromptController.updateDisplay(); |
| } |
| if (window.imageIndexController && typeof window.imageIndexController.updateDisplay === 'function') { |
| window.imageIndexController.updateDisplay(); |
| } |
| } catch (_) {} |
| }); |
| } |
| |
| |
| function createCubeSplat(size = 0.1, pointColor = [1, 1, 1]) { |
| const cubeSplat = new SplatMesh({ |
| constructSplats: (splats) => { |
| const NUM_SPLATS_PER_EDGE = 1000; |
| const scales = new THREE.Vector3().setScalar(0.002); |
| const quaternion = new THREE.Quaternion(); |
| const opacity = 1; |
| const color = new THREE.Color(...pointColor); |
| |
| |
| const halfSize = size / 2; |
| const vertices = [ |
| new THREE.Vector3(-halfSize, -halfSize, -halfSize), |
| new THREE.Vector3(halfSize, -halfSize, -halfSize), |
| new THREE.Vector3(halfSize, halfSize, -halfSize), |
| new THREE.Vector3(-halfSize, halfSize, -halfSize), |
| new THREE.Vector3(-halfSize, -halfSize, halfSize), |
| new THREE.Vector3(halfSize, -halfSize, halfSize), |
| new THREE.Vector3(halfSize, halfSize, halfSize), |
| new THREE.Vector3(-halfSize, halfSize, halfSize), |
| ]; |
| |
| |
| const edges = [ |
| [0, 1], [1, 2], [2, 3], [3, 0], |
| [4, 5], [5, 6], [6, 7], [7, 4], |
| [0, 4], [1, 5], [2, 6], [3, 7], |
| ]; |
| |
| |
| for (let i = 0; i < edges.length; i++) { |
| const start = vertices[edges[i][0]]; |
| const end = vertices[edges[i][1]]; |
| for (let j = 0; j < NUM_SPLATS_PER_EDGE; j++) { |
| const point = new THREE.Vector3().lerpVectors(start, end, j / NUM_SPLATS_PER_EDGE); |
| splats.pushSplat(point, scales, quaternion, opacity, color); |
| } |
| } |
| }, |
| }); |
| return cubeSplat; |
| } |
| |
| |
| function createCameraSplat(camera, pointColor = [1, 1, 1]) { |
| const cameraSplat = new SplatMesh({ |
| constructSplats: (splats) => { |
| const NUM_SPLATS_PER_EDGE = 1000; |
| const LENGTH_PER_EDGE = 0.1; |
| const center = new THREE.Vector3(); |
| const scales = new THREE.Vector3().setScalar(0.001); |
| const quaternion = new THREE.Quaternion(); |
| const opacity = 1; |
| const color = new THREE.Color(...pointColor); |
| |
| const H = 1000; |
| const W = 1000 * camera.aspect; |
| const fx = 0.5 * H / Math.tan(0.5 * camera.fov * Math.PI / 180); |
| const fy = 0.5 * H / Math.tan(0.5 * camera.fov * Math.PI / 180); |
| |
| const xt = (0 - W / 2 + 0.5) / fy; |
| const xb = (W - W / 2 + 0.5) / fy; |
| const yl = - (0 - H / 2 + 0.5) / fx; |
| const yr = - (H - H / 2 + 0.5) / fx; |
| |
| const lt = new THREE.Vector3(xt * LENGTH_PER_EDGE, yl * LENGTH_PER_EDGE, -1 * LENGTH_PER_EDGE); |
| const rt = new THREE.Vector3(xt * LENGTH_PER_EDGE, yr * LENGTH_PER_EDGE, -1 * LENGTH_PER_EDGE); |
| const lb = new THREE.Vector3(xb * LENGTH_PER_EDGE, yl * LENGTH_PER_EDGE, -1 * LENGTH_PER_EDGE); |
| const rb = new THREE.Vector3(xb * LENGTH_PER_EDGE, yr * LENGTH_PER_EDGE, -1 * LENGTH_PER_EDGE); |
| |
| const lines = [ |
| [center, lt], [center, rt], [center, lb], [center, rb], |
| [lt, rt], [lt, lb], [rt, rb], [lb, rb], |
| ]; |
| |
| for (let i = 0; i < lines.length; i++) { |
| for (let j = 0; j < NUM_SPLATS_PER_EDGE; j++) { |
| const point = new THREE.Vector3().lerpVectors(lines[i][0], lines[i][1], j / NUM_SPLATS_PER_EDGE); |
| splats.pushSplat(point, scales, quaternion, opacity, color); |
| } |
| } |
| }, |
| }); |
| cameraSplat.quaternion.copy(camera.quaternion); |
| cameraSplat.position.copy(camera.position); |
| return cameraSplat; |
| } |
| |
| |
| function generateCameraTrajectory(trajectoryType) { |
| if (trajectoryType === "Manual") { |
| updateStatus('Manual mode: Use Space to record cameras manually', cameraParams.length); |
| return; |
| } |
| |
| |
| if (!fixGenerationFOV) { |
| updateStatus('Error: Please fix FOV first before generating trajectory', cameraParams.length); |
| return; |
| } |
| |
| |
| let referenceCamera; |
| if (cameraParams.length > 0) { |
| |
| const lastCamera = cameraParams[cameraParams.length - 1]; |
| referenceCamera = new THREE.PerspectiveCamera(guiOptions.FOV, camera.aspect); |
| referenceCamera.position.copy(lastCamera.position); |
| referenceCamera.quaternion.copy(lastCamera.quaternion); |
| referenceCamera.updateProjectionMatrix(); |
| } else { |
| |
| referenceCamera = new THREE.PerspectiveCamera(guiOptions.FOV, camera.aspect); |
| referenceCamera.position.set(0, 0, 0); |
| referenceCamera.quaternion.set(0, 0, 0, 1); |
| referenceCamera.updateProjectionMatrix(); |
| } |
| |
| |
| |
| let orbitTarget = null; |
| let orbitStartCamera = null; |
| if (trajectoryType.includes("Orbit") && cameraParams.length > 0) { |
| |
| orbitStartCamera = cameraParams[cameraParams.length - 1]; |
| orbitTarget = orbitStartCamera.position.clone().add( |
| new THREE.Vector3(0, 0, -1).applyQuaternion(orbitStartCamera.quaternion) |
| ); |
| console.log("Orbit target calculated from last camera:", orbitStartCamera.position, "->", orbitTarget); |
| } else if (trajectoryType.includes("Orbit")) { |
| |
| orbitStartCamera = referenceCamera; |
| orbitTarget = referenceCamera.position.clone().add( |
| new THREE.Vector3(0, 0, -1).applyQuaternion(referenceCamera.quaternion) |
| ); |
| console.log("Orbit target calculated from current camera:", referenceCamera.position, "->", orbitTarget); |
| } |
| |
| const cameras = []; |
| const stepSize = 0.5; |
| const totalOrbitAngle = 15 * Math.PI / 180; |
| |
| |
| let numCameras = 1; |
| if (trajectoryType.includes("Orbit")) { |
| numCameras = 1; |
| console.log(`Generating ${numCameras} orbit camera with total angle ${totalOrbitAngle * 180 / Math.PI}°`); |
| } |
| |
| for (let i = 1; i <= numCameras; i++) { |
| const newCamera = new THREE.PerspectiveCamera(guiOptions.FOV, camera.aspect); |
| let position, quaternion; |
| |
| switch (trajectoryType) { |
| case "Move Forward": |
| position = referenceCamera.position.clone(); |
| position.z -= stepSize; |
| quaternion = referenceCamera.quaternion.clone(); |
| break; |
| |
| case "Move Backward": |
| position = referenceCamera.position.clone(); |
| position.z += stepSize; |
| quaternion = referenceCamera.quaternion.clone(); |
| break; |
| |
| case "Move Left": |
| position = referenceCamera.position.clone(); |
| position.x -= stepSize; |
| quaternion = referenceCamera.quaternion.clone(); |
| break; |
| |
| case "Move Right": |
| position = referenceCamera.position.clone(); |
| position.x += stepSize; |
| quaternion = referenceCamera.quaternion.clone(); |
| break; |
| |
| case "Orbit Left 15°": |
| const radius = 1.0; |
| |
| const angle = -totalOrbitAngle; |
| |
| console.log(`Camera ${i}: angle=${angle * 180 / Math.PI}° (Left)`); |
| |
| |
| const localOrbitPos = new THREE.Vector3( |
| Math.sin(angle) * radius, |
| 0, |
| Math.cos(angle) * radius |
| ); |
| |
| |
| const worldOrbitPos = localOrbitPos.applyQuaternion(orbitStartCamera.quaternion); |
| |
| |
| position = orbitTarget.clone().add(worldOrbitPos); |
| |
| console.log(`Orbit Left camera ${i}: localPos=`, localOrbitPos, 'worldPos=', worldOrbitPos, 'finalPos=', position); |
| |
| |
| const lookDirection = orbitTarget.clone().sub(position).normalize(); |
| quaternion = new THREE.Quaternion().setFromUnitVectors( |
| new THREE.Vector3(0, 0, -1), |
| lookDirection |
| ); |
| |
| console.log(`Orbit Left camera ${i}: quaternion=`, quaternion); |
| break; |
| |
| case "Orbit Right 15°": |
| const radiusRight = 1.0; |
| |
| const angleRight = totalOrbitAngle; |
| |
| console.log(`Camera ${i}: angle=${angleRight * 180 / Math.PI}° (Right)`); |
| |
| |
| const localOrbitPosRight = new THREE.Vector3( |
| Math.sin(angleRight) * radiusRight, |
| 0, |
| Math.cos(angleRight) * radiusRight |
| ); |
| |
| |
| const worldOrbitPosRight = localOrbitPosRight.applyQuaternion(orbitStartCamera.quaternion); |
| |
| |
| position = orbitTarget.clone().add(worldOrbitPosRight); |
| |
| console.log(`Orbit Right camera ${i}: localPos=`, localOrbitPosRight, 'worldPos=', worldOrbitPosRight, 'finalPos=', position); |
| |
| |
| const lookDirectionRight = orbitTarget.clone().sub(position).normalize(); |
| quaternion = new THREE.Quaternion().setFromUnitVectors( |
| new THREE.Vector3(0, 0, -1), |
| lookDirectionRight |
| ); |
| |
| console.log(`Orbit Right camera ${i}: quaternion=`, quaternion); |
| break; |
| |
| |
| default: |
| position = referenceCamera.position.clone(); |
| quaternion = referenceCamera.quaternion.clone(); |
| } |
| |
| newCamera.position.copy(position); |
| newCamera.quaternion.copy(quaternion); |
| newCamera.updateProjectionMatrix(); |
| cameras.push(newCamera); |
| } |
| |
| |
| cameras.forEach(cam => { |
| const cameraSplat = createCameraSplat(cam); |
| cameraSplats.push(cameraSplat); |
| cameraParams.push({ |
| position: cam.position.clone(), |
| quaternion: cam.quaternion.clone(), |
| fov: cam.fov, |
| aspect: cam.aspect, |
| }); |
| scene.add(cameraSplat); |
| }); |
| |
| updateStatus(`Added ${cameras.length} cameras using ${trajectoryType} trajectory`, cameraParams.length); |
| console.log(`Added ${cameras.length} cameras using ${trajectoryType} trajectory`); |
| } |
| |
| |
| |
| |
| |
| |
| function initializeGUI() { |
| const guiContainer = document.getElementById('gui-container'); |
| if (guiContainer && !gui) { |
| |
| guiContainer.innerHTML = ''; |
| |
| gui = new GUI({ title: "FlashWorld Controls", container: guiContainer }); |
| console.log('GUI initialized in container:', guiContainer); |
| |
| |
| const step1Folder = gui.addFolder('1. Configure Settings'); |
| step1Folder.add(guiOptions, "BackendAddress").name("Backend Address"); |
| step1Folder.add(guiOptions, "HF_TOKEN").name("HF Token"); |
| |
| |
| const fovController = step1Folder.add(guiOptions, "FOV", 0, 120, 1).name("FOV").onChange((value) => { |
| camera.fov = value; |
| camera.updateProjectionMatrix(); |
| }); |
| const resolutionController = step1Folder.add(guiOptions, "Resolution", supportedResolutions.map( |
| r => `${r.frame}x${r.height}x${r.width}` |
| )).name("Resolution (NxHxW)").onChange((value) => { |
| updateCanvasSize(); |
| }); |
| |
| window.fovController = fovController; |
| window.resolutionController = resolutionController; |
| |
| |
| const fixGenerationFOVController = step1Folder.add(guiOptions, "fixGenerationFOV").name("Fix Configuration"); |
| step1Folder.open(); |
| |
| |
| const step2Folder = gui.addFolder('2. Set Up Camera Path'); |
| |
| |
| const trajectoryFolder = step2Folder.addFolder('Camera Trajectory'); |
| |
| |
| const trajectoryModeController = trajectoryFolder.add(guiOptions, "trajectoryMode", [ |
| "Manual", |
| "Template", |
| "JSON" |
| ]).name("Trajectory Mode"); |
| |
| |
| const templateTypeController = trajectoryFolder.add(guiOptions, "templateType", [ |
| "Move Forward", |
| "Move Backward", |
| "Move Left", |
| "Move Right", |
| "Orbit Left 15°", |
| "Orbit Right 15°" |
| ]).name("Template Type"); |
| |
| |
| const generateTrajectoryController = trajectoryFolder.add(guiOptions, "generateTrajectory").name("Generate Trajectory"); |
| |
| |
| const loadTrajectoryController = trajectoryFolder.add(guiOptions, "LoadTrajectoryFromJson").name("Load from JSON"); |
| const saveTrajectoryController = trajectoryFolder.add(guiOptions, "saveTrajectoryToJson").name("Save Trajectory"); |
| |
| |
| saveTrajectoryController.disable(); |
| |
| |
| const clearAllCamerasController = trajectoryFolder.add(guiOptions, "clearAllCameras").name("Clear All Cameras"); |
| |
| |
| templateTypeController.disable(); |
| generateTrajectoryController.disable(); |
| loadTrajectoryController.disable(); |
| |
| |
| trajectoryModeController.onChange((value) => { |
| if (value === "Manual") { |
| templateTypeController.disable(); |
| generateTrajectoryController.disable(); |
| loadTrajectoryController.disable(); |
| } else if (value === "Template") { |
| templateTypeController.enable(); |
| if (fixGenerationFOV) { |
| generateTrajectoryController.enable(); |
| } else { |
| generateTrajectoryController.disable(); |
| } |
| loadTrajectoryController.disable(); |
| } else if (value === "JSON") { |
| templateTypeController.disable(); |
| generateTrajectoryController.disable(); |
| if (fixGenerationFOV) { |
| loadTrajectoryController.enable(); |
| } else { |
| loadTrajectoryController.disable(); |
| } |
| } |
| }); |
| |
| |
| const originalFixFOV = guiOptions.fixGenerationFOV; |
| guiOptions.fixGenerationFOV = () => { |
| originalFixFOV(); |
| |
| |
| fovController.disable(); |
| resolutionController.disable(); |
| |
| |
| if (guiOptions.trajectoryMode === "Template") { |
| generateTrajectoryController.enable(); |
| } else if (guiOptions.trajectoryMode === "JSON") { |
| loadTrajectoryController.enable(); |
| } |
| updateStatus('Configuration fixed. You can now generate camera trajectory.', cameraParams.length); |
| }; |
| |
| trajectoryFolder.open(); |
| |
| step2Folder.add(guiOptions, "VisualizeCameraSplats").name("Visualize Cameras").onChange((value) => { |
| cameraSplats.forEach(cameraSplat => { |
| cameraSplat.opacity = value ? 1 : 0; |
| }); |
| }); |
| step2Folder.add(guiOptions, "VisualizeInterpolatedCameras").name("Visualize Interpolated Cameras").onChange((value) => { |
| interpolatedCamerasSplats.forEach(interpolatedCameraSplat => { |
| interpolatedCameraSplat.opacity = value ? 1 : 0; |
| }); |
| }); |
| |
| |
| window.fixGenerationFOVController = fixGenerationFOVController; |
| window.saveTrajectoryController = saveTrajectoryController; |
| |
| |
| const step3Folder = gui.addFolder('3. Add Scene Prompts'); |
| step3Folder.add(guiOptions, "inputImagePrompt").name("Input Image Prompt"); |
| const inputTextPromptController = step3Folder.add(guiOptions, "inputTextPrompt").name("Input Text Prompt"); |
| const imageIndexController = step3Folder.add(guiOptions, "imageIndex", 0, 24, 1).name("Image Index"); |
| |
| window.inputTextPromptController = inputTextPromptController; |
| window.imageIndexController = imageIndexController; |
| |
| |
| |
| const step4Folder = gui.addFolder('4. Generate Scene'); |
| step4Folder.add(guiOptions, "generate").name("Generate!"); |
| const downloadController = step4Folder.add(guiOptions, "downloadGeneratedFile").name("Download SPZ File"); |
| downloadController.disable(); |
| step4Folder.open(); |
| |
| |
| window.downloadController = downloadController; |
| |
| |
| const step5Folder = gui.addFolder('5. Trajectory Playback'); |
| step5Folder.add(guiOptions, 'playbackT', 0, 1, 0.001).name('Scrub (0-1)').onChange((value) => { |
| |
| if (!userCameraState) { |
| userCameraState = { |
| position: camera.position.clone(), |
| quaternion: camera.quaternion.clone(), |
| fov: camera.fov |
| }; |
| } |
| setCameraByScrub(value); |
| updateStatus(`Scrubbing trajectory: t=${value.toFixed(3)}`, cameraParams.length); |
| }); |
| step5Folder.open(); |
| |
| |
| const step6Folder = gui.addFolder('6. JSON (All-in-one)'); |
| step6Folder.add(guiOptions, "LoadAllFromJson").name("Load All from JSON"); |
| step6Folder.add(guiOptions, "SaveAllToJson").name("Save All to JSON"); |
| step6Folder.open(); |
| |
| } |
| } |
| |
| |
| |
| |
| |
| const fileInput = document.querySelector("#file-input"); |
| fileInput.onchange = (event) => { |
| const files = event.target.files; |
| if (!files || files.length === 0) return; |
| Array.from(files).forEach(file => { |
| const reader = new FileReader(); |
| reader.onload = function(e) { |
| console.log("Loaded image:", file.name, e.target.result); |
| |
| |
| let resolutionStr = guiOptions.Resolution; |
| let [n, h, w] = resolutionStr.split('x').map(Number); |
| |
| |
| const img = new Image(); |
| img.onload = function() { |
| window.inputImageResolution = { width: img.width, height: img.height }; |
| console.log("Input image resolution:", window.inputImageResolution); |
| |
| |
| let scaleH = h / img.height; |
| let scaleW = w / img.width; |
| let scale = Math.max(scaleH, scaleW); |
| let newW = Math.round(w / scale); |
| let newH = Math.round(h / scale); |
| let sx = Math.floor((img.width - newW) / 2); |
| let sy = Math.floor((img.height - newH) / 2); |
| |
| |
| const canvas = document.createElement('canvas'); |
| canvas.width = w; |
| canvas.height = h; |
| const ctx = canvas.getContext('2d'); |
| ctx.drawImage( |
| img, |
| sx, sy, newW, newH, |
| 0, 0, w, h |
| ); |
| |
| inputImageBase64 = canvas.toDataURL('image/png'); |
| |
| const previewArea = document.getElementById('image-preview-area'); |
| const previewImg = document.getElementById('preview-img'); |
| if (previewImg && previewArea) { |
| previewImg.src = inputImageBase64; |
| previewArea.style.display = 'block'; |
| } |
| |
| window.inputImageResolution = { width: w, height: h }; |
| console.log("Cropped and resized image to:", w, h); |
| }; |
| img.src = e.target.result; |
| }; |
| reader.readAsDataURL(file); |
| }); |
| |
| }; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const jsonInput = document.querySelector("#json-input"); |
| jsonInput.onchange = (event) => { |
| const files = event.target.files; |
| if (!files || files.length === 0) return; |
| const file = files[0]; |
| const reader = new FileReader(); |
| reader.onload = function(e) { |
| let jsonData; |
| try { |
| jsonData = JSON.parse(e.target.result); |
| } catch (error) { |
| console.error("JSON parsing error:", error); |
| return; |
| } |
| const loadTrajectoryOnly = !!window.loadTrajectoryOnly; |
| window.loadTrajectoryOnly = false; |
| processJsonLoad(jsonData, loadTrajectoryOnly); |
| }; |
| reader.readAsText(file); |
| }; |
| |
| |
| |
| |
| document.addEventListener('keypress', (event) => { |
| if (event.code === 'Space') { |
| if (!fixGenerationFOV) { |
| updateStatus('Please fix Generation FOV first', cameraParams.length); |
| return; |
| } |
| |
| const new_camera = camera.clone(); |
| new_camera.fov = guiOptions.FOV; |
| new_camera.aspect = guiOptions.Resolution.split('x')[2] / guiOptions.Resolution.split('x')[1]; |
| new_camera.updateProjectionMatrix(); |
| |
| const cameraSplat = createCameraSplat(new_camera); |
| cameraSplats.push(cameraSplat); |
| cameraParams.push({ |
| position: new_camera.position.clone(), |
| quaternion: new_camera.quaternion.clone(), |
| fov: new_camera.fov, |
| aspect: new_camera.aspect, |
| }); |
| scene.add(cameraSplat); |
| |
| updateStatus(`Camera ${cameraParams.length} recorded. Press Space for more or Generate!`, cameraParams.length); |
| |
| console.log(new_camera.getFocalLength()); |
| } |
| }); |
| |
| |
| |
| |
| |
| |
| updateStatus('FlashWorld initialized. Configure settings to begin.', 0); |
| |
| |
| let instructionSplat = createCubeSplat(0.25, [1, 1, 1]); |
| instructionSplat.position.set(0, 0, -1); |
| scene.add(instructionSplat); |
| console.log('Cube splat added to scene'); |
| |
| |
| window.addEventListener('resize', () => { |
| console.log('Window resized, updating canvas...'); |
| |
| updateCanvasSize(); |
| }); |
| |
| |
| |
| |
| let lastTime = null; |
| |
| renderer.setAnimationLoop(function animate(time) { |
| const deltaTime = time - (lastTime || time); |
| lastTime = time; |
| |
| |
| if (instructionSplat) { |
| |
| instructionSplat.rotation.y += deltaTime / 5000; |
| instructionSplat.rotation.z += deltaTime / 6000; |
| } |
| |
| |
| |
| controls.update(camera); |
| renderer.render(scene, camera); |
| |
| }); |
| |
| </script> |
| </body> |
| </html> |