|
|
<!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/[email protected]/+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> |