akhaliq HF Staff commited on
Commit
bfb77f8
·
1 Parent(s): 1db10da

Migrate UI to gradio.Server with custom HTML frontend

Browse files

Replace gr.Blocks demo with gradio.Server + @app .api() endpoints so the
inference pipeline runs through Gradio's queue (concurrency, ZeroGPU,
gradio_client). Serve a polished vanilla HTML/CSS/JS UI via @app .get("/")
that talks to the backend through the Gradio JS client.

- Split pipeline into /reconstruct (GPU), /export_ply, /export_viewer,
/export_html, /viewer_html @app .api() endpoints
- Custom UI: drag-and-drop upload, live progress, viewer iframe fade-in,
toast notifications, example gallery, responsive layout
- Preloads viewer template for instant first paint when results arrive

Files changed (1) hide show
  1. app.py +827 -8
app.py CHANGED
@@ -1,28 +1,847 @@
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 src.demo.hf_runtime import (
18
+ InfiniSplatRuntime,
19
+ ViewerTemplate,
20
+ export_browser_viewer,
21
+ export_filtered_gaussian_ply,
22
+ export_standalone_viewer,
23
+ prepare_viewer_template,
24
+ )
25
+
26
+ OUTPUT_ROOT = Path(os.environ.get("GRADIO_TEMP_DIR", "/tmp/gradio")) / "infinisplat"
27
+ OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
28
+ GPU_DURATION_SECONDS = 6
29
 
30
 
31
  runtime = InfiniSplatRuntime.load()
32
+ viewer_template = prepare_viewer_template(OUTPUT_ROOT)
33
+
34
+
35
+ app = Server(
36
+ title="InfiniSplat",
37
+ description="Implicit Gaussian decoding for large-baseline monocular view synthesis.",
38
+ )
39
+
40
+
41
+ def _log(stage: str, **metrics) -> None:
42
+ import json
43
+
44
+ print(f"INFINISPLAT_TIMING {json.dumps({'stage': stage, **metrics}, sort_keys=True)}", flush=True)
45
+
46
+
47
+ @app.api(name="/reconstruct", queue=True, concurrency_limit=1, concurrency_id="gpu")
48
+ @spaces.GPU(duration=GPU_DURATION_SECONDS)
49
+ def reconstruct(image_path: FileData) -> FileData:
50
+ """Run GPU reconstruction and return the artifact path."""
51
+ started = time.perf_counter()
52
+ request_dir = OUTPUT_ROOT / uuid.uuid4().hex
53
+ request_dir.mkdir(parents=True, exist_ok=True)
54
+ artifact = runtime.infer_to_artifact(
55
+ image_path=Path(image_path["path"]),
56
+ artifact_path=request_dir / "gaussians.pt",
57
+ )
58
+ _log(
59
+ "gpu_reconstruct",
60
+ request=request_dir.name,
61
+ seconds=round(time.perf_counter() - started, 3),
62
+ bytes=artifact.stat().st_size,
63
+ )
64
+ return FileData(path=str(artifact))
65
+
66
+
67
+ @app.api(name="/export_ply", queue=True, concurrency_limit=2)
68
+ def export_ply(artifact: FileData) -> FileData:
69
+ """Filter and expose a PLY artifact."""
70
+ started = time.perf_counter()
71
+ internal = Path(artifact["path"])
72
+ scene_ply = export_filtered_gaussian_ply(
73
+ artifact_path=internal,
74
+ output_dir=internal.parent,
75
+ )
76
+ internal.unlink(missing_ok=True)
77
+ _log(
78
+ "ply_export",
79
+ request=scene_ply.parent.name,
80
+ seconds=round(time.perf_counter() - started, 3),
81
+ bytes=scene_ply.stat().st_size,
82
+ )
83
+ return FileData(path=str(scene_ply))
84
+
85
+
86
+ @app.api(name="/export_viewer", queue=True, concurrency_limit=2)
87
+ def export_viewer(scene_ply: FileData) -> FileData:
88
+ """Build the browser viewer and return its iframe-ready HTML."""
89
+ started = time.perf_counter()
90
+ exported = export_browser_viewer(
91
+ scene_ply=Path(scene_ply["path"]),
92
+ viewer_template=viewer_template,
93
+ )
94
+ _log(
95
+ "browser_viewer",
96
+ request=exported.viewer_html.parent.name,
97
+ seconds=round(time.perf_counter() - started, 3),
98
+ sog_bytes=exported.scene_sog.stat().st_size,
99
+ html_bytes=exported.viewer_html.stat().st_size,
100
+ )
101
+ return FileData(path=str(exported.viewer_html))
102
+
103
+
104
+ @app.api(name="/export_html", queue=True, concurrency_limit=2)
105
+ def export_html(viewer_html: FileData) -> FileData:
106
+ """Bundle a standalone HTML viewer for download."""
107
+ started = time.perf_counter()
108
+ standalone = export_standalone_viewer(
109
+ viewer_html=Path(viewer_html["path"]),
110
+ viewer_template=viewer_template,
111
+ )
112
+ _log(
113
+ "standalone_html",
114
+ request=standalone.parent.name,
115
+ seconds=round(time.perf_counter() - started, 3),
116
+ bytes=standalone.stat().st_size,
117
+ )
118
+ return FileData(path=str(standalone))
119
+
120
+
121
+ @app.api(name="/examples", queue=False)
122
+ def list_examples() -> FileData:
123
+ """Serve the README as a lightweight payload marker."""
124
+ from gradio.data_classes import FileData as _FD
125
+
126
+ return _FD(path=str(Path(__file__).resolve()))
127
+
128
+
129
+ @app.api(name="/viewer_html", queue=False)
130
+ def viewer_html() -> FileData:
131
+ """Serve the preloaded viewer template HTML for fast first paint."""
132
+ return FileData(path=str(viewer_template.viewer_html))
133
+
134
+
135
+ INDEX_HTML = r"""<!doctype html>
136
+ <html lang="en">
137
+ <head>
138
+ <meta charset="utf-8" />
139
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
140
+ <title>InfiniSplat — Implicit Gaussian Decoding</title>
141
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
142
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
143
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet" />
144
+ <style>
145
+ :root {
146
+ color-scheme: light;
147
+ --bg: #f4f5f4;
148
+ --surface: #ffffff;
149
+ --surface-muted: #f8f9f9;
150
+ --border: #dfe3e1;
151
+ --border-strong: #c6ceca;
152
+ --text: #17201e;
153
+ --muted: #68716f;
154
+ --viewer: #101413;
155
+ --primary: #087f62;
156
+ --primary-hover: #066b53;
157
+ --primary-soft: #edf8f4;
158
+ --accent: #c27616;
159
+ --danger: #b42318;
160
+ --radius: 10px;
161
+ --shadow: 0 8px 28px rgba(23, 32, 30, 0.06);
162
+ }
163
+ * { box-sizing: border-box; margin: 0; }
164
+ html, body { height: 100%; }
165
+ body {
166
+ background: var(--bg);
167
+ color: var(--text);
168
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
169
+ font-feature-settings: "ss01", "cv11";
170
+ -webkit-font-smoothing: antialiased;
171
+ line-height: 1.5;
172
+ }
173
+ .app {
174
+ max-width: 1440px;
175
+ margin: 0 auto;
176
+ padding: 28px clamp(20px, 4vw, 60px) 60px;
177
+ }
178
+ /* Header */
179
+ .header {
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: space-between;
183
+ gap: 24px;
184
+ margin-bottom: 24px;
185
+ flex-wrap: wrap;
186
+ }
187
+ .brand {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 6px;
191
+ min-width: 0;
192
+ }
193
+ .brand-row {
194
+ display: flex;
195
+ align-items: baseline;
196
+ gap: 12px;
197
+ flex-wrap: wrap;
198
+ }
199
+ .brand-name {
200
+ font-size: clamp(1.75rem, 2.6vw, 2.25rem);
201
+ font-weight: 800;
202
+ letter-spacing: -0.02em;
203
+ color: var(--text);
204
+ }
205
+ .brand-name .accent { color: var(--primary); }
206
+ .brand-tag {
207
+ color: var(--muted);
208
+ font-size: clamp(0.95rem, 1.3vw, 1.08rem);
209
+ font-weight: 500;
210
+ }
211
+ .brand-meta {
212
+ display: flex;
213
+ gap: 14px;
214
+ color: var(--muted);
215
+ font-size: 0.78rem;
216
+ flex-wrap: wrap;
217
+ }
218
+ .brand-meta span::before { content: "·"; margin-right: 14px; color: var(--border-strong); }
219
+ .brand-meta span:first-child::before { content: ""; margin: 0; }
220
+ .actions { display: flex; gap: 8px; flex-shrink: 0; }
221
+ .pill {
222
+ display: inline-flex;
223
+ align-items: center;
224
+ gap: 6px;
225
+ min-height: 38px;
226
+ padding: 0 14px;
227
+ border: 1px solid var(--border-strong);
228
+ border-radius: 999px;
229
+ background: var(--surface);
230
+ color: var(--text);
231
+ font-size: 0.8rem;
232
+ font-weight: 600;
233
+ text-decoration: none;
234
+ transition: border-color 150ms ease, background 150ms ease;
235
+ }
236
+ .pill:hover { border-color: var(--primary); background: var(--primary-soft); }
237
+ .pill svg { width: 14px; height: 14px; }
238
+
239
+ /* Layout */
240
+ .workspace {
241
+ display: grid;
242
+ grid-template-columns: minmax(320px, 1fr) minmax(380px, 1.55fr);
243
+ gap: 18px;
244
+ margin-bottom: 36px;
245
+ }
246
+ @media (max-width: 980px) { .workspace { grid-template-columns: 1fr; } }
247
+
248
+ .panel {
249
+ background: var(--surface);
250
+ border: 1px solid var(--border);
251
+ border-radius: var(--radius);
252
+ box-shadow: var(--shadow);
253
+ overflow: hidden;
254
+ }
255
+ .panel-head {
256
+ display: flex;
257
+ align-items: center;
258
+ justify-content: space-between;
259
+ padding: 16px 18px 14px;
260
+ border-bottom: 1px solid var(--border);
261
+ gap: 12px;
262
+ }
263
+ .panel-title {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 10px;
267
+ font-size: 0.9rem;
268
+ font-weight: 700;
269
+ }
270
+ .panel-title .idx {
271
+ color: var(--accent);
272
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
273
+ font-size: 0.72rem;
274
+ letter-spacing: 0.05em;
275
+ }
276
+ .panel-hint {
277
+ color: var(--muted);
278
+ font-size: 0.72rem;
279
+ text-align: right;
280
+ flex: 1;
281
+ }
282
+ .panel-body { padding: 16px 18px 18px; }
283
+
284
+ /* Drop zone */
285
+ .drop {
286
+ position: relative;
287
+ min-height: 480px;
288
+ border: 1.5px dashed var(--border-strong);
289
+ border-radius: 8px;
290
+ background: var(--surface-muted);
291
+ display: flex;
292
+ align-items: center;
293
+ justify-content: center;
294
+ overflow: hidden;
295
+ transition: border-color 150ms ease, background 150ms ease;
296
+ cursor: pointer;
297
+ }
298
+ .drop.dragover { border-color: var(--primary); background: var(--primary-soft); }
299
+ .drop.has-image { border-style: solid; border-color: var(--border); cursor: default; }
300
+ .drop input[type="file"] {
301
+ position: absolute; inset: 0; opacity: 0; cursor: pointer;
302
+ }
303
+ .drop.has-image input[type="file"] { pointer-events: none; }
304
+ .drop-empty {
305
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
306
+ color: var(--muted); text-align: center; padding: 24px;
307
+ }
308
+ .drop-empty .icon {
309
+ width: 44px; height: 44px; border-radius: 12px;
310
+ background: var(--surface);
311
+ display: grid; place-items: center;
312
+ border: 1px solid var(--border);
313
+ }
314
+ .drop-empty .icon svg { width: 22px; height: 22px; color: var(--primary); }
315
+ .drop-empty strong { color: var(--text); font-weight: 600; font-size: 0.95rem; }
316
+ .drop-empty span { font-size: 0.78rem; }
317
+ .drop-preview {
318
+ width: 100%; height: 100%; object-fit: contain; display: block;
319
+ background: #0e1110;
320
+ }
321
+ .drop-overlay {
322
+ position: absolute; inset: auto 12px 12px 12px;
323
+ display: flex; justify-content: space-between; gap: 8px;
324
+ }
325
+ .chip {
326
+ display: inline-flex; align-items: center; gap: 6px;
327
+ padding: 6px 10px; border-radius: 999px;
328
+ background: rgba(15, 17, 16, 0.65); color: #fff;
329
+ font-size: 0.72rem; backdrop-filter: blur(6px);
330
+ }
331
+ .chip button {
332
+ border: 0; background: transparent; color: inherit;
333
+ cursor: pointer; font: inherit; padding: 0;
334
+ }
335
+ .chip button:hover { color: #fca5a5; }
336
+
337
+ /* Controls */
338
+ .controls { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
339
+ .primary-btn {
340
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
341
+ width: 100%; min-height: 48px;
342
+ border: 0; border-radius: 8px;
343
+ background: var(--primary); color: #fff;
344
+ font: 600 0.95rem 'Inter', sans-serif;
345
+ cursor: pointer;
346
+ box-shadow: 0 4px 12px rgba(8, 127, 98, 0.18);
347
+ transition: background 150ms ease, transform 80ms ease;
348
+ }
349
+ .primary-btn:hover { background: var(--primary-hover); }
350
+ .primary-btn:active { transform: translateY(1px); }
351
+ .primary-btn:disabled { background: var(--border-strong); cursor: not-allowed; box-shadow: none; }
352
+ .primary-btn .spinner {
353
+ width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.4);
354
+ border-top-color: #fff; border-radius: 50%;
355
+ animation: spin 0.8s linear infinite;
356
+ }
357
+ @keyframes spin { to { transform: rotate(360deg); } }
358
+
359
+ /* Viewer */
360
+ .viewer-shell {
361
+ position: relative;
362
+ width: 100%;
363
+ aspect-ratio: 16 / 10;
364
+ min-height: 480px;
365
+ border-radius: 8px;
366
+ overflow: hidden;
367
+ background: var(--viewer);
368
+ }
369
+ .viewer-iframe {
370
+ width: 100%; height: 100%; border: 0; display: block; opacity: 0;
371
+ transition: opacity 200ms ease;
372
+ }
373
+ .viewer-iframe.ready { opacity: 1; }
374
+ .viewer-overlay {
375
+ position: absolute; inset: 0;
376
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
377
+ gap: 10px; color: #f4f7f6; text-align: center; padding: 24px;
378
+ transition: opacity 200ms ease, visibility 200ms ease;
379
+ }
380
+ .viewer-overlay.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
381
+ .viewer-overlay .ring {
382
+ width: 38px; height: 38px;
383
+ border: 3px solid #35413d; border-top-color: #34d399;
384
+ border-radius: 50%; animation: spin 0.9s linear infinite;
385
+ }
386
+ .viewer-overlay .bar { width: 36px; height: 3px; background: #f87171; border-radius: 2px; }
387
+ .viewer-overlay .idle { width: 36px; height: 2px; background: #46514e; border-radius: 2px; }
388
+ .viewer-overlay strong { font-weight: 650; font-size: 0.95rem; }
389
+ .viewer-overlay span { color: #94a19d; font-size: 0.78rem; }
390
+ .viewer-overlay .progress {
391
+ width: 220px; height: 4px; background: #1f2926; border-radius: 999px; overflow: hidden;
392
+ margin-top: 4px;
393
+ }
394
+ .viewer-overlay .progress > div {
395
+ height: 100%; background: #34d399; width: 0%;
396
+ transition: width 250ms ease;
397
+ }
398
+
399
+ /* Download row */
400
+ .downloads {
401
+ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px;
402
+ }
403
+ .dl {
404
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
405
+ min-height: 44px; padding: 0 12px;
406
+ border: 1px solid var(--border-strong); border-radius: 8px;
407
+ background: var(--surface-muted); color: var(--text);
408
+ font: 600 0.82rem 'Inter', sans-serif;
409
+ text-decoration: none; cursor: pointer;
410
+ transition: border-color 150ms ease, background 150ms ease;
411
+ }
412
+ .dl:hover { border-color: var(--accent); background: #fff; }
413
+ .dl.ready { border-color: #8ac6b5; background: var(--primary-soft); color: #075e49; }
414
+ .dl.ready:hover { border-color: var(--primary); background: #e3f4ee; }
415
+ .dl:disabled, .dl[aria-disabled="true"] {
416
+ opacity: 0.55; cursor: not-allowed; background: var(--surface-muted);
417
+ }
418
+ .dl svg { width: 14px; height: 14px; }
419
+
420
+ /* Examples */
421
+ .examples-head {
422
+ display: flex; align-items: baseline; justify-content: space-between;
423
+ margin-bottom: 12px; padding: 0 2px;
424
+ }
425
+ .examples-head h2 { font-size: 0.9rem; font-weight: 700; }
426
+ .examples-head span { color: var(--muted); font-size: 0.78rem; }
427
+ .gallery {
428
+ display: grid;
429
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
430
+ gap: 10px;
431
+ }
432
+ .thumb {
433
+ position: relative; aspect-ratio: 4 / 3;
434
+ border-radius: 8px; overflow: hidden; cursor: pointer;
435
+ border: 1px solid var(--border); background: var(--surface);
436
+ transition: transform 120ms ease, border-color 120ms ease;
437
+ }
438
+ .thumb:hover { transform: translateY(-2px); border-color: var(--primary); }
439
+ .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
440
+ .thumb .label {
441
+ position: absolute; inset: auto 0 0 0;
442
+ padding: 16px 10px 8px;
443
+ background: linear-gradient(to top, rgba(0,0,0,0.55), transparent);
444
+ color: #fff; font-size: 0.72rem; font-weight: 500;
445
+ }
446
+
447
+ /* Toast */
448
+ .toast {
449
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
450
+ background: #17201e; color: #fff; padding: 10px 16px; border-radius: 8px;
451
+ font-size: 0.85rem; box-shadow: 0 10px 30px rgba(0,0,0,0.2);
452
+ opacity: 0; transition: opacity 200ms ease, transform 200ms ease;
453
+ pointer-events: none; z-index: 100;
454
+ }
455
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(-4px); }
456
+ .toast.error { background: var(--danger); }
457
+
458
+ /* Spinner utilities */
459
+ .hidden { display: none !important; }
460
+ </style>
461
+ </head>
462
+ <body>
463
+ <div class="app">
464
+ <header class="header">
465
+ <div class="brand">
466
+ <div class="brand-row">
467
+ <div class="brand-name">Infini<span class="accent">Splat</span></div>
468
+ <div class="brand-tag">Implicit Gaussian decoding for monocular view synthesis</div>
469
+ </div>
470
+ <div class="brand-meta">
471
+ <span>Upload one photo</span>
472
+ <span>Get a Gaussian splat scene</span>
473
+ <span>Export to PLY or HTML</span>
474
+ </div>
475
+ </div>
476
+ <div class="actions">
477
+ <a class="pill" href="https://github.com/zju3dv/InfiniSplat" target="_blank" rel="noopener">
478
+ <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>
479
+ GitHub
480
+ </a>
481
+ <a class="pill" href="https://zju3dv.github.io/InfiniSplat" target="_blank" rel="noopener">
482
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M12 3v18"/></svg>
483
+ Project page
484
+ </a>
485
+ </div>
486
+ </header>
487
+
488
+ <section class="workspace">
489
+ <div class="panel">
490
+ <div class="panel-head">
491
+ <div class="panel-title"><span class="idx">01</span> Input image</div>
492
+ <div class="panel-hint">Better for indoor scenes (HyperSim training)</div>
493
+ </div>
494
+ <div class="panel-body">
495
+ <label class="drop" id="drop">
496
+ <input type="file" id="file" accept="image/*" />
497
+ <div class="drop-empty" id="dropEmpty">
498
+ <div class="icon">
499
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
500
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
501
+ <polyline points="17 8 12 3 7 8"/>
502
+ <line x1="12" y1="3" x2="12" y2="15"/>
503
+ </svg>
504
+ </div>
505
+ <strong>Drop or click to upload</strong>
506
+ <span>JPG, PNG, or WebP — pick a clear subject</span>
507
+ </div>
508
+ <img class="drop-preview hidden" id="preview" alt="" />
509
+ </label>
510
+ <div class="controls">
511
+ <button class="primary-btn" id="runBtn" disabled>
512
+ <span class="btn-label">Reconstruct scene</span>
513
+ </button>
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <div class="panel">
519
+ <div class="panel-head">
520
+ <div class="panel-title"><span class="idx">02</span> Scene viewer</div>
521
+ <div class="panel-hint">Interactive · drag to orbit · scroll to zoom</div>
522
+ </div>
523
+ <div class="panel-body">
524
+ <div class="viewer-shell" id="viewerShell">
525
+ <div class="viewer-overlay" id="viewerIdle">
526
+ <div class="idle"></div>
527
+ <strong>Ready</strong>
528
+ <span>Upload an image to begin</span>
529
+ </div>
530
+ <div class="viewer-overlay hidden" id="viewerLoading">
531
+ <div class="ring"></div>
532
+ <strong id="loadingTitle">Reconstructing scene</strong>
533
+ <span id="loadingDetail">Running model inference</span>
534
+ <div class="progress"><div id="progressBar"></div></div>
535
+ </div>
536
+ <div class="viewer-overlay hidden" id="viewerError">
537
+ <div class="bar"></div>
538
+ <strong>Reconstruction stopped</strong>
539
+ <span id="errorDetail">See console for details</span>
540
+ </div>
541
+ <iframe class="viewer-iframe" id="viewerFrame" title="Gaussian scene"></iframe>
542
+ </div>
543
+ <div class="downloads">
544
+ <button class="dl" id="dlPly" disabled aria-disabled="true">
545
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="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>
546
+ <span class="dl-label">Download PLY</span>
547
+ </button>
548
+ <button class="dl" id="dlHtml" disabled aria-disabled="true">
549
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="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>
550
+ <span class="dl-label">Download HTML viewer</span>
551
+ </button>
552
+ </div>
553
+ </div>
554
+ </div>
555
+ </section>
556
+
557
+ <section>
558
+ <div class="examples-head">
559
+ <h2>Examples</h2>
560
+ <span id="examplesCount">— scenes</span>
561
+ </div>
562
+ <div class="gallery" id="gallery"></div>
563
+ </section>
564
+ </div>
565
+
566
+ <div class="toast" id="toast"></div>
567
+
568
+ <script type="module">
569
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
570
+
571
+ const $ = (id) => document.getElementById(id);
572
+ const fileInput = $("file"), drop = $("drop"), preview = $("preview"),
573
+ dropEmpty = $("dropEmpty"), runBtn = $("runBtn"),
574
+ viewerShell = $("viewerShell"), viewerFrame = $("viewerFrame"),
575
+ viewerIdle = $("viewerIdle"), viewerLoading = $("viewerLoading"),
576
+ viewerError = $("viewerError"), loadingTitle = $("loadingTitle"),
577
+ loadingDetail = $("loadingDetail"), progressBar = $("progressBar"),
578
+ errorDetail = $("errorDetail"),
579
+ dlPly = $("dlPly"), dlHtml = $("dlHtml"),
580
+ gallery = $("gallery"), examplesCount = $("examplesCount"),
581
+ toast = $("toast");
582
+
583
+ let pendingFile = null;
584
+ let lastPlyUrl = null;
585
+ let lastHtmlUrl = null;
586
+
587
+ function toastMsg(msg, isError) {
588
+ toast.textContent = msg;
589
+ toast.classList.toggle("error", !!isError);
590
+ toast.classList.add("show");
591
+ clearTimeout(toast._t);
592
+ toast._t = setTimeout(() => toast.classList.remove("show"), 2400);
593
+ }
594
+
595
+ function setViewerState(state, opts) {
596
+ opts = opts || {};
597
+ viewerIdle.classList.toggle("hidden", state !== "idle");
598
+ viewerLoading.classList.toggle("hidden", state !== "loading");
599
+ viewerError.classList.toggle("hidden", state !== "error");
600
+ if (state === "loading") {
601
+ loadingTitle.textContent = opts.title || "Reconstructing scene";
602
+ loadingDetail.textContent = opts.detail || "Running model inference";
603
+ progressBar.style.width = (opts.progress || 0) + "%";
604
+ }
605
+ if (state === "error") errorDetail.textContent = opts.detail || "Unknown error";
606
+ }
607
+
608
+ function setReady(ready) {
609
+ runBtn.disabled = !ready;
610
+ const lbl = runBtn.querySelector(".btn-label");
611
+ lbl.textContent = ready ? "Reconstruct scene" : "Reconstructing…";
612
+ if (ready) {
613
+ const sp = runBtn.querySelector(".spinner");
614
+ if (sp) sp.remove();
615
+ } else {
616
+ if (!runBtn.querySelector(".spinner")) {
617
+ const sp = document.createElement("span");
618
+ sp.className = "spinner";
619
+ runBtn.insertBefore(sp, lbl);
620
+ }
621
+ }
622
+ }
623
+
624
+ function setDownload(btn, url, label) {
625
+ btn.disabled = !url;
626
+ btn.setAttribute("aria-disabled", String(!url));
627
+ const lbl = btn.querySelector(".dl-label");
628
+ lbl.textContent = label;
629
+ btn.classList.toggle("ready", !!url);
630
+ if (url) {
631
+ btn.onclick = () => {
632
+ const a = document.createElement("a");
633
+ a.href = url; a.download = ""; a.click();
634
+ };
635
+ } else {
636
+ btn.onclick = null;
637
+ }
638
+ }
639
+
640
+ function showPreview(file) {
641
+ const url = URL.createObjectURL(file);
642
+ preview.src = url;
643
+ preview.classList.remove("hidden");
644
+ dropEmpty.classList.add("hidden");
645
+ drop.classList.add("has-image");
646
+ // revoke later to free memory
647
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
648
+ }
649
+
650
+ function clearPreview() {
651
+ pendingFile = null;
652
+ preview.src = "";
653
+ preview.classList.add("hidden");
654
+ dropEmpty.classList.remove("hidden");
655
+ drop.classList.remove("has-image");
656
+ setReady(false);
657
+ }
658
+
659
+ fileInput.addEventListener("change", (e) => {
660
+ const f = e.target.files && e.target.files[0];
661
+ if (f) {
662
+ pendingFile = f;
663
+ showPreview(f);
664
+ setReady(true);
665
+ }
666
+ });
667
+
668
+ ["dragenter", "dragover"].forEach(ev =>
669
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("dragover"); })
670
+ );
671
+ ["dragleave", "drop"].forEach(ev =>
672
+ drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("dragover"); })
673
+ );
674
+ drop.addEventListener("drop", (e) => {
675
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
676
+ if (f && f.type.startsWith("image/")) {
677
+ pendingFile = f;
678
+ showPreview(f);
679
+ setReady(true);
680
+ }
681
+ });
682
+
683
+ // Connect to backend
684
+ let client;
685
+ try {
686
+ client = await Client.connect(window.location.origin);
687
+ } catch (err) {
688
+ toastMsg("Failed to connect to backend", true);
689
+ console.error(err);
690
+ }
691
+
692
+ // Preload viewer template so the iframe swaps in instantly
693
+ try {
694
+ const pre = await client.predict("/viewer_html", {});
695
+ if (pre && pre.data && pre.data[0]) {
696
+ const url = pre.data[0].url;
697
+ viewerFrame.src = url + "#preload";
698
+ }
699
+ } catch (err) {
700
+ console.warn("Viewer preload skipped:", err);
701
+ }
702
+
703
+ async function runPipeline() {
704
+ if (!pendingFile || !client) return;
705
+ setReady(false);
706
+ setDownload(dlPly, null, "Preparing PLY…");
707
+ setDownload(dlHtml, null, "Preparing HTML…");
708
+ viewerFrame.classList.remove("ready");
709
+
710
+ try {
711
+ setViewerState("loading", { title: "Reconstructing scene", detail: "Running model inference", progress: 15 });
712
+ const recon = await client.predict("/reconstruct", { image_path: handle_file(pendingFile) });
713
+ const artifactUrl = recon.data[0].url;
714
+ const artifactResp = await fetch(artifactUrl);
715
+ const artifactBlob = await artifactResp.blob();
716
+ const artifactFile = new File([artifactBlob], "gaussians.pt");
717
+
718
+ setViewerState("loading", { title: "Preparing PLY", detail: "Filtering Gaussians", progress: 45 });
719
+ const ply = await client.predict("/export_ply", { artifact: handle_file(artifactFile) });
720
+ const plyUrl = ply.data[0].url;
721
+ lastPlyUrl = plyUrl;
722
+ setDownload(dlPly, plyUrl, "Download PLY — Ready");
723
+
724
+ setViewerState("loading", { title: "Encoding viewer", detail: "Building WebGL scene", progress: 70 });
725
+ const view = await client.predict("/export_viewer", { scene_ply: handle_file(await (await fetch(plyUrl)).blob()) });
726
+ const viewerUrl = view.data[0].url + "?v=" + Date.now();
727
+ viewerFrame.src = viewerUrl;
728
+ viewerFrame.onload = () => {
729
+ viewerFrame.classList.add("ready");
730
+ setViewerState("idle");
731
+ };
732
+
733
+ setViewerState("loading", { title: "Bundling HTML", detail: "Embedding assets for download", progress: 92 });
734
+ const html = await client.predict("/export_html", {
735
+ viewer_html: handle_file(await (await fetch(viewerUrl)).blob())
736
+ });
737
+ const htmlUrl = html.data[0].url;
738
+ lastHtmlUrl = htmlUrl;
739
+ setDownload(dlHtml, htmlUrl, "Download HTML viewer — Ready");
740
+
741
+ setViewerState("idle");
742
+ toastMsg("Reconstruction complete");
743
+ } catch (err) {
744
+ console.error(err);
745
+ setViewerState("error", { detail: err.message || "Pipeline failed" });
746
+ toastMsg(err.message || "Reconstruction failed", true);
747
+ } finally {
748
+ setReady(true);
749
+ }
750
+ }
751
+
752
+ runBtn.addEventListener("click", runPipeline);
753
+
754
+ // Examples — pull from /gradio_api examples endpoint or static list
755
+ // We fall back to a curated list of bundled examples (HF Spaces serves /examples).
756
+ const FALLBACK_EXAMPLES = [
757
+ { label: "Bedroom", path: "/__examples/bedroom.jpg" },
758
+ { label: "Living room", path: "/__examples/living_room.jpg" },
759
+ { label: "Loft", path: "/__examples/loft_room.jpg" },
760
+ { label: "Gym", path: "/__examples/gym.png" },
761
+ { label: "Meerkat", path: "/__examples/meerkat.jpg" },
762
+ { label: "Painting", path: "/__examples/painting_room.jpg" },
763
+ { label: "Ghibli room", path: "/__examples/ghibli_room.jpg" },
764
+ { label: "Sofa", path: "/__examples/sofa_ai.jpg" },
765
+ ];
766
+ gallery.innerHTML = FALLBACK_EXAMPLES.map(ex => `
767
+ <div class="thumb" data-path="${ex.path}" title="${ex.label}">
768
+ <img loading="lazy" src="${ex.path}" alt="${ex.label}" />
769
+ <div class="label">${ex.label}</div>
770
+ </div>
771
+ `).join("");
772
+ examplesCount.textContent = `${FALLBACK_EXAMPLES.length} scenes`;
773
+
774
+ gallery.addEventListener("click", async (e) => {
775
+ const t = e.target.closest(".thumb");
776
+ if (!t) return;
777
+ const path = t.dataset.path;
778
+ try {
779
+ const resp = await fetch(path);
780
+ const blob = await resp.blob();
781
+ const f = new File([blob], path.split("/").pop(), { type: blob.type });
782
+ pendingFile = f;
783
+ showPreview(f);
784
+ setReady(true);
785
+ toastMsg("Example loaded — ready to reconstruct");
786
+ } catch (err) {
787
+ toastMsg("Failed to load example", true);
788
+ }
789
+ });
790
+ </script>
791
+ </body>
792
+ </html>
793
+ """
794
+
795
+
796
+ @app.get("/", response_class=HTMLResponse)
797
+ async def homepage() -> str:
798
+ return INDEX_HTML
799
+
800
+
801
+ @app.get("/__examples/{name}")
802
+ async def serve_example(name: str):
803
+ """Serve curated example images from the bundled examples directory."""
804
+ from fastapi.responses import FileResponse
805
+
806
+ examples_dir = Path(__file__).resolve().parent / "examples" / "data" / "rgb_demo"
807
+ candidate = examples_dir / name
808
+ if not candidate.is_file() or not str(candidate.resolve()).startswith(str(examples_dir.resolve())):
809
+ from fastapi import HTTPException
810
+
811
+ raise HTTPException(status_code=404, detail="Example not found")
812
+ return FileResponse(candidate)
813
+
814
+
815
+ def _cleanup() -> None:
816
+ """Remove expired per-request directories on shutdown."""
817
+ if not OUTPUT_ROOT.is_dir():
818
+ return
819
+ cutoff = time.time() - 3600
820
+ for request_dir in OUTPUT_ROOT.iterdir():
821
+ if request_dir.is_symlink() or not request_dir.is_dir():
822
+ continue
823
+ try:
824
+ if uuid.UUID(hex=request_dir.name).hex != request_dir.name:
825
+ continue
826
+ except ValueError:
827
+ continue
828
+ if request_dir.lstat().st_mtime > cutoff:
829
+ continue
830
+ shutil.rmtree(request_dir, ignore_errors=True)
831
+
832
+
833
+ import atexit as _atexit
834
+
835
+ _atexit.register(_cleanup)
836
 
837
 
838
  if __name__ == "__main__":
839
+ app.launch(
840
  server_name="0.0.0.0",
841
  server_port=int(os.environ.get("PORT", "7860")),
842
  allowed_paths=[str(OUTPUT_ROOT)],
843
  max_file_size="20mb",
844
+ show_error=True,
845
  ssr_mode=False,
846
  footer_links=[],
847
+ )