use gradio server

#2
by akhaliq HF Staff - opened
Files changed (1) hide show
  1. app.py +1466 -7
app.py CHANGED
@@ -1,28 +1,1487 @@
1
  from __future__ import annotations
2
 
3
  import os
 
 
 
 
4
 
5
  os.environ.setdefault("GRADIO_SSR_MODE", "false")
6
 
7
  import spaces # noqa: F401 - must patch torch before model modules are imported
8
 
9
- from src.demo.hf_runtime import InfiniSplatRuntime
10
- from src.demo.hf_ui import APP_CSS, APP_THEME, OUTPUT_ROOT, create_demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  runtime = InfiniSplatRuntime.load()
14
- demo = create_demo(runtime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  if __name__ == "__main__":
18
- demo.launch(
19
  server_name="0.0.0.0",
20
  server_port=int(os.environ.get("PORT", "7860")),
21
  allowed_paths=[str(OUTPUT_ROOT)],
22
  max_file_size="20mb",
23
- show_error=False,
24
  ssr_mode=False,
25
  footer_links=[],
26
- theme=APP_THEME,
27
- css=APP_CSS,
28
  )
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import shutil
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
 
9
  os.environ.setdefault("GRADIO_SSR_MODE", "false")
10
 
11
  import spaces # noqa: F401 - must patch torch before model modules are imported
12
 
13
+ from fastapi.responses import HTMLResponse
14
+ from gradio import Server
15
+ from gradio.data_classes import FileData
16
+
17
+ from urllib.parse import quote, urlparse
18
+
19
+ from src.demo.hf_runtime import (
20
+ InfiniSplatRuntime,
21
+ ViewerTemplate,
22
+ export_browser_viewer,
23
+ export_filtered_gaussian_ply,
24
+ export_standalone_viewer,
25
+ prepare_viewer_template,
26
+ )
27
+
28
+ OUTPUT_ROOT = Path(os.environ.get("GRADIO_TEMP_DIR", "/tmp/gradio")) / "infinisplat"
29
+ OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
30
+ GPU_DURATION_SECONDS = 6
31
 
32
 
33
  runtime = InfiniSplatRuntime.load()
34
+ viewer_template = prepare_viewer_template(OUTPUT_ROOT)
35
+
36
+
37
+ app = Server(
38
+ title="InfiniSplat",
39
+ description="Implicit Gaussian decoding for large-baseline monocular view synthesis.",
40
+ )
41
+
42
+
43
+ def _log(stage: str, **metrics) -> None:
44
+ import json
45
+
46
+ print(f"INFINISPLAT_TIMING {json.dumps({'stage': stage, **metrics}, sort_keys=True)}", flush=True)
47
+
48
+
49
+ def _file_url(path: Path) -> str:
50
+ """Return the public Gradio file URL for a server-side path."""
51
+ return f"/gradio_api/file={quote(str(path.resolve()))}"
52
+
53
+
54
+ def _resolve_file_path(value: FileData | str) -> Path:
55
+ """Resolve a FileData input (or URL/path string) back to a local file path."""
56
+ if isinstance(value, str):
57
+ path_str = value
58
+ elif isinstance(value, dict):
59
+ path_str = value.get("path") or value.get("url") or ""
60
+ else:
61
+ path_str = str(value)
62
+ if not path_str:
63
+ raise ValueError(f"Cannot resolve file path from input: {value!r}")
64
+ # Strip Gradio's /gradio_api/file= URL prefix to get the real path
65
+ if path_str.startswith("/gradio_api/file="):
66
+ decoded = urlparse(path_str).path[len("/gradio_api/file="):]
67
+ return Path(decoded)
68
+ # Already a server-local path
69
+ if path_str.startswith("/"):
70
+ return Path(path_str)
71
+ return Path(path_str)
72
+
73
+
74
+ @app.api(name="reconstruct", queue=True, concurrency_limit=1, concurrency_id="gpu")
75
+ @spaces.GPU(duration=GPU_DURATION_SECONDS)
76
+ def reconstruct(image_path: FileData) -> dict:
77
+ """Run GPU reconstruction and return a public URL for the artifact."""
78
+ started = time.perf_counter()
79
+ request_dir = OUTPUT_ROOT / uuid.uuid4().hex
80
+ request_dir.mkdir(parents=True, exist_ok=True)
81
+ artifact = runtime.infer_to_artifact(
82
+ image_path=_resolve_file_path(image_path),
83
+ artifact_path=request_dir / "gaussians.pt",
84
+ )
85
+ _log(
86
+ "gpu_reconstruct",
87
+ request=request_dir.name,
88
+ seconds=round(time.perf_counter() - started, 3),
89
+ bytes=artifact.stat().st_size,
90
+ )
91
+ return {"url": _file_url(artifact), "size": artifact.stat().st_size}
92
+
93
+
94
+ @app.api(name="export_ply", queue=True, concurrency_limit=2)
95
+ def export_ply(artifact_url: str) -> dict:
96
+ """Filter one PLY artifact and return its public URL."""
97
+ started = time.perf_counter()
98
+ internal = _resolve_file_path(artifact_url)
99
+ scene_ply = export_filtered_gaussian_ply(
100
+ artifact_path=internal,
101
+ output_dir=internal.parent,
102
+ )
103
+ internal.unlink(missing_ok=True)
104
+ _log(
105
+ "ply_export",
106
+ request=scene_ply.parent.name,
107
+ seconds=round(time.perf_counter() - started, 3),
108
+ bytes=scene_ply.stat().st_size,
109
+ )
110
+ return {"url": _file_url(scene_ply), "size": scene_ply.stat().st_size}
111
+
112
+
113
+ @app.api(name="export_viewer", queue=True, concurrency_limit=2)
114
+ def export_viewer(scene_ply_url: str) -> dict:
115
+ """Build the browser viewer and return its iframe-ready HTML URL."""
116
+ started = time.perf_counter()
117
+ scene_ply_path = _resolve_file_path(scene_ply_url)
118
+ exported = export_browser_viewer(
119
+ scene_ply=scene_ply_path,
120
+ viewer_template=viewer_template,
121
+ )
122
+ _log(
123
+ "browser_viewer",
124
+ request=exported.viewer_html.parent.name,
125
+ seconds=round(time.perf_counter() - started, 3),
126
+ sog_bytes=exported.scene_sog.stat().st_size,
127
+ viewer_html_bytes=exported.viewer_html.stat().st_size,
128
+ )
129
+ return {"url": _file_url(exported.viewer_html), "size": exported.viewer_html.stat().st_size}
130
+
131
+
132
+ @app.api(name="export_html", queue=True, concurrency_limit=2)
133
+ def export_html(viewer_html_url: str) -> dict:
134
+ """Bundle a standalone HTML viewer and return its public URL."""
135
+ started = time.perf_counter()
136
+ viewer_html_path = _resolve_file_path(viewer_html_url)
137
+ standalone = export_standalone_viewer(
138
+ viewer_html=viewer_html_path,
139
+ viewer_template=viewer_template,
140
+ )
141
+ _log(
142
+ "standalone_html",
143
+ request=standalone.parent.name,
144
+ seconds=round(time.perf_counter() - started, 3),
145
+ bytes=standalone.stat().st_size,
146
+ )
147
+ return {"url": _file_url(standalone), "size": standalone.stat().st_size}
148
+
149
+
150
+ @app.api(name="viewer_html", queue=False)
151
+ def viewer_html() -> dict:
152
+ """Serve the preloaded viewer template HTML for fast first paint."""
153
+ return {"url": _file_url(viewer_template.viewer_html)}
154
+
155
+
156
+ INDEX_HTML = r"""<!doctype html>
157
+ <html lang="en">
158
+ <head>
159
+ <meta charset="utf-8" />
160
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
161
+ <title>InfiniSplat — Studio</title>
162
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
163
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
164
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
165
+ <style>
166
+ /* ─────────────── Design tokens ─────────────── */
167
+ :root {
168
+ color-scheme: dark;
169
+ --bg: #0c1014;
170
+ --bg-grid: #0e1217;
171
+ --surface: #151a21;
172
+ --surface-2: #1a2028;
173
+ --surface-3: #222932;
174
+ --panel: #161b22;
175
+ --panel-hi: #1d242e;
176
+ --border: #232b35;
177
+ --border-strong: #34404e;
178
+ --text: #e7eaed;
179
+ --text-2: #b6bec8;
180
+ --muted: #8a95a3;
181
+ --muted-2: #5c6675;
182
+ --accent: #ffb547;
183
+ --accent-hi: #ffc870;
184
+ --accent-soft: rgba(255, 181, 71, 0.14);
185
+ --accent-glow: rgba(255, 181, 71, 0.28);
186
+ --success: #34d399;
187
+ --success-soft: rgba(52, 211, 153, 0.14);
188
+ --danger: #f87171;
189
+ --warn: #fbbf24;
190
+ --info: #60a5fa;
191
+ --viewer: #0a0d10;
192
+ --radius: 6px;
193
+ --radius-sm: 4px;
194
+ --shadow: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.35);
195
+ --grid-line: rgba(255,255,255,0.02);
196
+ }
197
+ * { box-sizing: border-box; margin: 0; padding: 0; }
198
+ html, body { height: 100%; }
199
+ body {
200
+ background: var(--bg);
201
+ color: var(--text);
202
+ font-family: 'Inter', system-ui, sans-serif;
203
+ font-size: 13px;
204
+ line-height: 1.5;
205
+ -webkit-font-smoothing: antialiased;
206
+ overflow: hidden;
207
+ }
208
+ .mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: "tnum"; }
209
+
210
+ /* ─────────────── App shell ─────────────── */
211
+ .app {
212
+ display: grid;
213
+ grid-template-rows: 44px 1fr 24px;
214
+ height: 100vh;
215
+ background: var(--bg);
216
+ }
217
+
218
+ /* ─────────────── Toolbar ─────────────── */
219
+ .toolbar {
220
+ display: grid;
221
+ grid-template-columns: auto 1fr auto auto;
222
+ align-items: center;
223
+ gap: 16px;
224
+ padding: 0 12px;
225
+ background: var(--surface);
226
+ border-bottom: 1px solid var(--border);
227
+ user-select: none;
228
+ }
229
+ .tb-brand {
230
+ display: flex; align-items: center; gap: 10px;
231
+ padding-right: 14px; height: 100%;
232
+ border-right: 1px solid var(--border);
233
+ }
234
+ .tb-logo {
235
+ width: 22px; height: 22px;
236
+ display: grid; place-items: center;
237
+ background: var(--accent);
238
+ color: #0c1014;
239
+ border-radius: var(--radius-sm);
240
+ box-shadow: 0 0 12px var(--accent-glow);
241
+ }
242
+ .tb-logo svg { width: 14px; height: 14px; }
243
+ .tb-name {
244
+ font-weight: 800; font-size: 13px; letter-spacing: -0.01em;
245
+ }
246
+ .tb-name .accent { color: var(--accent); }
247
+ .tb-build {
248
+ font-family: 'JetBrains Mono', monospace;
249
+ font-size: 10px; color: var(--muted-2);
250
+ padding: 2px 6px; border: 1px solid var(--border);
251
+ border-radius: 3px; margin-left: 4px;
252
+ }
253
+ .tb-scene {
254
+ display: flex; align-items: center; gap: 8px;
255
+ min-width: 0;
256
+ }
257
+ .tb-scene-label { font-size: 11px; color: var(--muted); }
258
+ .tb-scene-name {
259
+ font-weight: 600; font-size: 12px;
260
+ padding: 4px 8px;
261
+ background: var(--surface-2);
262
+ border: 1px solid var(--border);
263
+ border-radius: var(--radius-sm);
264
+ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
265
+ }
266
+ .tb-actions { display: flex; align-items: center; gap: 6px; }
267
+ .tb-shortcut {
268
+ font-family: 'JetBrains Mono', monospace;
269
+ font-size: 10px; color: var(--muted-2);
270
+ padding: 1px 4px; border: 1px solid var(--border-strong);
271
+ border-radius: 3px; margin-left: 6px;
272
+ }
273
+ .icon-btn {
274
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
275
+ min-height: 28px; padding: 0 10px;
276
+ background: var(--surface-2);
277
+ border: 1px solid var(--border);
278
+ border-radius: var(--radius-sm);
279
+ color: var(--text-2);
280
+ font: 500 12px 'Inter', sans-serif;
281
+ text-decoration: none;
282
+ cursor: pointer;
283
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
284
+ }
285
+ .icon-btn:hover { border-color: var(--accent); color: var(--text); }
286
+ .icon-btn svg { width: 13px; height: 13px; }
287
+ .icon-btn.ghost { background: transparent; border-color: transparent; }
288
+ .icon-btn.ghost:hover { background: var(--surface-2); border-color: var(--border); }
289
+
290
+ /* Primary reconstruct button */
291
+ .reconstruct-btn {
292
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
293
+ min-height: 32px; padding: 0 16px;
294
+ background: var(--accent); color: #0c1014;
295
+ border: 0; border-radius: var(--radius-sm);
296
+ font: 700 12px 'Inter', sans-serif;
297
+ text-transform: uppercase; letter-spacing: 0.04em;
298
+ cursor: pointer;
299
+ box-shadow: 0 0 0 1px var(--accent), 0 0 16px var(--accent-glow);
300
+ transition: background 120ms ease, box-shadow 120ms ease;
301
+ }
302
+ .reconstruct-btn:hover { background: var(--accent-hi); }
303
+ .reconstruct-btn:active { transform: translateY(1px); }
304
+ .reconstruct-btn:disabled {
305
+ background: var(--surface-3); color: var(--muted-2);
306
+ box-shadow: 0 0 0 1px var(--border);
307
+ cursor: not-allowed;
308
+ }
309
+ .reconstruct-btn .spinner {
310
+ width: 12px; height: 12px;
311
+ border: 2px solid rgba(12,16,20,0.3);
312
+ border-top-color: #0c1014;
313
+ border-radius: 50%;
314
+ animation: spin 0.8s linear infinite;
315
+ }
316
+ @keyframes spin { to { transform: rotate(360deg); } }
317
+
318
+ /* ─────────────── Workspace ─────────────── */
319
+ .workspace {
320
+ display: grid;
321
+ grid-template-columns: 240px 1fr 280px;
322
+ gap: 1px;
323
+ background: var(--border);
324
+ min-height: 0;
325
+ overflow: hidden;
326
+ }
327
+ @media (max-width: 1100px) { .workspace { grid-template-columns: 220px 1fr 240px; } }
328
+ @media (max-width: 900px) {
329
+ .workspace { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
330
+ }
331
+
332
+ .col {
333
+ display: flex; flex-direction: column;
334
+ background: var(--bg);
335
+ min-height: 0; min-width: 0;
336
+ overflow: hidden;
337
+ }
338
+
339
+ /* ─────────────── Panel chrome ─────────────── */
340
+ .panel {
341
+ display: flex; flex-direction: column;
342
+ background: var(--panel);
343
+ min-height: 0; flex: 1 1 auto;
344
+ overflow: hidden;
345
+ }
346
+ .panel + .panel { border-top: 1px solid var(--border); }
347
+ .panel-head {
348
+ display: grid;
349
+ grid-template-columns: auto 1fr auto;
350
+ align-items: center;
351
+ gap: 10px;
352
+ padding: 0 12px;
353
+ min-height: 30px;
354
+ background: var(--surface);
355
+ border-bottom: 1px solid var(--border);
356
+ user-select: none;
357
+ }
358
+ .panel-head .bar {
359
+ width: 3px; height: 14px;
360
+ background: var(--accent);
361
+ border-radius: 1px;
362
+ }
363
+ .panel-head h3 {
364
+ font-size: 11px; font-weight: 700;
365
+ text-transform: uppercase; letter-spacing: 0.08em;
366
+ color: var(--text-2);
367
+ }
368
+ .panel-head .meta {
369
+ font-family: 'JetBrains Mono', monospace;
370
+ font-size: 10px; color: var(--muted);
371
+ }
372
+ .panel-body {
373
+ padding: 12px;
374
+ overflow: auto;
375
+ flex: 1 1 auto;
376
+ min-height: 0;
377
+ }
378
+ .panel-body.tight { padding: 8px; }
379
+
380
+ /* ─────────────── Drop zone ─────────────── */
381
+ .drop {
382
+ position: relative;
383
+ aspect-ratio: 4 / 3;
384
+ border: 1.5px dashed var(--border-strong);
385
+ border-radius: var(--radius);
386
+ background: var(--surface-2);
387
+ display: grid; place-items: center;
388
+ overflow: hidden;
389
+ cursor: pointer;
390
+ transition: border-color 150ms ease, background 150ms ease;
391
+ }
392
+ .drop.dragover { border-color: var(--accent); background: var(--accent-soft); }
393
+ .drop.has-image { border-style: solid; border-color: var(--border); cursor: default; }
394
+ .drop input[type="file"] {
395
+ position: absolute; inset: 0; opacity: 0; cursor: pointer;
396
+ }
397
+ .drop.has-image input[type="file"] { pointer-events: none; }
398
+ .drop-empty {
399
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
400
+ color: var(--muted); text-align: center; padding: 16px;
401
+ }
402
+ .drop-empty .icon {
403
+ width: 36px; height: 36px;
404
+ display: grid; place-items: center;
405
+ background: var(--surface-3);
406
+ border: 1px solid var(--border-strong);
407
+ border-radius: var(--radius);
408
+ }
409
+ .drop-empty .icon svg { width: 18px; height: 18px; color: var(--accent); }
410
+ .drop-empty strong { color: var(--text); font-weight: 600; font-size: 12px; }
411
+ .drop-empty span { font-size: 11px; color: var(--muted); }
412
+ .drop-preview {
413
+ width: 100%; height: 100%; object-fit: contain;
414
+ background: #0a0d10; display: block;
415
+ }
416
+ .drop-meta {
417
+ margin-top: 10px;
418
+ display: grid; grid-template-columns: auto 1fr; gap: 4px 10px;
419
+ font-family: 'JetBrains Mono', monospace; font-size: 11px;
420
+ }
421
+ .drop-meta dt { color: var(--muted); }
422
+ .drop-meta dd { color: var(--text-2); }
423
+
424
+ /* ─────────────── Viewport ─────────────── */
425
+ .viewport {
426
+ position: relative;
427
+ flex: 1 1 auto;
428
+ min-height: 0;
429
+ background: var(--viewer);
430
+ overflow: hidden;
431
+ }
432
+ .viewport-frame {
433
+ width: 100%; height: 100%; border: 0; display: block;
434
+ background: var(--viewer);
435
+ opacity: 0; transition: opacity 240ms ease;
436
+ }
437
+ .viewport-frame.ready { opacity: 1; }
438
+
439
+ /* Viewport overlay (corner badges, status) */
440
+ .vp-overlay {
441
+ position: absolute; pointer-events: none;
442
+ transition: opacity 200ms ease;
443
+ }
444
+ .vp-overlay.hidden { opacity: 0; visibility: hidden; }
445
+ .vp-top-left {
446
+ top: 12px; left: 12px;
447
+ display: flex; flex-direction: column; gap: 6px;
448
+ }
449
+ .vp-bottom-left {
450
+ bottom: 12px; left: 12px;
451
+ display: flex; flex-direction: column; gap: 6px;
452
+ }
453
+ .vp-bottom-right {
454
+ bottom: 12px; right: 12px;
455
+ display: flex; gap: 6px;
456
+ pointer-events: auto;
457
+ }
458
+ .vp-top-right {
459
+ top: 12px; right: 12px;
460
+ pointer-events: auto;
461
+ }
462
+ .vp-badge {
463
+ display: inline-flex; align-items: center; gap: 6px;
464
+ padding: 4px 8px;
465
+ background: rgba(10, 13, 16, 0.7);
466
+ backdrop-filter: blur(8px);
467
+ border: 1px solid rgba(255,255,255,0.06);
468
+ border-radius: var(--radius-sm);
469
+ font-family: 'JetBrains Mono', monospace;
470
+ font-size: 10px;
471
+ color: var(--text-2);
472
+ letter-spacing: 0.04em;
473
+ text-transform: uppercase;
474
+ }
475
+ .vp-badge .dot {
476
+ width: 6px; height: 6px; border-radius: 50%;
477
+ background: var(--muted);
478
+ }
479
+ .vp-badge.ok .dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
480
+ .vp-badge.warn .dot { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
481
+ .vp-badge.err .dot { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
482
+ .vp-badge.run .dot {
483
+ background: var(--accent);
484
+ animation: pulse 1.2s ease-in-out infinite;
485
+ }
486
+ @keyframes pulse {
487
+ 0%, 100% { opacity: 1; transform: scale(1); }
488
+ 50% { opacity: 0.5; transform: scale(0.85); }
489
+ }
490
+ .vp-crosshair {
491
+ position: absolute; top: 50%; left: 50%;
492
+ width: 20px; height: 20px;
493
+ transform: translate(-50%, -50%);
494
+ pointer-events: none;
495
+ }
496
+ .vp-crosshair::before, .vp-crosshair::after {
497
+ content: ""; position: absolute;
498
+ background: rgba(255,255,255,0.06);
499
+ }
500
+ .vp-crosshair::before { top: 50%; left: 0; right: 0; height: 1px; }
501
+ .vp-crosshair::after { left: 50%; top: 0; bottom: 0; width: 1px; }
502
+
503
+ /* Viewport controls (view cube + zoom) */
504
+ .vp-controls {
505
+ display: flex; gap: 4px;
506
+ background: rgba(10, 13, 16, 0.7);
507
+ backdrop-filter: blur(8px);
508
+ border: 1px solid rgba(255,255,255,0.06);
509
+ border-radius: var(--radius-sm);
510
+ padding: 4px;
511
+ }
512
+ .vp-ctrl {
513
+ width: 26px; height: 26px;
514
+ display: grid; place-items: center;
515
+ background: transparent;
516
+ border: 0; border-radius: 3px;
517
+ color: var(--text-2);
518
+ cursor: pointer;
519
+ transition: background 120ms ease, color 120ms ease;
520
+ }
521
+ .vp-ctrl:hover { background: rgba(255,255,255,0.08); color: var(--text); }
522
+ .vp-ctrl svg { width: 14px; height: 14px; }
523
+
524
+ /* Loading + idle + error states inside viewport */
525
+ .vp-state {
526
+ position: absolute; inset: 0;
527
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
528
+ gap: 12px;
529
+ color: var(--text-2); text-align: center;
530
+ padding: 24px;
531
+ background: var(--viewer);
532
+ transition: opacity 200ms ease, visibility 200ms ease;
533
+ }
534
+ .vp-state.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
535
+ .vp-state .ring {
536
+ width: 32px; height: 32px;
537
+ border: 2px solid var(--surface-3);
538
+ border-top-color: var(--accent);
539
+ border-radius: 50%;
540
+ animation: spin 0.9s linear infinite;
541
+ }
542
+ .vp-state .idle-ring {
543
+ width: 48px; height: 48px;
544
+ border: 1px dashed var(--border-strong);
545
+ border-radius: 50%;
546
+ display: grid; place-items: center;
547
+ color: var(--muted);
548
+ }
549
+ .vp-state .idle-ring svg { width: 22px; height: 22px; }
550
+ .vp-state strong { font-size: 13px; font-weight: 600; color: var(--text); }
551
+ .vp-state span { font-size: 11px; color: var(--muted); }
552
+ .vp-state .vp-progress {
553
+ width: 200px; height: 3px;
554
+ background: var(--surface-3);
555
+ border-radius: 999px; overflow: hidden;
556
+ margin-top: 4px;
557
+ }
558
+ .vp-state .vp-progress > div {
559
+ height: 100%; background: var(--accent);
560
+ width: 0%; transition: width 250ms ease;
561
+ }
562
+ .vp-state .err-bar { width: 32px; height: 3px; background: var(--danger); border-radius: 2px; }
563
+
564
+ /* Export bar inside viewport panel */
565
+ .export-bar {
566
+ display: grid; grid-template-columns: 1fr 1fr; gap: 1px;
567
+ background: var(--border);
568
+ border-top: 1px solid var(--border);
569
+ }
570
+ .export-btn {
571
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
572
+ min-height: 36px; padding: 0 12px;
573
+ background: var(--surface);
574
+ border: 0;
575
+ color: var(--text-2);
576
+ font: 600 11px 'Inter', sans-serif;
577
+ text-transform: uppercase; letter-spacing: 0.06em;
578
+ cursor: pointer;
579
+ transition: background 120ms ease, color 120ms ease;
580
+ }
581
+ .export-btn:hover { background: var(--surface-2); color: var(--text); }
582
+ .export-btn[aria-disabled="true"], .export-btn:disabled {
583
+ opacity: 0.4; cursor: not-allowed; pointer-events: none;
584
+ }
585
+ .export-btn.ready { color: var(--accent); }
586
+ .export-btn.ready:hover { color: var(--accent-hi); }
587
+ .export-btn svg { width: 14px; height: 14px; }
588
+
589
+ /* ─────────────── Inspector (right column) ─────────────── */
590
+ .inspector { display: flex; flex-direction: column; min-height: 0; }
591
+
592
+ /* Section group inside inspector */
593
+ .section {
594
+ border-bottom: 1px solid var(--border);
595
+ }
596
+ .section:last-child { border-bottom: 0; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
597
+ .section-head {
598
+ display: flex; align-items: center; justify-content: space-between;
599
+ padding: 8px 12px;
600
+ background: var(--surface);
601
+ border-bottom: 1px solid var(--border);
602
+ user-select: none;
603
+ cursor: pointer;
604
+ }
605
+ .section-head h4 {
606
+ font-size: 10px; font-weight: 700;
607
+ text-transform: uppercase; letter-spacing: 0.08em;
608
+ color: var(--text-2);
609
+ }
610
+ .section-head .chev {
611
+ color: var(--muted); font-size: 10px;
612
+ transition: transform 150ms ease;
613
+ }
614
+ .section.collapsed .chev { transform: rotate(-90deg); }
615
+ .section.collapsed .section-body { display: none; }
616
+ .section-body { padding: 10px 12px; }
617
+
618
+ /* Scene tree */
619
+ .tree { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
620
+ .tree-row {
621
+ display: grid; grid-template-columns: 14px 1fr auto;
622
+ align-items: center; gap: 8px;
623
+ padding: 4px 6px;
624
+ border-radius: 3px;
625
+ color: var(--text-2);
626
+ }
627
+ .tree-row:hover { background: var(--surface-2); }
628
+ .tree-row .ico {
629
+ width: 14px; height: 14px;
630
+ display: grid; place-items: center;
631
+ color: var(--accent);
632
+ }
633
+ .tree-row .ico svg { width: 12px; height: 12px; }
634
+ .tree-row .val {
635
+ font-family: 'JetBrains Mono', monospace;
636
+ font-size: 11px; color: var(--text-2);
637
+ }
638
+ .tree-row.pending .ico { color: var(--muted); }
639
+ .tree-row.pending .val { color: var(--muted-2); }
640
+ .tree-row.active .ico svg { animation: pulse 1.2s ease-in-out infinite; }
641
+ .tree-row.done .ico { color: var(--success); }
642
+ .tree-row.error .ico { color: var(--danger); }
643
+
644
+ /* Telemetry */
645
+ .telemetry { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; font-size: 11px; }
646
+ .telemetry .label { color: var(--muted); }
647
+ .telemetry .val {
648
+ font-family: 'JetBrains Mono', monospace;
649
+ color: var(--text-2); text-align: right;
650
+ }
651
+ .telemetry .val.accent { color: var(--accent); }
652
+
653
+ /* Pipeline log */
654
+ .log {
655
+ flex: 1 1 auto; min-height: 80px;
656
+ overflow: auto;
657
+ font-family: 'JetBrains Mono', monospace;
658
+ font-size: 11px;
659
+ line-height: 1.6;
660
+ color: var(--text-2);
661
+ background: var(--bg);
662
+ padding: 8px 12px;
663
+ }
664
+ .log-line { display: grid; grid-template-columns: 56px 60px 1fr; gap: 8px; }
665
+ .log-time { color: var(--muted-2); }
666
+ .log-stage { color: var(--accent); }
667
+ .log-stage.info { color: var(--info); }
668
+ .log-stage.ok { color: var(--success); }
669
+ .log-stage.err { color: var(--danger); }
670
+ .log-msg { color: var(--text-2); }
671
+ .log-empty { color: var(--muted); padding: 12px 0; text-align: center; }
672
+
673
+ /* Examples */
674
+ .examples-grid {
675
+ display: grid;
676
+ grid-template-columns: 1fr 1fr;
677
+ gap: 6px;
678
+ }
679
+ .ex-thumb {
680
+ position: relative;
681
+ aspect-ratio: 4 / 3;
682
+ border: 1px solid var(--border);
683
+ border-radius: var(--radius-sm);
684
+ overflow: hidden;
685
+ cursor: pointer;
686
+ background: var(--surface-2);
687
+ transition: border-color 120ms ease, transform 120ms ease;
688
+ }
689
+ .ex-thumb:hover { border-color: var(--accent); transform: translateY(-1px); }
690
+ .ex-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
691
+ .ex-thumb .ex-label {
692
+ position: absolute; inset: auto 0 0 0;
693
+ padding: 14px 6px 4px;
694
+ background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
695
+ color: #fff; font-size: 10px; font-weight: 500;
696
+ }
697
+
698
+ /* ─────────────── Status bar ─────────────── */
699
+ .statusbar {
700
+ display: grid;
701
+ grid-template-columns: auto auto auto auto 1fr auto;
702
+ align-items: center;
703
+ gap: 16px;
704
+ padding: 0 12px;
705
+ background: var(--surface);
706
+ border-top: 1px solid var(--border);
707
+ font-family: 'JetBrains Mono', monospace;
708
+ font-size: 10px;
709
+ color: var(--muted);
710
+ user-select: none;
711
+ }
712
+ .status-item { display: flex; align-items: center; gap: 6px; }
713
+ .status-item .label { color: var(--muted-2); }
714
+ .status-item .val { color: var(--text-2); }
715
+ .status-item .val.accent { color: var(--accent); }
716
+ .status-item .val.success { color: var(--success); }
717
+ .status-item .val.danger { color: var(--danger); }
718
+ .status-dot {
719
+ width: 6px; height: 6px; border-radius: 50%;
720
+ background: var(--muted-2);
721
+ }
722
+ .status-dot.idle { background: var(--success); box-shadow: 0 0 4px var(--success); }
723
+ .status-dot.run { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
724
+ .status-dot.err { background: var(--danger); box-shadow: 0 0 4px var(--danger); }
725
+
726
+ /* Toast */
727
+ .toast {
728
+ position: fixed; bottom: 36px; left: 50%;
729
+ transform: translateX(-50%) translateY(8px);
730
+ background: var(--surface-3); color: var(--text);
731
+ padding: 8px 14px; border-radius: var(--radius-sm);
732
+ border: 1px solid var(--border-strong);
733
+ font-size: 12px;
734
+ box-shadow: var(--shadow);
735
+ opacity: 0; transition: opacity 200ms ease, transform 200ms ease;
736
+ pointer-events: none; z-index: 100;
737
+ }
738
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
739
+ .toast.error { border-color: var(--danger); color: var(--danger); }
740
+
741
+ /* Utility */
742
+ .hidden { display: none !important; }
743
+ .sr-only {
744
+ position: absolute; width: 1px; height: 1px;
745
+ padding: 0; margin: -1px; overflow: hidden;
746
+ clip: rect(0,0,0,0); white-space: nowrap; border: 0;
747
+ }
748
+ </style>
749
+ </head>
750
+ <body>
751
+ <div class="app">
752
+ <!-- ───── Toolbar ───── -->
753
+ <header class="toolbar">
754
+ <div class="tb-brand">
755
+ <div class="tb-logo" aria-hidden="true">
756
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
757
+ <path d="M12 2 L22 7 L22 17 L12 22 L2 17 L2 7 Z"/>
758
+ <path d="M12 2 L12 12 M22 7 L12 12 M2 7 L12 12"/>
759
+ </svg>
760
+ </div>
761
+ <div class="tb-name">Infini<span class="accent">Splat</span><span class="tb-build">v1.0</span></div>
762
+ </div>
763
+ <div class="tb-scene">
764
+ <span class="tb-scene-label">SCENE</span>
765
+ <span class="tb-scene-name mono" id="sceneName">untitled.gsplat</span>
766
+ </div>
767
+ <div class="tb-actions">
768
+ <button class="reconstruct-btn" id="runBtn" disabled>
769
+ <span class="btn-label">Reconstruct</span>
770
+ <span class="tb-shortcut">R</span>
771
+ </button>
772
+ </div>
773
+ <div class="tb-actions">
774
+ <a class="icon-btn ghost" href="https://github.com/zju3dv/InfiniSplat" target="_blank" rel="noopener">
775
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.3-3.2-.1-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.2 3 .1 3.3.8.8 1.3 1.9 1.3 3.2 0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
776
+ GitHub
777
+ </a>
778
+ <a class="icon-btn ghost" href="https://zju3dv.github.io/InfiniSplat" target="_blank" rel="noopener">
779
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12h18M12 3v18"/></svg>
780
+ Project
781
+ </a>
782
+ </div>
783
+ </header>
784
+
785
+ <!-- ───── Workspace ───── -->
786
+ <main class="workspace">
787
+ <!-- LEFT: Input -->
788
+ <section class="col">
789
+ <div class="panel">
790
+ <div class="panel-head">
791
+ <div class="bar"></div>
792
+ <h3>Input</h3>
793
+ <span class="meta mono" id="inputMeta">no source</span>
794
+ </div>
795
+ <div class="panel-body">
796
+ <label class="drop" id="drop">
797
+ <input type="file" id="file" accept="image/*" />
798
+ <div class="drop-empty" id="dropEmpty">
799
+ <div class="icon">
800
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
801
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
802
+ <polyline points="17 8 12 3 7 8"/>
803
+ <line x1="12" y1="3" x2="12" y2="15"/>
804
+ </svg>
805
+ </div>
806
+ <strong>Drop image</strong>
807
+ <span>JPG · PNG · WebP</span>
808
+ </div>
809
+ <img class="drop-preview hidden" id="preview" alt="" />
810
+ </label>
811
+ <dl class="drop-meta">
812
+ <dt>FILE</dt><dd class="mono" id="inputFileName">—</dd>
813
+ <dt>SIZE</dt><dd class="mono" id="inputFileSize">—</dd>
814
+ <dt>FORMAT</dt><dd class="mono" id="inputFormat">—</dd>
815
+ </dl>
816
+ </div>
817
+ </div>
818
+ </section>
819
+
820
+ <!-- CENTER: Viewport -->
821
+ <section class="col">
822
+ <div class="panel" style="flex: 1 1 auto;">
823
+ <div class="panel-head">
824
+ <div class="bar"></div>
825
+ <h3>Viewport</h3>
826
+ <span class="meta mono" id="viewportMeta">idle</span>
827
+ </div>
828
+ <div class="viewport" id="viewport">
829
+ <!-- Corner overlays -->
830
+ <div class="vp-overlay vp-top-left" id="vpTopLeft">
831
+ <div class="vp-badge" id="vpBadge">
832
+ <span class="dot"></span>
833
+ <span id="vpBadgeText">STAGE 0 / 4</span>
834
+ </div>
835
+ </div>
836
+ <div class="vp-overlay vp-bottom-left" id="vpBottomLeft">
837
+ <div class="vp-badge mono">
838
+ <span id="vpSceneLabel">—</span>
839
+ </div>
840
+ </div>
841
+ <div class="vp-overlay vp-bottom-right" id="vpBottomRight">
842
+ <div class="vp-controls">
843
+ <button class="vp-ctrl" id="vpReset" title="Reset view (V)">
844
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>
845
+ </button>
846
+ <button class="vp-ctrl" id="vpZoomIn" title="Zoom in (+)">
847
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/><line x1="16" y1="16" x2="21" y2="21"/></svg>
848
+ </button>
849
+ <button class="vp-ctrl" id="vpZoomOut" title="Zoom out (−)">
850
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="8" y1="11" x2="14" y2="11"/><line x1="16" y1="16" x2="21" y2="21"/></svg>
851
+ </button>
852
+ </div>
853
+ </div>
854
+
855
+ <!-- State overlays -->
856
+ <div class="vp-state" id="vpIdle">
857
+ <div class="idle-ring">
858
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
859
+ <path d="M12 2 L22 7 L22 17 L12 22 L2 17 L2 7 Z"/>
860
+ <path d="M2 7 L12 12 L22 7 M12 12 L12 22"/>
861
+ </svg>
862
+ </div>
863
+ <strong>No scene loaded</strong>
864
+ <span>Upload an image to begin reconstruction</span>
865
+ </div>
866
+ <div class="vp-state hidden" id="vpLoading">
867
+ <div class="ring"></div>
868
+ <strong id="vpLoadingTitle">Reconstructing scene</strong>
869
+ <span id="vpLoadingDetail">Running model inference</span>
870
+ <div class="vp-progress"><div id="vpProgressBar"></div></div>
871
+ </div>
872
+ <div class="vp-state hidden" id="vpError">
873
+ <div class="err-bar"></div>
874
+ <strong>Reconstruction stopped</strong>
875
+ <span id="vpErrorDetail">See console for details</span>
876
+ </div>
877
+
878
+ <!-- The actual iframe (always present, faded in when ready) -->
879
+ <iframe class="viewport-frame" id="viewerFrame" title="Gaussian scene"></iframe>
880
+ </div>
881
+ <div class="export-bar">
882
+ <button class="export-btn" id="dlPly" aria-disabled="true">
883
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
884
+ <span class="dl-label">Download PLY</span>
885
+ </button>
886
+ <button class="export-btn" id="dlHtml" aria-disabled="true">
887
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
888
+ <span class="dl-label">Download HTML</span>
889
+ </button>
890
+ </div>
891
+ </div>
892
+ </section>
893
+
894
+ <!-- RIGHT: Inspector -->
895
+ <aside class="col inspector">
896
+ <div class="section">
897
+ <div class="section-head" data-toggle>
898
+ <h4>Scene tree</h4>
899
+ <span class="chev">▾</span>
900
+ </div>
901
+ <div class="section-body" id="sceneTree">
902
+ <div class="tree">
903
+ <div class="tree-row pending" data-stage="input">
904
+ <div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></div>
905
+ <span>Input image</span>
906
+ <span class="val">—</span>
907
+ </div>
908
+ <div class="tree-row pending" data-stage="reconstruct">
909
+ <div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="2" x2="9" y2="4"/><line x1="15" y1="2" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="22"/><line x1="15" y1="20" x2="15" y2="22"/></svg></div>
910
+ <span>Reconstruct</span>
911
+ <span class="val">—</span>
912
+ </div>
913
+ <div class="tree-row pending" data-stage="ply">
914
+ <div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L22 7 L22 17 L12 22 L2 17 L2 7 Z"/></svg></div>
915
+ <span>Filter PLY</span>
916
+ <span class="val">—</span>
917
+ </div>
918
+ <div class="tree-row pending" data-stage="viewer">
919
+ <div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="3" x2="12" y2="21"/><line x1="3" y1="12" x2="21" y2="12"/></svg></div>
920
+ <span>WebGL viewer</span>
921
+ <span class="val">—</span>
922
+ </div>
923
+ <div class="tree-row pending" data-stage="html">
924
+ <div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></div>
925
+ <span>Standalone HTML</span>
926
+ <span class="val">—</span>
927
+ </div>
928
+ </div>
929
+ </div>
930
+ </div>
931
+
932
+ <div class="section">
933
+ <div class="section-head" data-toggle>
934
+ <h4>Telemetry</h4>
935
+ <span class="chev">▾</span>
936
+ </div>
937
+ <div class="section-body">
938
+ <div class="telemetry">
939
+ <span class="label">Gaussians</span><span class="val accent" id="telGaussians">—</span>
940
+ <span class="label">Artifact</span><span class="val" id="telArtifact">—</span>
941
+ <span class="label">PLY</span><span class="val" id="telPly">—</span>
942
+ <span class="label">Viewer</span><span class="val" id="telViewer">—</span>
943
+ <span class="label">GPU time</span><span class="val" id="telGpu">—</span>
944
+ <span class="label">Total</span><span class="val" id="telTotal">—</span>
945
+ </div>
946
+ </div>
947
+ </div>
948
+
949
+ <div class="section">
950
+ <div class="section-head" data-toggle>
951
+ <h4>Pipeline log</h4>
952
+ <span class="chev">▾</span>
953
+ </div>
954
+ <div class="log mono" id="log">
955
+ <div class="log-empty">ready · awaiting input</div>
956
+ </div>
957
+ </div>
958
+
959
+ <div class="section">
960
+ <div class="section-head" data-toggle>
961
+ <h4>Examples</h4>
962
+ <span class="meta mono" id="examplesCount">—</span>
963
+ <span class="chev">▾</span>
964
+ </div>
965
+ <div class="section-body">
966
+ <div class="examples-grid" id="gallery"></div>
967
+ </div>
968
+ </div>
969
+ </aside>
970
+ </main>
971
+
972
+ <!-- ───── Status bar ───── -->
973
+ <footer class="statusbar">
974
+ <div class="status-item">
975
+ <span class="status-dot idle" id="statusDot"></span>
976
+ <span class="label">STATE</span>
977
+ <span class="val" id="statusState">idle</span>
978
+ </div>
979
+ <div class="status-item">
980
+ <span class="label">GPU</span>
981
+ <span class="val" id="statusGpu">idle</span>
982
+ </div>
983
+ <div class="status-item">
984
+ <span class="label">QUEUE</span>
985
+ <span class="val" id="statusQueue">0</span>
986
+ </div>
987
+ <div class="status-item">
988
+ <span class="label">STAGE</span>
989
+ <span class="val" id="statusStage">0 / 4</span>
990
+ </div>
991
+ <div></div>
992
+ <div class="status-item">
993
+ <span class="label">zju3dv / InfiniSplat</span>
994
+ </div>
995
+ </footer>
996
+ </div>
997
+
998
+ <div class="toast" id="toast"></div>
999
+
1000
+ <script type="module">
1001
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
1002
+
1003
+ const $ = (id) => document.getElementById(id);
1004
+ const fileInput = $("file"), drop = $("drop"), preview = $("preview"),
1005
+ dropEmpty = $("dropEmpty"), runBtn = $("runBtn"),
1006
+ btnLabel = runBtn.querySelector(".btn-label"),
1007
+ viewerFrame = $("viewerFrame"),
1008
+ vpIdle = $("vpIdle"), vpLoading = $("vpLoading"), vpError = $("vpError"),
1009
+ vpLoadingTitle = $("vpLoadingTitle"), vpLoadingDetail = $("vpLoadingDetail"),
1010
+ vpProgressBar = $("vpProgressBar"), vpErrorDetail = $("vpErrorDetail"),
1011
+ vpBadge = $("vpBadge"), vpBadgeText = $("vpBadgeText"),
1012
+ vpSceneLabel = $("vpSceneLabel"),
1013
+ dlPly = $("dlPly"), dlHtml = $("dlHtml"),
1014
+ sceneName = $("sceneName"),
1015
+ inputMeta = $("inputMeta"),
1016
+ inputFileName = $("inputFileName"), inputFileSize = $("inputFileSize"), inputFormat = $("inputFormat"),
1017
+ viewportMeta = $("viewportMeta"),
1018
+ gallery = $("gallery"), examplesCount = $("examplesCount"),
1019
+ log = $("log"),
1020
+ statusDot = $("statusDot"), statusState = $("statusState"),
1021
+ statusGpu = $("statusGpu"), statusQueue = $("statusQueue"), statusStage = $("statusStage"),
1022
+ toast = $("toast");
1023
+
1024
+ let pendingFile = null;
1025
+ let pipelineStart = 0;
1026
+ const stageTimings = {};
1027
+ const STAGES = ["input", "reconstruct", "ply", "viewer", "html"];
1028
+
1029
+ function logLine(stage, kind, msg) {
1030
+ const time = new Date().toLocaleTimeString("en-US", { hour12: false });
1031
+ const line = document.createElement("div");
1032
+ line.className = "log-line";
1033
+ line.innerHTML = `
1034
+ <span class="log-time">${time}</span>
1035
+ <span class="log-stage ${kind}">${stage}</span>
1036
+ <span class="log-msg">${msg}</span>
1037
+ `;
1038
+ // Remove empty placeholder
1039
+ const empty = log.querySelector(".log-empty");
1040
+ if (empty) empty.remove();
1041
+ log.appendChild(line);
1042
+ log.scrollTop = log.scrollHeight;
1043
+ }
1044
+
1045
+ function clearLog() {
1046
+ log.innerHTML = '<div class="log-empty">ready · awaiting input</div>';
1047
+ }
1048
+
1049
+ function setStage(stageId, state, val) {
1050
+ const row = document.querySelector(`.tree-row[data-stage="${stageId}"]`);
1051
+ if (!row) return;
1052
+ row.classList.remove("pending", "active", "done", "error");
1053
+ row.classList.add(state);
1054
+ if (val !== undefined) row.querySelector(".val").textContent = val;
1055
+ }
1056
+ function resetStages() {
1057
+ STAGES.forEach((s) => {
1058
+ const row = document.querySelector(`.tree-row[data-stage="${s}"]`);
1059
+ if (row) {
1060
+ row.classList.remove("active", "done", "error");
1061
+ row.classList.add("pending");
1062
+ row.querySelector(".val").textContent = "—";
1063
+ }
1064
+ });
1065
+ document.getElementById("telGaussians").textContent = "—";
1066
+ document.getElementById("telArtifact").textContent = "—";
1067
+ document.getElementById("telPly").textContent = "—";
1068
+ document.getElementById("telViewer").textContent = "—";
1069
+ document.getElementById("telGpu").textContent = "—";
1070
+ document.getElementById("telTotal").textContent = "—";
1071
+ }
1072
+
1073
+ function setStatus(state, opts) {
1074
+ opts = opts || {};
1075
+ statusDot.classList.remove("idle", "run", "err");
1076
+ statusDot.classList.add(state);
1077
+ const stateMap = { idle: "idle", run: "running", err: "error" };
1078
+ statusState.textContent = stateMap[state] || state;
1079
+ statusState.classList.remove("accent", "success", "danger");
1080
+ if (state === "idle") statusState.classList.add("success");
1081
+ if (state === "err") statusState.classList.add("danger");
1082
+ if (state === "run") statusState.classList.add("accent");
1083
+ if (opts.gpu) statusGpu.textContent = opts.gpu;
1084
+ if (opts.queue !== undefined) statusQueue.textContent = String(opts.queue);
1085
+ if (opts.stage !== undefined) statusStage.textContent = opts.stage;
1086
+ }
1087
+
1088
+ function setViewerState(state, opts) {
1089
+ opts = opts || {};
1090
+ const isReady = state === "ready";
1091
+ const isLoading = state === "loading";
1092
+ const isError = state === "error";
1093
+ vpIdle.classList.toggle("hidden", !(!isReady && !isLoading && !isError));
1094
+ vpLoading.classList.toggle("hidden", !isLoading);
1095
+ vpError.classList.toggle("hidden", !isError);
1096
+ if (isLoading) {
1097
+ vpLoadingTitle.textContent = opts.title || "Reconstructing scene";
1098
+ vpLoadingDetail.textContent = opts.detail || "Running model inference";
1099
+ vpProgressBar.style.width = (opts.progress || 0) + "%";
1100
+ }
1101
+ if (isError) vpErrorDetail.textContent = opts.detail || "Unknown error";
1102
+ if (isReady) {
1103
+ vpIdle.classList.add("hidden");
1104
+ vpLoading.classList.add("hidden");
1105
+ vpError.classList.add("hidden");
1106
+ viewerFrame.classList.add("ready");
1107
+ } else {
1108
+ viewerFrame.classList.remove("ready");
1109
+ }
1110
+ // Top-left badge
1111
+ vpBadge.classList.remove("ok", "warn", "err", "run");
1112
+ const badgeText = $("vpBadgeText");
1113
+ if (isLoading) {
1114
+ vpBadge.classList.add("run");
1115
+ badgeText.textContent = opts.stageLabel || "STAGE";
1116
+ } else if (isReady) {
1117
+ vpBadge.classList.add("ok");
1118
+ badgeText.textContent = "RENDERING";
1119
+ } else if (isError) {
1120
+ vpBadge.classList.add("err");
1121
+ badgeText.textContent = "ERROR";
1122
+ } else {
1123
+ badgeText.textContent = "IDLE";
1124
+ }
1125
+ }
1126
+
1127
+ function setReady(ready) {
1128
+ runBtn.disabled = !ready;
1129
+ btnLabel.textContent = ready ? "Reconstruct" : "Reconstructing";
1130
+ if (ready) {
1131
+ const sp = runBtn.querySelector(".spinner");
1132
+ if (sp) sp.remove();
1133
+ } else {
1134
+ if (!runBtn.querySelector(".spinner")) {
1135
+ const sp = document.createElement("span");
1136
+ sp.className = "spinner";
1137
+ runBtn.insertBefore(sp, btnLabel);
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ function setDownload(btn, url, label) {
1143
+ btn.disabled = !url;
1144
+ btn.setAttribute("aria-disabled", String(!url));
1145
+ const lbl = btn.querySelector(".dl-label");
1146
+ lbl.textContent = label;
1147
+ btn.classList.toggle("ready", !!url);
1148
+ if (url) {
1149
+ btn.onclick = () => {
1150
+ const a = document.createElement("a");
1151
+ a.href = url; a.download = ""; a.click();
1152
+ };
1153
+ } else {
1154
+ btn.onclick = null;
1155
+ }
1156
+ }
1157
+
1158
+ function showPreview(file) {
1159
+ const url = URL.createObjectURL(file);
1160
+ preview.src = url;
1161
+ preview.classList.remove("hidden");
1162
+ dropEmpty.classList.add("hidden");
1163
+ drop.classList.add("has-image");
1164
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
1165
+
1166
+ // Update source metadata
1167
+ const fname = file.name || "image";
1168
+ inputFileName.textContent = fname.length > 22 ? fname.slice(0, 20) + "…" : fname;
1169
+ const sizeKb = (file.size / 1024);
1170
+ inputFileSize.textContent = sizeKb > 1024 ? (sizeKb / 1024).toFixed(2) + " MB" : sizeKb.toFixed(1) + " KB";
1171
+ inputFormat.textContent = (file.type || "image").split("/").pop().toUpperCase();
1172
+ inputMeta.textContent = (file.size / 1024 / 1024).toFixed(2) + " MB";
1173
+
1174
+ // Update scene name
1175
+ const stem = fname.replace(/\.[^.]+$/, "");
1176
+ sceneName.textContent = `${stem}.gsplat`;
1177
+ vpSceneLabel.textContent = `${stem}.gsplat`;
1178
+
1179
+ setStage("input", "done", inputFormat.textContent);
1180
+ }
1181
+
1182
+ function clearPreview() {
1183
+ pendingFile = null;
1184
+ preview.src = "";
1185
+ preview.classList.add("hidden");
1186
+ dropEmpty.classList.remove("hidden");
1187
+ drop.classList.remove("has-image");
1188
+ setReady(false);
1189
+ resetStages();
1190
+ sceneName.textContent = "untitled.gsplat";
1191
+ inputFileName.textContent = "—";
1192
+ inputFileSize.textContent = "—";
1193
+ inputFormat.textContent = "—";
1194
+ inputMeta.textContent = "no source";
1195
+ viewportMeta.textContent = "idle";
1196
+ }
1197
+
1198
+ function toastMsg(msg, isError) {
1199
+ toast.textContent = msg;
1200
+ toast.classList.toggle("error", !!isError);
1201
+ toast.classList.add("show");
1202
+ clearTimeout(toast._t);
1203
+ toast._t = setTimeout(() => toast.classList.remove("show"), 2400);
1204
+ }
1205
+
1206
+ function fmtBytes(n) {
1207
+ if (!n) return "—";
1208
+ if (n > 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + " MB";
1209
+ if (n > 1024) return (n / 1024).toFixed(1) + " KB";
1210
+ return n + " B";
1211
+ }
1212
+ function fmtSec(s) {
1213
+ if (s == null) return "—";
1214
+ if (s > 60) return Math.floor(s / 60) + "m " + (s % 60).toFixed(1) + "s";
1215
+ return s.toFixed(2) + "s";
1216
+ }
1217
+
1218
+ fileInput.addEventListener("change", (e) => {
1219
+ const f = e.target.files && e.target.files[0];
1220
+ if (f) {
1221
+ pendingFile = f;
1222
+ showPreview(f);
1223
+ setReady(true);
1224
+ }
1225
+ });
1226
+
1227
+ ["dragenter", "dragover"].forEach(ev =>
1228
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("dragover"); })
1229
+ );
1230
+ ["dragleave", "drop"].forEach(ev =>
1231
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("dragover"); })
1232
+ );
1233
+ drop.addEventListener("drop", (e) => {
1234
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
1235
+ if (f && f.type.startsWith("image/")) {
1236
+ pendingFile = f;
1237
+ showPreview(f);
1238
+ setReady(true);
1239
+ }
1240
+ });
1241
+
1242
+ // Collapsible sections
1243
+ document.querySelectorAll(".section-head[data-toggle]").forEach((head) => {
1244
+ head.addEventListener("click", () => {
1245
+ head.parentElement.classList.toggle("collapsed");
1246
+ });
1247
+ });
1248
+
1249
+ // Connect to backend
1250
+ let client;
1251
+ try {
1252
+ client = await Client.connect(window.location.origin);
1253
+ logLine("client", "ok", "backend connected");
1254
+ } catch (err) {
1255
+ toastMsg("Failed to connect to backend", true);
1256
+ logLine("client", "err", err.message || String(err));
1257
+ console.error(err);
1258
+ }
1259
+
1260
+ // Preload viewer template so the iframe swaps in instantly
1261
+ try {
1262
+ const pre = await client.predict("/viewer_html", {});
1263
+ if (pre && pre.data && pre.data[0]) {
1264
+ const url = pre.data[0].url;
1265
+ viewerFrame.src = url + "#preload";
1266
+ logLine("viewer", "info", "template preloaded");
1267
+ }
1268
+ } catch (err) {
1269
+ console.warn("Viewer preload skipped:", err);
1270
+ }
1271
+
1272
+ async function timeStage(fn) {
1273
+ const t0 = performance.now();
1274
+ const result = await fn();
1275
+ return { result, ms: performance.now() - t0 };
1276
+ }
1277
+
1278
+ async function runPipeline() {
1279
+ if (!pendingFile || !client) return;
1280
+ setReady(false);
1281
+ setDownload(dlPly, null, "Preparing PLY…");
1282
+ setDownload(dlHtml, null, "Preparing HTML…");
1283
+ viewerFrame.classList.remove("ready");
1284
+ pipelineStart = performance.now();
1285
+ setStatus("run", { gpu: "queued", queue: 1, stage: "0 / 4" });
1286
+
1287
+ try {
1288
+ // Stage 1: Reconstruct (GPU)
1289
+ setViewerState("loading", { title: "Reconstructing scene", detail: "Running model inference", progress: 15, stageLabel: "STAGE 1 / 4 · GPU" });
1290
+ setStage("reconstruct", "active");
1291
+ setStatus("run", { gpu: "running", queue: 1, stage: "1 / 4" });
1292
+ const reconWrap = await timeStage(() =>
1293
+ client.predict("/reconstruct", { image_path: handle_file(pendingFile) })
1294
+ );
1295
+ const artifactUrl = reconWrap.result.data[0].url;
1296
+ const artifactSize = reconWrap.result.data[0].size;
1297
+ stageTimings.gpu = reconWrap.ms;
1298
+ setStage("reconstruct", "done", fmtSec(reconWrap.ms / 1000));
1299
+ document.getElementById("telGpu").textContent = fmtSec(reconWrap.ms / 1000);
1300
+ document.getElementById("telArtifact").textContent = fmtBytes(artifactSize);
1301
+ logLine("reconstruct", "ok", `${fmtSec(reconWrap.ms / 1000)} · ${fmtBytes(artifactSize)}`);
1302
+
1303
+ // Stage 2: PLY export
1304
+ setViewerState("loading", { title: "Preparing PLY", detail: "Filtering Gaussians", progress: 45, stageLabel: "STAGE 2 / 4 · CPU" });
1305
+ setStage("ply", "active");
1306
+ setStatus("run", { gpu: "—", queue: 1, stage: "2 / 4" });
1307
+ const plyWrap = await timeStage(() =>
1308
+ client.predict("/export_ply", { artifact_url: artifactUrl })
1309
+ );
1310
+ const plyUrl = plyWrap.result.data[0].url;
1311
+ const plySize = plyWrap.result.data[0].size;
1312
+ setStage("ply", "done", fmtBytes(plySize));
1313
+ setDownload(dlPly, plyUrl, "Download PLY");
1314
+ document.getElementById("telPly").textContent = fmtBytes(plySize);
1315
+ logLine("ply", "ok", `${fmtSec(plyWrap.ms / 1000)} · ${fmtBytes(plySize)}`);
1316
+
1317
+ // Stage 3: Viewer
1318
+ setViewerState("loading", { title: "Encoding viewer", detail: "Building WebGL scene", progress: 70, stageLabel: "STAGE 3 / 4 · ENCODE" });
1319
+ setStage("viewer", "active");
1320
+ setStatus("run", { gpu: "—", queue: 1, stage: "3 / 4" });
1321
+ const viewWrap = await timeStage(() =>
1322
+ client.predict("/export_viewer", { scene_ply_url: plyUrl })
1323
+ );
1324
+ const viewerUrl = viewWrap.result.data[0].url + "?v=" + Date.now();
1325
+ const viewerSize = viewWrap.result.data[0].size;
1326
+ setStage("viewer", "done", fmtBytes(viewerSize));
1327
+ document.getElementById("telViewer").textContent = fmtBytes(viewerSize);
1328
+ viewerFrame.src = viewerUrl;
1329
+ viewerFrame.onload = () => {
1330
+ viewerFrame.classList.add("ready");
1331
+ setViewerState("ready");
1332
+ viewportMeta.textContent = "ready";
1333
+ };
1334
+ logLine("viewer", "ok", `${fmtSec(viewWrap.ms / 1000)} · ${fmtBytes(viewerSize)}`);
1335
+
1336
+ // Stage 4: HTML
1337
+ setViewerState("loading", { title: "Bundling HTML", detail: "Embedding assets for download", progress: 92, stageLabel: "STAGE 4 / 4 · BUNDLE" });
1338
+ setStage("html", "active");
1339
+ setStatus("run", { gpu: "—", queue: 1, stage: "4 / 4" });
1340
+ const htmlWrap = await timeStage(() =>
1341
+ client.predict("/export_html", { viewer_html_url: viewerUrl.replace(/[?#].*/, "") })
1342
+ );
1343
+ const htmlUrl = htmlWrap.result.data[0].url;
1344
+ const htmlSize = htmlWrap.result.data[0].size;
1345
+ setStage("html", "done", fmtBytes(htmlSize));
1346
+ setDownload(dlHtml, htmlUrl, "Download HTML");
1347
+
1348
+ const totalSec = (performance.now() - pipelineStart) / 1000;
1349
+ document.getElementById("telTotal").textContent = fmtSec(totalSec);
1350
+ logLine("html", "ok", `${fmtSec(htmlWrap.ms / 1000)} · ${fmtBytes(htmlSize)}`);
1351
+
1352
+ setViewerState("ready");
1353
+ setStatus("idle", { gpu: "idle", queue: 0, stage: "4 / 4" });
1354
+ viewportMeta.textContent = "ready";
1355
+ toastMsg("Reconstruction complete");
1356
+ } catch (err) {
1357
+ console.error(err);
1358
+ setViewerState("error", { detail: err.message || "Pipeline failed" });
1359
+ setStage("reconstruct", "error");
1360
+ setStatus("err", { gpu: "—", queue: 0, stage: "—" });
1361
+ logLine("error", "err", err.message || String(err));
1362
+ toastMsg(err.message || "Reconstruction failed", true);
1363
+ } finally {
1364
+ setReady(true);
1365
+ }
1366
+ }
1367
+
1368
+ runBtn.addEventListener("click", runPipeline);
1369
+
1370
+ // Keyboard shortcuts
1371
+ document.addEventListener("keydown", (e) => {
1372
+ if (e.target.tagName === "INPUT" && e.target.type !== "button") return;
1373
+ if (e.key === "r" || e.key === "R") {
1374
+ if (!runBtn.disabled) runBtn.click();
1375
+ }
1376
+ });
1377
+
1378
+ // Viewport controls — postMessage to the embedded viewer when supported
1379
+ function postToViewer(msg) {
1380
+ try { viewerFrame.contentWindow?.postMessage(msg, "*"); } catch (_) {}
1381
+ }
1382
+ $("vpReset").addEventListener("click", () => postToViewer({ type: "infinisplat:reset" }));
1383
+ $("vpZoomIn").addEventListener("click", () => postToViewer({ type: "infinisplat:zoom", delta: 1 }));
1384
+ $("vpZoomOut").addEventListener("click", () => postToViewer({ type: "infinisplat:zoom", delta: -1 }));
1385
+
1386
+ // Examples — fall back to a curated list served via /__examples/{name}
1387
+ const EXAMPLES = [
1388
+ { label: "Bedroom", path: "/__examples/bedroom.jpg" },
1389
+ { label: "Living room", path: "/__examples/living_room.jpg" },
1390
+ { label: "Loft", path: "/__examples/loft_room.jpg" },
1391
+ { label: "Gym", path: "/__examples/gym.png" },
1392
+ { label: "Meerkat", path: "/__examples/meerkat.jpg" },
1393
+ { label: "Painting", path: "/__examples/painting_room.jpg" },
1394
+ { label: "Ghibli room", path: "/__examples/ghibli_room.jpg" },
1395
+ { label: "Sofa", path: "/__examples/sofa_ai.jpg" },
1396
+ { label: "Summer", path: "/__examples/summer_room.jpg" },
1397
+ { label: "Animate", path: "/__examples/animate_room.jpg" },
1398
+ { label: "My bedroom", path: "/__examples/my_bedroom.JPG" },
1399
+ { label: "Cave", path: "/__examples/cave_ai.jpg" },
1400
+ ];
1401
+ gallery.innerHTML = EXAMPLES.map((ex) => `
1402
+ <div class="ex-thumb" data-path="${ex.path}" title="${ex.label}">
1403
+ <img loading="lazy" src="${ex.path}" alt="${ex.label}" />
1404
+ <div class="ex-label">${ex.label}</div>
1405
+ </div>
1406
+ `).join("");
1407
+ examplesCount.textContent = `${EXAMPLES.length}`;
1408
+
1409
+ gallery.addEventListener("click", async (e) => {
1410
+ const t = e.target.closest(".ex-thumb");
1411
+ if (!t) return;
1412
+ const path = t.dataset.path;
1413
+ try {
1414
+ const resp = await fetch(path);
1415
+ const blob = await resp.blob();
1416
+ const f = new File([blob], path.split("/").pop(), { type: blob.type });
1417
+ pendingFile = f;
1418
+ showPreview(f);
1419
+ setReady(true);
1420
+ logLine("input", "info", `loaded example: ${path.split("/").pop()}`);
1421
+ toastMsg("Example loaded");
1422
+ } catch (err) {
1423
+ toastMsg("Failed to load example", true);
1424
+ logLine("input", "err", err.message || String(err));
1425
+ }
1426
+ });
1427
+
1428
+ // Initial status
1429
+ setStatus("idle", { gpu: "idle", queue: 0, stage: "0 / 4" });
1430
+ </script>
1431
+ </body>
1432
+ </html>
1433
+ """
1434
+
1435
+
1436
+ @app.get("/", response_class=HTMLResponse)
1437
+ async def homepage() -> str:
1438
+ return INDEX_HTML
1439
+
1440
+
1441
+ @app.get("/__examples/{name}")
1442
+ async def serve_example(name: str):
1443
+ """Serve curated example images from the bundled examples directory."""
1444
+ from fastapi.responses import FileResponse
1445
+
1446
+ examples_dir = Path(__file__).resolve().parent / "examples" / "data" / "rgb_demo"
1447
+ candidate = examples_dir / name
1448
+ if not candidate.is_file() or not str(candidate.resolve()).startswith(str(examples_dir.resolve())):
1449
+ from fastapi import HTTPException
1450
+
1451
+ raise HTTPException(status_code=404, detail="Example not found")
1452
+ return FileResponse(candidate)
1453
+
1454
+
1455
+ def _cleanup() -> None:
1456
+ """Remove expired per-request directories on shutdown."""
1457
+ if not OUTPUT_ROOT.is_dir():
1458
+ return
1459
+ cutoff = time.time() - 3600
1460
+ for request_dir in OUTPUT_ROOT.iterdir():
1461
+ if request_dir.is_symlink() or not request_dir.is_dir():
1462
+ continue
1463
+ try:
1464
+ if uuid.UUID(hex=request_dir.name).hex != request_dir.name:
1465
+ continue
1466
+ except ValueError:
1467
+ continue
1468
+ if request_dir.lstat().st_mtime > cutoff:
1469
+ continue
1470
+ shutil.rmtree(request_dir, ignore_errors=True)
1471
+
1472
+
1473
+ import atexit as _atexit
1474
+
1475
+ _atexit.register(_cleanup)
1476
 
1477
 
1478
  if __name__ == "__main__":
1479
+ app.launch(
1480
  server_name="0.0.0.0",
1481
  server_port=int(os.environ.get("PORT", "7860")),
1482
  allowed_paths=[str(OUTPUT_ROOT)],
1483
  max_file_size="20mb",
1484
+ show_error=True,
1485
  ssr_mode=False,
1486
  footer_links=[],
 
 
1487
  )