gradio server

#1
by akhaliq HF Staff - opened
Files changed (3) hide show
  1. app.py +122 -96
  2. index.html +1254 -0
  3. requirements.txt +1 -0
app.py CHANGED
@@ -1,16 +1,20 @@
1
- """TripoSplat Gradio demo with Spark.js in-browser viewer.
2
  Usage: python app.py
3
  """
4
  import base64
 
5
  import subprocess
6
  import tempfile
7
  import time
8
  from pathlib import Path
9
  from uuid import uuid4
10
 
11
- import gradio as gr
12
  import spaces
13
  import torch
 
 
 
 
14
 
15
  from triposplat import TripoSplatPipeline
16
  import example_inputs_b64 as _b64
@@ -41,145 +45,167 @@ PIPE = TripoSplatPipeline(
41
  device = "cuda",
42
  )
43
 
44
- OUT_ROOT = Path("gradio_outputs").resolve()
45
  OUT_ROOT.mkdir(parents=True, exist_ok=True)
46
- VIEWER_HTML = Path("static/viewer/viewer.html").resolve()
47
 
48
  # Decode example images from base64 into a persistent temp directory so that
49
- # gr.Examples (which needs file paths) works without binary files in the repo.
50
  _EXAMPLES_TMPDIR = tempfile.mkdtemp(prefix="triposplat_examples_")
 
 
51
  def _write_example(varname: str, filename: str) -> str:
52
  path = Path(_EXAMPLES_TMPDIR) / filename
53
  path.write_bytes(base64.b64decode(getattr(_b64, varname)))
54
  return str(path)
55
 
 
56
  EXAMPLES = [
57
- _write_example("CREATURE_BUTTERFLY", "creature_butterfly.webp"),
58
- _write_example("BUILDING_STONE_HOUSE", "building_stone_house.webp"),
59
- _write_example("VEHICLE_PIRATE_SHIP", "vehicle_pirate_ship.webp"),
60
- _write_example("PLANT_WATER_LILY", "plant_water_lily.webp"),
61
  ]
62
 
63
- PLACEHOLDER_HTML = (
64
- "<div style='display:flex;align-items:center;justify-content:center;height:520px;"
65
- "color:#94a3b8;font:16px system-ui;background:#111318;border-radius:12px'>"
66
- "3D viewer will appear here after generation</div>"
67
- )
68
 
 
69
 
70
- def _gr_file(path: Path) -> str:
71
- """Gradio serves any file under `allowed_paths` at `/gradio_api/file=<abspath>`."""
72
- return f"/gradio_api/file={path.as_posix()}"
73
 
 
74
 
75
- def _viewer_iframe(ply_path: Path) -> str:
76
- ts = time.time() # cache-bust so the iframe reloads each generation
77
- src = f"{_gr_file(VIEWER_HTML)}?ply={_gr_file(ply_path)}&ts={ts}"
78
- return (
79
- f"<iframe src='{src}' "
80
- "style='width:100%;height:520px;border:0;border-radius:12px;background:#0a0b0e'></iframe>"
 
 
 
 
 
 
 
 
81
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  # ----------------------------------------------------------------------------
85
- # Event handlers
86
  # ----------------------------------------------------------------------------
87
 
88
  @spaces.GPU
89
- def generate(image, seed: int, steps: int, guidance_scale: float,
90
- num_gaussians: int, output_format: str,
91
- progress=gr.Progress(track_tqdm=True)):
92
- """Run the full pipeline (preprocess + encode + sample + decode) in a
93
- single GPU acquisition."""
94
- if image is None:
95
- raise gr.Error("Please upload an image first.")
96
-
97
- progress(0, desc="Generating...")
98
  t0 = time.time()
99
- prepared = PIPE.preprocess_image(image)
100
  gen = torch.Generator(device=PIPE._device).manual_seed(int(seed))
101
  cond = PIPE.encode_image(prepared, generator=gen)
102
- out = PIPE.sample_latent(cond, steps=int(steps),
103
- guidance_scale=float(guidance_scale),
104
- generator=gen, show_progress=True)
 
 
 
 
105
  gaussian = PIPE.decode_latent(out["latent"], num_gaussians=int(num_gaussians))
106
  gen_dt = time.time() - t0
107
 
108
- out_dir = OUT_ROOT / uuid4().hex[:12]
109
- out_dir.mkdir(parents=True, exist_ok=True)
 
 
 
110
  ply_path = out_dir / "splat.ply"
111
  gaussian.save_ply(str(ply_path))
112
 
 
113
  fmt = output_format.lower()
114
- if fmt == "ply":
115
- download_path = ply_path
116
- elif fmt == "splat":
117
  download_path = out_dir / "splat.splat"
118
  gaussian.save_splat(str(download_path))
119
  else:
120
- raise gr.Error(f"Unknown output format: {output_format}")
121
 
122
- info = (f"{gaussian.get_xyz.shape[0]:,} gaussians · "
123
- f"generation: {gen_dt:.1f}s · saved: {download_path.name}")
124
- return prepared, _viewer_iframe(ply_path), gr.update(value=str(download_path), interactive=True), info
 
125
 
126
 
127
  # ----------------------------------------------------------------------------
128
- # Gradio UI
129
  # ----------------------------------------------------------------------------
130
 
131
- with gr.Blocks(title="TripoSplat") as demo:
132
- gr.Markdown("# TripoSplat")
133
- gr.Markdown(
134
- "TripoSplat converts a single 2D image into high-quality and variable number of 3D Gaussians, developed by [TripoAI](https://www.tripo3d.ai/). "
135
- "It can serve as a powerful pipeline tool for asset creation, AR/VR, game development, simulation environments, and beyond.\n\n"
136
- "[Read Paper](https://arxiv.org/abs/2605.16355) | [Technical Blog](https://www.tripo3d.ai/research/triposplat) | [GitHub](https://github.com/VAST-AI-Research/TripoSplat)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  )
138
 
139
- with gr.Row():
140
- with gr.Column(scale=1):
141
- image_in = gr.Image(label="Input image", type="pil", image_mode="RGBA",
142
- height=320)
143
-
144
- gr.Examples(
145
- examples=[[p] for p in EXAMPLES],
146
- inputs=[image_in],
147
- label="Examples (click to load)",
148
- examples_per_page=10,
149
- cache_examples=False,
150
- )
151
-
152
- with gr.Accordion("Sampling settings", open=False):
153
- seed_in = gr.Number(label="Seed", value=42, precision=0)
154
- steps_in = gr.Slider(label="Inference steps", minimum=1, maximum=50, step=1, value=20)
155
- cfg_in = gr.Slider(label="Guidance scale", minimum=1.0, maximum=10.0, step=0.5, value=3.0)
156
- num_g_in = gr.Dropdown(
157
- label="Number of gaussians",
158
- choices=["32768", "65536", "131072", "262144"],
159
- value="262144",
160
- )
161
- fmt_in = gr.Dropdown(label="Download format", choices=["ply", "splat"], value="ply")
162
-
163
- run_btn = gr.Button("Generate", variant="primary")
164
- prepared_out = gr.Image(label="Preprocessed input", interactive=False, height=240)
165
- info_out = gr.Markdown()
166
-
167
- with gr.Column(scale=2):
168
- viewer_out = gr.HTML(value=PLACEHOLDER_HTML, label="Spark.js viewer")
169
- file_out = gr.DownloadButton(label="Download", value=None, interactive=False)
170
-
171
- run_btn.click(
172
- fn=generate,
173
- inputs=[image_in, seed_in, steps_in, cfg_in, num_g_in, fmt_in],
174
- outputs=[prepared_out, viewer_out, file_out, info_out],
175
  )
176
 
177
 
 
 
 
 
178
  if __name__ == "__main__":
179
- demo.launch(
180
- allowed_paths=[
181
- str(VIEWER_HTML.parent),
182
- str(OUT_ROOT),
183
- _EXAMPLES_TMPDIR,
184
- ],
185
- )
 
1
+ """TripoSplat gradio.Server with custom frontend.
2
  Usage: python app.py
3
  """
4
  import base64
5
+ import os
6
  import subprocess
7
  import tempfile
8
  import time
9
  from pathlib import Path
10
  from uuid import uuid4
11
 
 
12
  import spaces
13
  import torch
14
+ from PIL import Image
15
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
16
+ from gradio import Server
17
+ from gradio.data_classes import FileData
18
 
19
  from triposplat import TripoSplatPipeline
20
  import example_inputs_b64 as _b64
 
45
  device = "cuda",
46
  )
47
 
48
+ OUT_ROOT = Path("gradio_outputs").resolve()
49
  OUT_ROOT.mkdir(parents=True, exist_ok=True)
 
50
 
51
  # Decode example images from base64 into a persistent temp directory so that
52
+ # the custom frontend can serve them via FastAPI routes.
53
  _EXAMPLES_TMPDIR = tempfile.mkdtemp(prefix="triposplat_examples_")
54
+
55
+
56
  def _write_example(varname: str, filename: str) -> str:
57
  path = Path(_EXAMPLES_TMPDIR) / filename
58
  path.write_bytes(base64.b64decode(getattr(_b64, varname)))
59
  return str(path)
60
 
61
+
62
  EXAMPLES = [
63
+ {"name": "Creature Butterfly", "file": _write_example("CREATURE_BUTTERFLY", "creature_butterfly.webp")},
64
+ {"name": "Building Stone House","file": _write_example("BUILDING_STONE_HOUSE", "building_stone_house.webp")},
65
+ {"name": "Vehicle Pirate Ship", "file": _write_example("VEHICLE_PIRATE_SHIP", "vehicle_pirate_ship.webp")},
66
+ {"name": "Plant Water Lily", "file": _write_example("PLANT_WATER_LILY", "plant_water_lily.webp")},
67
  ]
68
 
69
+ # ----------------------------------------------------------------------------
70
+ # gradio.Server
71
+ # ----------------------------------------------------------------------------
 
 
72
 
73
+ app = Server()
74
 
 
 
 
75
 
76
+ # ---- Static pages ----------------------------------------------------------
77
 
78
+ @app.get("/")
79
+ async def homepage():
80
+ """Serve the custom frontend."""
81
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
82
+ with open(html_path, "r", encoding="utf-8") as f:
83
+ return HTMLResponse(f.read())
84
+
85
+
86
+ @app.get("/viewer")
87
+ async def viewer_page():
88
+ """Serve the Spark.js 3D viewer (loaded inside an iframe)."""
89
+ viewer_path = os.path.join(
90
+ os.path.dirname(os.path.abspath(__file__)),
91
+ "static", "viewer", "viewer.html",
92
  )
93
+ with open(viewer_path, "r", encoding="utf-8") as f:
94
+ return HTMLResponse(f.read())
95
+
96
+
97
+ # ---- Example images --------------------------------------------------------
98
+
99
+ @app.get("/api/examples")
100
+ async def get_examples():
101
+ """Return a JSON list of example images the frontend can display."""
102
+ return JSONResponse([
103
+ {"name": ex["name"], "url": f"/api/example/{i}"}
104
+ for i, ex in enumerate(EXAMPLES)
105
+ ])
106
+
107
+
108
+ @app.get("/api/example/{idx}")
109
+ async def get_example(idx: int):
110
+ """Serve an individual example image by index."""
111
+ if 0 <= idx < len(EXAMPLES):
112
+ return FileResponse(EXAMPLES[idx]["file"], media_type="image/webp")
113
+ return JSONResponse({"error": "not found"}, status_code=404)
114
 
115
 
116
  # ----------------------------------------------------------------------------
117
+ # GPU pipeline helper
118
  # ----------------------------------------------------------------------------
119
 
120
  @spaces.GPU
121
+ def _run_pipeline(pil_image, seed, steps, guidance_scale, num_gaussians,
122
+ out_dir, output_format):
123
+ """Run the full pipeline (preprocess → encode → sample → decode → save)
124
+ in a single GPU acquisition.
125
+
126
+ All file I/O happens here so the unpicklable Gaussian object never
127
+ crosses the ZeroGPU multiprocessing boundary.
128
+ """
 
129
  t0 = time.time()
130
+ prepared = PIPE.preprocess_image(pil_image)
131
  gen = torch.Generator(device=PIPE._device).manual_seed(int(seed))
132
  cond = PIPE.encode_image(prepared, generator=gen)
133
+ out = PIPE.sample_latent(
134
+ cond,
135
+ steps=int(steps),
136
+ guidance_scale=float(guidance_scale),
137
+ generator=gen,
138
+ show_progress=True,
139
+ )
140
  gaussian = PIPE.decode_latent(out["latent"], num_gaussians=int(num_gaussians))
141
  gen_dt = time.time() - t0
142
 
143
+ # Save preprocessed image
144
+ prep_path = out_dir / "preprocessed.png"
145
+ prepared.save(str(prep_path))
146
+
147
+ # Save PLY (always needed for the viewer)
148
  ply_path = out_dir / "splat.ply"
149
  gaussian.save_ply(str(ply_path))
150
 
151
+ # Save in the requested download format
152
  fmt = output_format.lower()
153
+ if fmt == "splat":
 
 
154
  download_path = out_dir / "splat.splat"
155
  gaussian.save_splat(str(download_path))
156
  else:
157
+ download_path = ply_path
158
 
159
+ n_gaussians = gaussian.get_xyz.shape[0]
160
+
161
+ # Return only picklable primitives / paths
162
+ return str(prep_path), str(ply_path), str(download_path), n_gaussians, gen_dt
163
 
164
 
165
  # ----------------------------------------------------------------------------
166
+ # Main API endpoint (queued via Gradio's engine)
167
  # ----------------------------------------------------------------------------
168
 
169
+ @app.api()
170
+ def generate(
171
+ image: FileData,
172
+ seed: int = 42,
173
+ steps: int = 20,
174
+ guidance_scale: float = 3.0,
175
+ num_gaussians: int = 262144,
176
+ output_format: str = "ply",
177
+ ) -> tuple[FileData, FileData, FileData, str]:
178
+ """Generate 3D Gaussians from an input image.
179
+
180
+ Returns (preprocessed_image, ply_file, download_file, info_string).
181
+ The frontend receives these as result.data[0..3].
182
+ """
183
+ pil_image = Image.open(image["path"]).convert("RGBA")
184
+
185
+ out_dir = OUT_ROOT / uuid4().hex[:12]
186
+ out_dir.mkdir(parents=True, exist_ok=True)
187
+
188
+ prep_path, ply_path, download_path, n_gaussians, gen_dt = _run_pipeline(
189
+ pil_image, seed, steps, guidance_scale, num_gaussians,
190
+ out_dir, output_format,
191
+ )
192
+
193
+ info = (
194
+ f"{n_gaussians:,} gaussians · "
195
+ f"generation: {gen_dt:.1f}s · saved: {Path(download_path).name}"
196
  )
197
 
198
+ return (
199
+ FileData(path=prep_path),
200
+ FileData(path=ply_path),
201
+ FileData(path=download_path),
202
+ info,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  )
204
 
205
 
206
+ # ----------------------------------------------------------------------------
207
+ # Launch
208
+ # ----------------------------------------------------------------------------
209
+
210
  if __name__ == "__main__":
211
+ app.launch(show_error=True)
 
 
 
 
 
 
index.html ADDED
@@ -0,0 +1,1254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TripoSplat — Image to 3D Gaussians</title>
7
+ <meta name="description" content="Convert a single 2D image into high-quality 3D Gaussian splats using TripoSplat by TripoAI.">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11
+ <style>
12
+ /* ======================================================================
13
+ DESIGN TOKENS
14
+ ====================================================================== */
15
+ :root {
16
+ --bg-base: #08090d;
17
+ --bg-panel: rgba(13, 15, 22, 0.75);
18
+ --bg-surface: rgba(255, 255, 255, 0.035);
19
+ --bg-surface-hover: rgba(255, 255, 255, 0.065);
20
+ --bg-input: rgba(255, 255, 255, 0.045);
21
+ --border: rgba(255, 255, 255, 0.06);
22
+ --border-hover: rgba(255, 255, 255, 0.12);
23
+ --border-focus: rgba(255, 77, 79, 0.45);
24
+ --text: #e2e8f0;
25
+ --text-secondary: #94a3b8;
26
+ --text-muted: #64748b;
27
+ --text-faint: #475569;
28
+ --accent: #ff4d4f;
29
+ --accent-dark: #d9363e;
30
+ --accent-glow: rgba(255, 77, 79, 0.25);
31
+ --accent-subtle: rgba(255, 77, 79, 0.08);
32
+ --success: #34d399;
33
+ --error: #f87171;
34
+ --radius-sm: 6px;
35
+ --radius-md: 10px;
36
+ --radius-lg: 14px;
37
+ --radius-xl: 18px;
38
+ --transition-fast: 0.15s ease;
39
+ --transition: 0.25s ease;
40
+ --transition-slow: 0.4s ease;
41
+ --panel-left-w: 320px;
42
+ --panel-right-w: 300px;
43
+ --header-h: 56px;
44
+ }
45
+
46
+ /* ======================================================================
47
+ RESET & BASE
48
+ ====================================================================== */
49
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
50
+ html, body { height: 100%; overflow: hidden; }
51
+ body {
52
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
53
+ background: var(--bg-base);
54
+ color: var(--text);
55
+ -webkit-font-smoothing: antialiased;
56
+ -moz-osx-font-smoothing: grayscale;
57
+ }
58
+
59
+ /* ======================================================================
60
+ BACKGROUND EFFECTS
61
+ ====================================================================== */
62
+ body::before {
63
+ content: '';
64
+ position: fixed;
65
+ inset: 0;
66
+ background:
67
+ radial-gradient(ellipse at 15% 25%, rgba(255, 77, 79, 0.07) 0%, transparent 55%),
68
+ radial-gradient(ellipse at 85% 75%, rgba(255, 120, 117, 0.05) 0%, transparent 50%),
69
+ radial-gradient(ellipse at 50% 50%, rgba(63, 27, 27, 0.15) 0%, transparent 70%);
70
+ pointer-events: none;
71
+ z-index: 0;
72
+ animation: bgPulse 25s ease-in-out infinite alternate;
73
+ }
74
+ @keyframes bgPulse {
75
+ 0% { opacity: 0.6; }
76
+ 100% { opacity: 1; }
77
+ }
78
+
79
+ /* ======================================================================
80
+ SCROLLBAR
81
+ ====================================================================== */
82
+ ::-webkit-scrollbar { width: 5px; }
83
+ ::-webkit-scrollbar-track { background: transparent; }
84
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
85
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
86
+
87
+ /* ======================================================================
88
+ HEADER
89
+ ====================================================================== */
90
+ #app-header {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ height: var(--header-h);
95
+ padding: 0 24px;
96
+ border-bottom: 1px solid var(--border);
97
+ background: var(--bg-panel);
98
+ backdrop-filter: blur(24px);
99
+ -webkit-backdrop-filter: blur(24px);
100
+ position: relative;
101
+ z-index: 20;
102
+ }
103
+ .header-left {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 10px;
107
+ }
108
+ .logo-img {
109
+ height: 24px;
110
+ width: auto;
111
+ object-fit: contain;
112
+ }
113
+ .logo-text {
114
+ font-size: 18px;
115
+ font-weight: 700;
116
+ background: linear-gradient(135deg, #ff9c6e, #ff4d4f);
117
+ -webkit-background-clip: text;
118
+ -webkit-text-fill-color: transparent;
119
+ background-clip: text;
120
+ letter-spacing: -0.3px;
121
+ }
122
+ .header-center {
123
+ position: absolute;
124
+ left: 50%;
125
+ transform: translateX(-50%);
126
+ font-size: 12px;
127
+ color: var(--text-muted);
128
+ max-width: 400px;
129
+ text-align: center;
130
+ line-height: 1.4;
131
+ display: none;
132
+ }
133
+ @media (min-width: 1100px) { .header-center { display: block; } }
134
+ .header-right {
135
+ display: flex;
136
+ align-items: center;
137
+ gap: 4px;
138
+ }
139
+ .header-link {
140
+ padding: 6px 12px;
141
+ font-size: 12px;
142
+ font-weight: 500;
143
+ color: var(--text-secondary);
144
+ text-decoration: none;
145
+ border-radius: var(--radius-sm);
146
+ transition: all var(--transition-fast);
147
+ }
148
+ .header-link:hover {
149
+ color: var(--text);
150
+ background: var(--bg-surface);
151
+ }
152
+
153
+ /* ======================================================================
154
+ MAIN LAYOUT
155
+ ====================================================================== */
156
+ #app-main {
157
+ display: flex;
158
+ height: calc(100vh - var(--header-h));
159
+ position: relative;
160
+ z-index: 1;
161
+ }
162
+
163
+ /* ======================================================================
164
+ PANELS (shared)
165
+ ====================================================================== */
166
+ .panel {
167
+ background: var(--bg-panel);
168
+ backdrop-filter: blur(24px);
169
+ -webkit-backdrop-filter: blur(24px);
170
+ }
171
+ .panel-left, .panel-right {
172
+ display: flex;
173
+ flex-direction: column;
174
+ overflow-y: auto;
175
+ padding: 20px;
176
+ }
177
+ .panel-left {
178
+ width: var(--panel-left-w);
179
+ min-width: 280px;
180
+ border-right: 1px solid var(--border);
181
+ }
182
+ .panel-right {
183
+ width: var(--panel-right-w);
184
+ min-width: 260px;
185
+ border-left: 1px solid var(--border);
186
+ }
187
+ .panel-center {
188
+ flex: 1;
189
+ display: flex;
190
+ flex-direction: column;
191
+ padding: 20px;
192
+ min-width: 0;
193
+ background: transparent;
194
+ backdrop-filter: none;
195
+ }
196
+ .panel-header {
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: space-between;
200
+ margin-bottom: 18px;
201
+ }
202
+ .panel-title {
203
+ font-size: 14px;
204
+ font-weight: 600;
205
+ color: var(--text);
206
+ letter-spacing: -0.2px;
207
+ }
208
+ .badge {
209
+ font-size: 10px;
210
+ font-weight: 600;
211
+ padding: 3px 8px;
212
+ border-radius: 20px;
213
+ background: var(--accent-subtle);
214
+ color: var(--accent);
215
+ letter-spacing: 0.3px;
216
+ text-transform: uppercase;
217
+ }
218
+
219
+ /* ======================================================================
220
+ SECTION
221
+ ====================================================================== */
222
+ .section {
223
+ margin-bottom: 16px;
224
+ }
225
+ .section-title {
226
+ font-size: 11px;
227
+ font-weight: 600;
228
+ color: var(--text-muted);
229
+ text-transform: uppercase;
230
+ letter-spacing: 0.6px;
231
+ margin-bottom: 10px;
232
+ }
233
+
234
+ /* ======================================================================
235
+ UPLOAD AREA
236
+ ====================================================================== */
237
+ #upload-area {
238
+ border: 2px dashed rgba(255,255,255,0.08);
239
+ border-radius: var(--radius-lg);
240
+ cursor: pointer;
241
+ transition: all var(--transition);
242
+ background: var(--bg-surface);
243
+ margin-bottom: 16px;
244
+ position: relative;
245
+ overflow: hidden;
246
+ }
247
+ #upload-area:hover {
248
+ border-color: rgba(255, 77, 79, 0.35);
249
+ background: var(--accent-subtle);
250
+ }
251
+ #upload-area.drag-over {
252
+ border-color: var(--accent);
253
+ background: rgba(255, 77, 79, 0.12);
254
+ transform: scale(1.01);
255
+ }
256
+ #upload-area.has-image {
257
+ border-style: solid;
258
+ border-color: var(--border);
259
+ padding: 0;
260
+ }
261
+ .upload-placeholder {
262
+ display: flex;
263
+ flex-direction: column;
264
+ align-items: center;
265
+ justify-content: center;
266
+ padding: 36px 16px;
267
+ gap: 8px;
268
+ }
269
+ .upload-icon {
270
+ width: 36px; height: 36px;
271
+ color: var(--text-muted);
272
+ margin-bottom: 4px;
273
+ }
274
+ .upload-text {
275
+ font-size: 13px;
276
+ font-weight: 500;
277
+ color: var(--text-secondary);
278
+ }
279
+ .upload-hint {
280
+ font-size: 11px;
281
+ color: var(--text-muted);
282
+ }
283
+ .upload-preview {
284
+ position: relative;
285
+ }
286
+ .upload-preview img {
287
+ width: 100%;
288
+ display: block;
289
+ border-radius: calc(var(--radius-lg) - 2px);
290
+ }
291
+ .btn-clear {
292
+ position: absolute;
293
+ top: 8px; right: 8px;
294
+ width: 28px; height: 28px;
295
+ border-radius: 50%;
296
+ border: none;
297
+ background: rgba(0,0,0,0.65);
298
+ backdrop-filter: blur(8px);
299
+ color: var(--text-secondary);
300
+ cursor: pointer;
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ transition: all var(--transition-fast);
305
+ z-index: 2;
306
+ }
307
+ .btn-clear:hover {
308
+ background: rgba(248, 113, 113, 0.5);
309
+ color: #fff;
310
+ }
311
+ .btn-clear svg {
312
+ width: 14px; height: 14px;
313
+ }
314
+
315
+ /* ======================================================================
316
+ EXAMPLES
317
+ ====================================================================== */
318
+ .examples-grid {
319
+ display: grid;
320
+ grid-template-columns: repeat(4, 1fr);
321
+ gap: 8px;
322
+ margin-bottom: 16px;
323
+ }
324
+ .example-thumb {
325
+ aspect-ratio: 1;
326
+ border-radius: var(--radius-md);
327
+ overflow: hidden;
328
+ cursor: pointer;
329
+ border: 2px solid transparent;
330
+ transition: all var(--transition);
331
+ position: relative;
332
+ }
333
+ .example-thumb:hover {
334
+ border-color: rgba(255, 77, 79, 0.35);
335
+ transform: scale(1.06);
336
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
337
+ }
338
+ .example-thumb.selected {
339
+ border-color: var(--accent);
340
+ box-shadow: 0 0 0 1px var(--accent), 0 0 12px var(--accent-glow);
341
+ }
342
+ .example-thumb img {
343
+ width: 100%;
344
+ height: 100%;
345
+ object-fit: cover;
346
+ display: block;
347
+ }
348
+
349
+ /* ======================================================================
350
+ ACCORDION
351
+ ====================================================================== */
352
+ .accordion-btn {
353
+ width: 100%;
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: space-between;
357
+ padding: 10px 12px;
358
+ background: var(--bg-surface);
359
+ border: 1px solid var(--border);
360
+ border-radius: var(--radius-md);
361
+ color: var(--text-secondary);
362
+ font-family: inherit;
363
+ font-size: 12px;
364
+ font-weight: 500;
365
+ cursor: pointer;
366
+ transition: all var(--transition-fast);
367
+ }
368
+ .accordion-btn:hover {
369
+ background: var(--bg-surface-hover);
370
+ color: var(--text);
371
+ }
372
+ .accordion-icon {
373
+ width: 16px; height: 16px;
374
+ transition: transform var(--transition);
375
+ }
376
+ .accordion-icon.rotated {
377
+ transform: rotate(180deg);
378
+ }
379
+ .accordion-body {
380
+ max-height: 0;
381
+ overflow: hidden;
382
+ transition: max-height var(--transition-slow) ease, padding var(--transition-slow) ease;
383
+ padding: 0 4px;
384
+ }
385
+ .accordion-body.open {
386
+ max-height: 400px;
387
+ padding: 14px 4px 4px;
388
+ }
389
+
390
+ /* ======================================================================
391
+ FORM CONTROLS
392
+ ====================================================================== */
393
+ .form-group {
394
+ margin-bottom: 14px;
395
+ }
396
+ .form-group label {
397
+ display: flex;
398
+ align-items: center;
399
+ justify-content: space-between;
400
+ font-size: 11px;
401
+ font-weight: 500;
402
+ color: var(--text-muted);
403
+ text-transform: uppercase;
404
+ letter-spacing: 0.5px;
405
+ margin-bottom: 6px;
406
+ }
407
+ .value-display {
408
+ font-size: 11px;
409
+ font-weight: 600;
410
+ color: var(--accent);
411
+ font-variant-numeric: tabular-nums;
412
+ background: var(--accent-subtle);
413
+ padding: 1px 6px;
414
+ border-radius: 4px;
415
+ }
416
+ input[type="number"],
417
+ select {
418
+ width: 100%;
419
+ padding: 9px 12px;
420
+ background: var(--bg-input);
421
+ border: 1px solid var(--border);
422
+ border-radius: var(--radius-sm);
423
+ color: var(--text);
424
+ font-family: inherit;
425
+ font-size: 13px;
426
+ outline: none;
427
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
428
+ }
429
+ input[type="number"]:focus,
430
+ select:focus {
431
+ border-color: var(--border-focus);
432
+ box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.1);
433
+ }
434
+ select {
435
+ appearance: none;
436
+ -webkit-appearance: none;
437
+ cursor: pointer;
438
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
439
+ background-repeat: no-repeat;
440
+ background-position: right 10px center;
441
+ padding-right: 32px;
442
+ }
443
+
444
+ /* Range slider */
445
+ input[type="range"] {
446
+ -webkit-appearance: none;
447
+ appearance: none;
448
+ width: 100%;
449
+ height: 4px;
450
+ background: rgba(255,255,255,0.08);
451
+ border-radius: 2px;
452
+ outline: none;
453
+ cursor: pointer;
454
+ }
455
+ input[type="range"]::-webkit-slider-thumb {
456
+ -webkit-appearance: none;
457
+ width: 16px; height: 16px;
458
+ background: var(--accent);
459
+ border-radius: 50%;
460
+ border: 2.5px solid var(--bg-base);
461
+ box-shadow: 0 0 8px var(--accent-glow), 0 1px 3px rgba(0,0,0,0.3);
462
+ cursor: grab;
463
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
464
+ }
465
+ input[type="range"]::-webkit-slider-thumb:hover {
466
+ transform: scale(1.15);
467
+ box-shadow: 0 0 14px var(--accent-glow), 0 1px 3px rgba(0,0,0,0.3);
468
+ }
469
+ input[type="range"]::-moz-range-thumb {
470
+ width: 16px; height: 16px;
471
+ background: var(--accent);
472
+ border-radius: 50%;
473
+ border: 2.5px solid var(--bg-base);
474
+ box-shadow: 0 0 8px var(--accent-glow);
475
+ cursor: grab;
476
+ }
477
+
478
+ /* ======================================================================
479
+ GENERATE BUTTON
480
+ ====================================================================== */
481
+ .btn-primary {
482
+ width: 100%;
483
+ padding: 13px 16px;
484
+ margin-top: auto;
485
+ background: linear-gradient(135deg, #ff4d4f 0%, #d9363e 100%);
486
+ border: none;
487
+ border-radius: var(--radius-md);
488
+ color: #fff;
489
+ font-family: inherit;
490
+ font-size: 14px;
491
+ font-weight: 600;
492
+ cursor: pointer;
493
+ display: flex;
494
+ align-items: center;
495
+ justify-content: center;
496
+ gap: 8px;
497
+ transition: all var(--transition);
498
+ position: relative;
499
+ overflow: hidden;
500
+ flex-shrink: 0;
501
+ }
502
+ .btn-primary::before {
503
+ content: '';
504
+ position: absolute;
505
+ inset: 0;
506
+ background: linear-gradient(120deg, transparent 25%, rgba(255,255,255,0.12) 50%, transparent 75%);
507
+ transform: translateX(-120%);
508
+ transition: transform 0.65s ease;
509
+ }
510
+ .btn-primary:hover:not(:disabled)::before {
511
+ transform: translateX(120%);
512
+ }
513
+ .btn-primary:hover:not(:disabled) {
514
+ box-shadow: 0 6px 24px rgba(255, 77, 79, 0.35), 0 0 0 1px rgba(255, 77, 79, 0.2);
515
+ transform: translateY(-1px);
516
+ }
517
+ .btn-primary:active:not(:disabled) {
518
+ transform: translateY(0);
519
+ }
520
+ .btn-primary:disabled {
521
+ opacity: 0.4;
522
+ cursor: not-allowed;
523
+ }
524
+ .btn-primary.loading {
525
+ background: linear-gradient(135deg, #d9363e 0%, #ad2128 100%);
526
+ pointer-events: none;
527
+ }
528
+ .btn-icon {
529
+ width: 16px; height: 16px;
530
+ flex-shrink: 0;
531
+ }
532
+
533
+ /* ======================================================================
534
+ VIEWER
535
+ ====================================================================== */
536
+ .viewer-wrap {
537
+ flex: 1;
538
+ border-radius: var(--radius-xl);
539
+ overflow: hidden;
540
+ position: relative;
541
+ background: radial-gradient(ellipse at 50% 55%, #151a2e 0%, #0a0b10 100%);
542
+ border: 1px solid var(--border);
543
+ transition: box-shadow var(--transition-slow);
544
+ }
545
+ .viewer-wrap.active {
546
+ box-shadow: 0 0 40px rgba(255, 77, 79, 0.08), 0 0 80px rgba(255, 120, 117, 0.04);
547
+ }
548
+ .viewer-placeholder {
549
+ display: flex;
550
+ flex-direction: column;
551
+ align-items: center;
552
+ justify-content: center;
553
+ height: 100%;
554
+ gap: 14px;
555
+ color: var(--text-faint);
556
+ user-select: none;
557
+ }
558
+ .placeholder-icon {
559
+ width: 56px; height: 56px;
560
+ opacity: 0.35;
561
+ }
562
+ .viewer-placeholder p {
563
+ font-size: 14px;
564
+ font-weight: 500;
565
+ }
566
+ .viewer-placeholder .hint {
567
+ font-size: 12px;
568
+ color: var(--text-muted);
569
+ opacity: 0.6;
570
+ }
571
+ #viewer-iframe {
572
+ width: 100%;
573
+ height: 100%;
574
+ border: none;
575
+ position: absolute;
576
+ inset: 0;
577
+ }
578
+ .viewer-bar {
579
+ display: flex;
580
+ align-items: center;
581
+ justify-content: center;
582
+ padding: 10px 0 0;
583
+ flex-shrink: 0;
584
+ }
585
+ .viewer-hint {
586
+ font-size: 11px;
587
+ color: var(--text-muted);
588
+ letter-spacing: 0.3px;
589
+ }
590
+
591
+ /* ======================================================================
592
+ OUTPUT PANEL
593
+ ====================================================================== */
594
+ .output-placeholder {
595
+ display: flex;
596
+ flex-direction: column;
597
+ align-items: center;
598
+ justify-content: center;
599
+ padding: 48px 16px;
600
+ text-align: center;
601
+ gap: 12px;
602
+ color: var(--text-faint);
603
+ }
604
+ .output-placeholder .placeholder-icon {
605
+ width: 40px; height: 40px;
606
+ opacity: 0.25;
607
+ }
608
+ .output-placeholder p {
609
+ font-size: 13px;
610
+ }
611
+
612
+ /* Info card */
613
+ .info-card {
614
+ background: var(--bg-surface);
615
+ border: 1px solid var(--border);
616
+ border-radius: var(--radius-md);
617
+ padding: 4px 0;
618
+ margin-bottom: 16px;
619
+ }
620
+ .info-row {
621
+ display: flex;
622
+ align-items: center;
623
+ justify-content: space-between;
624
+ padding: 10px 14px;
625
+ }
626
+ .info-row + .info-row {
627
+ border-top: 1px solid rgba(255,255,255,0.03);
628
+ }
629
+ .info-key {
630
+ font-size: 11px;
631
+ font-weight: 500;
632
+ color: var(--text-muted);
633
+ text-transform: uppercase;
634
+ letter-spacing: 0.5px;
635
+ }
636
+ .info-val {
637
+ font-size: 13px;
638
+ font-weight: 600;
639
+ color: var(--text);
640
+ font-variant-numeric: tabular-nums;
641
+ }
642
+
643
+ /* Download button */
644
+ .btn-download {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ gap: 8px;
649
+ width: 100%;
650
+ padding: 11px 16px;
651
+ background: var(--bg-surface);
652
+ border: 1px solid var(--border);
653
+ border-radius: var(--radius-md);
654
+ color: var(--text);
655
+ font-family: inherit;
656
+ font-size: 13px;
657
+ font-weight: 500;
658
+ cursor: pointer;
659
+ transition: all var(--transition);
660
+ text-decoration: none;
661
+ margin-bottom: 20px;
662
+ }
663
+ .btn-download:hover {
664
+ background: var(--bg-surface-hover);
665
+ border-color: var(--border-hover);
666
+ box-shadow: 0 2px 12px rgba(0,0,0,0.2);
667
+ }
668
+ .btn-download svg {
669
+ width: 16px; height: 16px;
670
+ }
671
+
672
+ /* Preprocessed image */
673
+ .preprocessed-section img {
674
+ width: 100%;
675
+ border-radius: var(--radius-md);
676
+ margin-top: 10px;
677
+ border: 1px solid var(--border);
678
+ }
679
+
680
+ /* Status bar */
681
+ .status-bar {
682
+ margin-top: auto;
683
+ display: flex;
684
+ align-items: center;
685
+ gap: 8px;
686
+ padding: 10px 0 0;
687
+ flex-shrink: 0;
688
+ transition: opacity var(--transition-slow);
689
+ }
690
+ .status-bar.fade-out {
691
+ opacity: 0;
692
+ }
693
+ .status-dot {
694
+ width: 7px; height: 7px;
695
+ border-radius: 50%;
696
+ flex-shrink: 0;
697
+ }
698
+ .status-dot.connecting {
699
+ background: #fbbf24;
700
+ animation: dotPulse 1.2s ease infinite;
701
+ }
702
+ .status-dot.connected {
703
+ background: var(--success);
704
+ }
705
+ .status-dot.error {
706
+ background: var(--error);
707
+ }
708
+ @keyframes dotPulse {
709
+ 0%, 100% { opacity: 1; }
710
+ 50% { opacity: 0.3; }
711
+ }
712
+ .status-text {
713
+ font-size: 11px;
714
+ color: var(--text-muted);
715
+ }
716
+
717
+ /* ======================================================================
718
+ ERROR TOAST
719
+ ====================================================================== */
720
+ .error-toast {
721
+ position: fixed;
722
+ bottom: 24px;
723
+ left: 50%;
724
+ transform: translateX(-50%);
725
+ padding: 12px 24px;
726
+ background: rgba(127, 29, 29, 0.9);
727
+ backdrop-filter: blur(12px);
728
+ border: 1px solid rgba(248, 113, 113, 0.3);
729
+ border-radius: var(--radius-md);
730
+ color: #fca5a5;
731
+ font-size: 13px;
732
+ font-weight: 500;
733
+ z-index: 100;
734
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
735
+ white-space: nowrap;
736
+ max-width: 90vw;
737
+ overflow: hidden;
738
+ text-overflow: ellipsis;
739
+ }
740
+
741
+ /* ======================================================================
742
+ ANIMATIONS
743
+ ====================================================================== */
744
+ @keyframes fadeIn {
745
+ from { opacity: 0; transform: translateY(8px); }
746
+ to { opacity: 1; transform: translateY(0); }
747
+ }
748
+ .fade-in { animation: fadeIn 0.35s ease forwards; }
749
+
750
+ @keyframes spin {
751
+ to { transform: rotate(360deg); }
752
+ }
753
+ .spin { animation: spin 0.9s linear infinite; }
754
+
755
+ /* ======================================================================
756
+ UTILITIES
757
+ ====================================================================== */
758
+ .hidden { display: none !important; }
759
+
760
+ /* ======================================================================
761
+ RESPONSIVE
762
+ ====================================================================== */
763
+ @media (max-width: 960px) {
764
+ #app-main { flex-direction: column; overflow-y: auto; }
765
+ .panel-left, .panel-right {
766
+ width: 100%;
767
+ min-width: auto;
768
+ border-right: none;
769
+ border-left: none;
770
+ border-bottom: 1px solid var(--border);
771
+ }
772
+ .panel-center { min-height: 400px; }
773
+ .header-center { display: none; }
774
+ :root {
775
+ --panel-left-w: 100%;
776
+ --panel-right-w: 100%;
777
+ }
778
+ }
779
+ </style>
780
+ </head>
781
+ <body>
782
+
783
+ <!-- =====================================================================
784
+ HEADER
785
+ ===================================================================== -->
786
+ <header id="app-header">
787
+ <div class="header-left">
788
+ <img class="logo-img" src="https://cdn-web.tripo3d.ai/tripo-web/logo/tripo-logo1.webp" alt="Tripo AI Logo">
789
+ <span class="logo-text">Splat</span>
790
+ </div>
791
+ <div class="header-center">
792
+ Single image → high-quality 3D Gaussians by <a href="https://www.tripo3d.ai/" style="color:var(--accent);text-decoration:none" target="_blank">TripoAI</a>
793
+ </div>
794
+ <div class="header-right">
795
+ <a class="header-link" href="https://arxiv.org/abs/2605.16355" target="_blank" rel="noopener">Paper</a>
796
+ <a class="header-link" href="https://www.tripo3d.ai/research/triposplat" target="_blank" rel="noopener">Blog</a>
797
+ <a class="header-link" href="https://github.com/VAST-AI-Research/TripoSplat" target="_blank" rel="noopener">GitHub</a>
798
+ </div>
799
+ </header>
800
+
801
+ <!-- =====================================================================
802
+ MAIN
803
+ ===================================================================== -->
804
+ <main id="app-main">
805
+
806
+ <!-- =============== LEFT PANEL =============== -->
807
+ <aside class="panel panel-left" id="panel-left">
808
+ <div class="panel-header">
809
+ <h2 class="panel-title">Image to 3D</h2>
810
+ <span class="badge">Gaussian Splatting</span>
811
+ </div>
812
+
813
+ <!-- Upload area -->
814
+ <div id="upload-area">
815
+ <input type="file" id="file-input" accept="image/*" hidden>
816
+ <div class="upload-placeholder" id="upload-placeholder">
817
+ <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
818
+ <path d="M12 16V4"/>
819
+ <path d="M8 8l4-4 4 4"/>
820
+ <path d="M3 17l.621 2.485A2 2 0 0 0 5.561 21h12.878a2 2 0 0 0 1.94-1.515L21 17"/>
821
+ </svg>
822
+ <p class="upload-text">Drop image or click to upload</p>
823
+ <p class="upload-hint">PNG, JPG, WEBP up to 20 MB</p>
824
+ </div>
825
+ <div class="upload-preview hidden" id="upload-preview">
826
+ <img id="preview-image" alt="Preview">
827
+ <button class="btn-clear" id="clear-btn" title="Remove image">
828
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
829
+ </button>
830
+ </div>
831
+ </div>
832
+
833
+ <!-- Examples -->
834
+ <div class="section">
835
+ <h3 class="section-title">Examples</h3>
836
+ <div class="examples-grid" id="examples-grid"></div>
837
+ </div>
838
+
839
+ <!-- Settings accordion -->
840
+ <div class="section">
841
+ <button class="accordion-btn" id="settings-toggle" type="button">
842
+ <span>Sampling Settings</span>
843
+ <svg class="accordion-icon" id="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
844
+ </button>
845
+ <div class="accordion-body" id="settings-body">
846
+ <div class="form-group">
847
+ <label for="seed">Seed</label>
848
+ <input type="number" id="seed" value="42" min="0" max="999999999">
849
+ </div>
850
+ <div class="form-group">
851
+ <label for="steps">Inference Steps <span class="value-display" id="steps-val">20</span></label>
852
+ <input type="range" id="steps" min="1" max="50" value="20" step="1">
853
+ </div>
854
+ <div class="form-group">
855
+ <label for="guidance">Guidance Scale <span class="value-display" id="guidance-val">3.0</span></label>
856
+ <input type="range" id="guidance" min="1" max="10" value="3" step="0.5">
857
+ </div>
858
+ <div class="form-group">
859
+ <label for="gaussians">Number of Gaussians</label>
860
+ <select id="gaussians">
861
+ <option value="32768">32,768</option>
862
+ <option value="65536">65,536</option>
863
+ <option value="131072">131,072</option>
864
+ <option value="262144" selected>262,144</option>
865
+ </select>
866
+ </div>
867
+ <div class="form-group">
868
+ <label for="format">Download Format</label>
869
+ <select id="format">
870
+ <option value="ply" selected>PLY</option>
871
+ <option value="splat">SPLAT</option>
872
+ </select>
873
+ </div>
874
+ </div>
875
+ </div>
876
+
877
+ <!-- Spacer -->
878
+ <div style="flex:1;min-height:12px"></div>
879
+
880
+ <!-- Generate button -->
881
+ <button class="btn-primary" id="generate-btn" type="button" disabled>
882
+ <svg class="btn-icon" id="generate-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41 23 12 14.59 15.59 12 24 9.41 15.59 1 12 9.41 8.41Z"/></svg>
883
+ <span id="generate-label">Generate</span>
884
+ <svg class="btn-icon spin hidden" id="generate-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
885
+ </button>
886
+ </aside>
887
+
888
+ <!-- =============== CENTER PANEL =============== -->
889
+ <section class="panel panel-center" id="panel-center">
890
+ <div class="viewer-wrap" id="viewer-wrap">
891
+ <div class="viewer-placeholder" id="viewer-placeholder">
892
+ <svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
893
+ <path d="M12 2L3 7v10l9 5 9-5V7l-9-5z"/>
894
+ <path d="M12 22V12"/>
895
+ <path d="M3 7l9 5"/>
896
+ <path d="M21 7l-9 5"/>
897
+ </svg>
898
+ <p>3D viewer will appear here after generation</p>
899
+ <p class="hint">Upload an image and click Generate</p>
900
+ </div>
901
+ <iframe id="viewer-iframe" class="hidden" allowfullscreen></iframe>
902
+ </div>
903
+ <div class="viewer-bar">
904
+ <span class="viewer-hint">drag to orbit &nbsp;·&nbsp; scroll to zoom &nbsp;·&nbsp; right-drag to pan</span>
905
+ </div>
906
+ </section>
907
+
908
+ <!-- =============== RIGHT PANEL =============== -->
909
+ <aside class="panel panel-right" id="panel-right">
910
+ <div class="panel-header">
911
+ <h2 class="panel-title">Output</h2>
912
+ </div>
913
+
914
+ <!-- Placeholder -->
915
+ <div class="output-placeholder" id="output-placeholder">
916
+ <svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
917
+ <rect x="3" y="3" width="18" height="18" rx="3"/>
918
+ <path d="M9 9h.01M15 9h.01"/>
919
+ <path d="M8 14s1.5 2 4 2 4-2 4-2"/>
920
+ </svg>
921
+ <p>Generation results<br>will appear here</p>
922
+ </div>
923
+
924
+ <!-- Results (hidden until generation) -->
925
+ <div class="hidden" id="output-content">
926
+ <div class="info-card">
927
+ <div class="info-row">
928
+ <span class="info-key">Gaussians</span>
929
+ <span class="info-val" id="out-gaussians">—</span>
930
+ </div>
931
+ <div class="info-row">
932
+ <span class="info-key">Time</span>
933
+ <span class="info-val" id="out-time">—</span>
934
+ </div>
935
+ <div class="info-row">
936
+ <span class="info-key">File</span>
937
+ <span class="info-val" id="out-file">—</span>
938
+ </div>
939
+ </div>
940
+
941
+ <a class="btn-download" id="download-btn" download>
942
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
943
+ <path d="M12 4v12"/>
944
+ <path d="M8 12l4 4 4-4"/>
945
+ <path d="M3 17l.621 2.485A2 2 0 0 0 5.561 21h12.878a2 2 0 0 0 1.94-1.515L21 17"/>
946
+ </svg>
947
+ <span>Download</span>
948
+ </a>
949
+
950
+ <div class="preprocessed-section">
951
+ <h3 class="section-title">Preprocessed Input</h3>
952
+ <img id="preprocessed-img" alt="Preprocessed input">
953
+ </div>
954
+ </div>
955
+
956
+ <!-- Connection status -->
957
+ <div class="status-bar" id="status-bar">
958
+ <span class="status-dot connecting" id="status-dot"></span>
959
+ <span class="status-text" id="status-text">Connecting…</span>
960
+ </div>
961
+ </aside>
962
+
963
+ </main>
964
+
965
+ <!-- =====================================================================
966
+ JAVASCRIPT
967
+ ===================================================================== -->
968
+ <script type="module">
969
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
970
+
971
+ // --------------------------------------------------------------------------
972
+ // State
973
+ // --------------------------------------------------------------------------
974
+ let client = null;
975
+ let currentFile = null;
976
+ let isGenerating = false;
977
+
978
+ // --------------------------------------------------------------------------
979
+ // DOM helpers
980
+ // --------------------------------------------------------------------------
981
+ const $ = (id) => document.getElementById(id);
982
+
983
+ // --------------------------------------------------------------------------
984
+ // Initialise
985
+ // --------------------------------------------------------------------------
986
+ async function init() {
987
+ updateStatus("connecting");
988
+
989
+ try {
990
+ client = await Client.connect(window.location.origin);
991
+ updateStatus("connected");
992
+ } catch (e) {
993
+ updateStatus("error", e.message);
994
+ console.error("Gradio client connection failed:", e);
995
+ }
996
+
997
+ loadExamples();
998
+ setupUpload();
999
+ setupSettings();
1000
+ setupGenerate();
1001
+ }
1002
+
1003
+ // --------------------------------------------------------------------------
1004
+ // Example images
1005
+ // --------------------------------------------------------------------------
1006
+ async function loadExamples() {
1007
+ try {
1008
+ const res = await fetch("/api/examples");
1009
+ const examples = await res.json();
1010
+ const grid = $("examples-grid");
1011
+
1012
+ examples.forEach((ex, i) => {
1013
+ const thumb = document.createElement("div");
1014
+ thumb.className = "example-thumb";
1015
+ thumb.innerHTML = `<img src="${ex.url}" alt="${ex.name}" loading="lazy" draggable="false">`;
1016
+ thumb.addEventListener("click", () => selectExample(ex, i));
1017
+ grid.appendChild(thumb);
1018
+ });
1019
+ } catch (e) {
1020
+ console.warn("Failed to load examples:", e);
1021
+ }
1022
+ }
1023
+
1024
+ async function selectExample(ex, idx) {
1025
+ try {
1026
+ const res = await fetch(ex.url);
1027
+ const blob = await res.blob();
1028
+ currentFile = new File([blob], `example_${idx}.webp`, { type: "image/webp" });
1029
+ showPreview(URL.createObjectURL(blob));
1030
+
1031
+ // Highlight the selected thumbnail
1032
+ document.querySelectorAll(".example-thumb").forEach((t, i) => {
1033
+ t.classList.toggle("selected", i === idx);
1034
+ });
1035
+ } catch (e) {
1036
+ console.error("Failed to load example:", e);
1037
+ }
1038
+ }
1039
+
1040
+ // --------------------------------------------------------------------------
1041
+ // File upload / drag & drop
1042
+ // --------------------------------------------------------------------------
1043
+ function setupUpload() {
1044
+ const area = $("upload-area");
1045
+ const input = $("file-input");
1046
+
1047
+ area.addEventListener("click", (e) => {
1048
+ // Don't trigger if clicking the clear button
1049
+ if (e.target.closest(".btn-clear")) return;
1050
+ input.click();
1051
+ });
1052
+
1053
+ input.addEventListener("change", () => {
1054
+ if (input.files[0]) handleFile(input.files[0]);
1055
+ });
1056
+
1057
+ area.addEventListener("dragover", (e) => {
1058
+ e.preventDefault();
1059
+ area.classList.add("drag-over");
1060
+ });
1061
+ area.addEventListener("dragleave", () => {
1062
+ area.classList.remove("drag-over");
1063
+ });
1064
+ area.addEventListener("drop", (e) => {
1065
+ e.preventDefault();
1066
+ area.classList.remove("drag-over");
1067
+ const file = e.dataTransfer.files[0];
1068
+ if (file && file.type.startsWith("image/")) handleFile(file);
1069
+ });
1070
+
1071
+ $("clear-btn").addEventListener("click", (e) => {
1072
+ e.stopPropagation();
1073
+ clearFile();
1074
+ });
1075
+ }
1076
+
1077
+ function handleFile(file) {
1078
+ if (!file.type.startsWith("image/")) return;
1079
+ currentFile = file;
1080
+ showPreview(URL.createObjectURL(file));
1081
+ // De-select example thumbnails
1082
+ document.querySelectorAll(".example-thumb").forEach((t) => t.classList.remove("selected"));
1083
+ }
1084
+
1085
+ function showPreview(url) {
1086
+ $("preview-image").src = url;
1087
+ $("upload-placeholder").classList.add("hidden");
1088
+ $("upload-preview").classList.remove("hidden");
1089
+ $("upload-area").classList.add("has-image");
1090
+ $("generate-btn").disabled = false;
1091
+ }
1092
+
1093
+ function clearFile() {
1094
+ currentFile = null;
1095
+ $("file-input").value = "";
1096
+ $("upload-placeholder").classList.remove("hidden");
1097
+ $("upload-preview").classList.add("hidden");
1098
+ $("upload-area").classList.remove("has-image");
1099
+ $("generate-btn").disabled = true;
1100
+ document.querySelectorAll(".example-thumb").forEach((t) => t.classList.remove("selected"));
1101
+ }
1102
+
1103
+ // --------------------------------------------------------------------------
1104
+ // Settings accordion & slider labels
1105
+ // --------------------------------------------------------------------------
1106
+ function setupSettings() {
1107
+ const toggle = $("settings-toggle");
1108
+ const body = $("settings-body");
1109
+ const icon = $("settings-icon");
1110
+ let open = false;
1111
+
1112
+ toggle.addEventListener("click", () => {
1113
+ open = !open;
1114
+ body.classList.toggle("open", open);
1115
+ icon.classList.toggle("rotated", open);
1116
+ });
1117
+
1118
+ $("steps").addEventListener("input", (e) => {
1119
+ $("steps-val").textContent = e.target.value;
1120
+ });
1121
+ $("guidance").addEventListener("input", (e) => {
1122
+ $("guidance-val").textContent = parseFloat(e.target.value).toFixed(1);
1123
+ });
1124
+ }
1125
+
1126
+ // --------------------------------------------------------------------------
1127
+ // Generation
1128
+ // --------------------------------------------------------------------------
1129
+ function setupGenerate() {
1130
+ $("generate-btn").addEventListener("click", generate);
1131
+ }
1132
+
1133
+ async function generate() {
1134
+ if (!currentFile || isGenerating || !client) return;
1135
+
1136
+ isGenerating = true;
1137
+ const btn = $("generate-btn");
1138
+ btn.disabled = true;
1139
+ btn.classList.add("loading");
1140
+ $("generate-label").textContent = "Generating…";
1141
+ $("generate-icon").classList.add("hidden");
1142
+ $("generate-spinner").classList.remove("hidden");
1143
+
1144
+ try {
1145
+ const result = await client.predict("/generate", {
1146
+ image: handle_file(currentFile),
1147
+ seed: parseInt($("seed").value) || 42,
1148
+ steps: parseInt($("steps").value) || 20,
1149
+ guidance_scale: parseFloat($("guidance").value) || 3.0,
1150
+ num_gaussians: parseInt($("gaussians").value) || 262144,
1151
+ output_format: $("format").value || "ply",
1152
+ });
1153
+
1154
+ showResults(result);
1155
+ } catch (error) {
1156
+ console.error("Generation failed:", error);
1157
+ showError(error.message || "Generation failed. Please try again.");
1158
+ } finally {
1159
+ isGenerating = false;
1160
+ btn.disabled = false;
1161
+ btn.classList.remove("loading");
1162
+ $("generate-label").textContent = "Generate";
1163
+ $("generate-icon").classList.remove("hidden");
1164
+ $("generate-spinner").classList.add("hidden");
1165
+ }
1166
+ }
1167
+
1168
+ // --------------------------------------------------------------------------
1169
+ // Display results
1170
+ // --------------------------------------------------------------------------
1171
+ function showResults(result) {
1172
+ // 1. 3D Viewer — load PLY in the iframe
1173
+ const plyUrl = result.data[1].url;
1174
+ const viewerSrc = `/viewer?ply=${encodeURIComponent(plyUrl)}`;
1175
+ const iframe = $("viewer-iframe");
1176
+ iframe.src = viewerSrc;
1177
+ iframe.classList.remove("hidden");
1178
+ $("viewer-placeholder").classList.add("hidden");
1179
+ $("viewer-wrap").classList.add("active");
1180
+
1181
+ // 2. Parse info string: "262,144 gaussians · generation: 15.3s · saved: splat.ply"
1182
+ const info = result.data[3];
1183
+ const match = info.match(
1184
+ /^([\d,]+)\s+gaussians\s+·\s+generation:\s+([\d.]+s)\s+·\s+saved:\s+(.+)$/
1185
+ );
1186
+ if (match) {
1187
+ $("out-gaussians").textContent = match[1];
1188
+ $("out-time").textContent = match[2];
1189
+ $("out-file").textContent = match[3];
1190
+ } else {
1191
+ // Fallback: display the whole string
1192
+ $("out-gaussians").textContent = info;
1193
+ $("out-time").textContent = "";
1194
+ $("out-file").textContent = "";
1195
+ }
1196
+
1197
+ // 3. Download button
1198
+ const dl = $("download-btn");
1199
+ dl.href = result.data[2].url;
1200
+ dl.download = result.data[2].orig_name || "triposplat_output";
1201
+
1202
+ // 4. Preprocessed image
1203
+ $("preprocessed-img").src = result.data[0].url;
1204
+
1205
+ // 5. Reveal output section
1206
+ $("output-placeholder").classList.add("hidden");
1207
+ const content = $("output-content");
1208
+ content.classList.remove("hidden");
1209
+ content.classList.remove("fade-in");
1210
+ // Force reflow for re-triggering animation
1211
+ void content.offsetWidth;
1212
+ content.classList.add("fade-in");
1213
+ }
1214
+
1215
+ // --------------------------------------------------------------------------
1216
+ // Error handling
1217
+ // --------------------------------------------------------------------------
1218
+ function showError(msg) {
1219
+ const toast = document.createElement("div");
1220
+ toast.className = "error-toast fade-in";
1221
+ toast.textContent = msg;
1222
+ document.body.appendChild(toast);
1223
+ setTimeout(() => {
1224
+ toast.style.opacity = "0";
1225
+ toast.style.transition = "opacity 0.3s ease";
1226
+ setTimeout(() => toast.remove(), 350);
1227
+ }, 5000);
1228
+ }
1229
+
1230
+ // --------------------------------------------------------------------------
1231
+ // Connection status
1232
+ // --------------------------------------------------------------------------
1233
+ function updateStatus(state, msg) {
1234
+ const dot = $("status-dot");
1235
+ const text = $("status-text");
1236
+
1237
+ dot.className = "status-dot " + state;
1238
+ if (state === "connecting") {
1239
+ text.textContent = "Connecting…";
1240
+ } else if (state === "connected") {
1241
+ text.textContent = "Connected";
1242
+ setTimeout(() => $("status-bar").classList.add("fade-out"), 3000);
1243
+ } else if (state === "error") {
1244
+ text.textContent = msg || "Connection error";
1245
+ }
1246
+ }
1247
+
1248
+ // --------------------------------------------------------------------------
1249
+ // Boot
1250
+ // --------------------------------------------------------------------------
1251
+ init();
1252
+ </script>
1253
+ </body>
1254
+ </html>
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  torch
2
  torchvision
3
  numpy
 
1
+ gradio
2
  torch
3
  torchvision
4
  numpy