VISHAL18for4 commited on
Commit
0f2f4d9
Β·
verified Β·
1 Parent(s): 8d0867f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1086 -0
app.py ADDED
@@ -0,0 +1,1086 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import subprocess
3
+ import sys
4
+ import os
5
+ import base64
6
+ import tempfile
7
+ import time
8
+ import json
9
+ import urllib.parse
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ # ── ZeroGPU optional ──
14
+ try:
15
+ import spaces
16
+ HAS_GPU = True
17
+ except Exception:
18
+ HAS_GPU = False
19
+ class spaces:
20
+ @staticmethod
21
+ def GPU(duration=60):
22
+ return lambda fn: fn
23
+
24
+ # ── gradio_client optional (for the TripoSplat alt-model call) ──
25
+ try:
26
+ from gradio_client import Client as GradioClient, handle_file as gradio_handle_file
27
+ HAS_GRADIO_CLIENT = True
28
+ except Exception:
29
+ HAS_GRADIO_CLIENT = False
30
+
31
+ # ── plyfile optional (for the auto-clean / noise-pruning step) ──
32
+ try:
33
+ from plyfile import PlyData, PlyElement
34
+ HAS_PLYFILE = True
35
+ except Exception:
36
+ HAS_PLYFILE = False
37
+
38
+ # ── Install SHARP ──
39
+ def install_sharp():
40
+ try:
41
+ r = subprocess.run(["sharp", "--help"], capture_output=True, timeout=10)
42
+ if r.returncode == 0:
43
+ return
44
+ except Exception:
45
+ pass
46
+ print("Installing Apple SHARP...")
47
+ subprocess.check_call([
48
+ sys.executable, "-m", "pip", "install",
49
+ "git+https://github.com/apple/ml-sharp.git",
50
+ "--quiet"
51
+ ])
52
+
53
+ install_sharp()
54
+
55
+ # ── Resize before SHARP ──
56
+ def resize_image(path, max_size=512):
57
+ img = Image.open(path).convert("RGB")
58
+ w, h = img.size
59
+ if max(w, h) > max_size:
60
+ r = max_size / max(w, h)
61
+ img = img.resize((int(w*r), int(h*r)), Image.LANCZOS)
62
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, prefix="sharp_in_")
63
+ img.save(tmp.name, "JPEG", quality=90)
64
+ return tmp.name
65
+
66
+
67
+ # ────────────────────────────────────────────────────────────────
68
+ # Core SHARP runner β€” shared by the GPU and CPU code paths.
69
+ #
70
+ # Progress: SHARP's CLI doesn't stream fine-grained step-by-step progress,
71
+ # so we can't show a "true" per-layer progress bar. What we *can* do
72
+ # honestly: launch it with Popen (not the blocking .run()) and poll it in
73
+ # a loop from this same function, calling gr.Progress() each tick. Gradio
74
+ # streams those calls live to the frontend as the function runs β€” no
75
+ # threading needed, and no risk of interfering with ZeroGPU's execution
76
+ # context. The percentage is paced against historically observed timing
77
+ # (we logged ~49s for a typical run), so it's a real, live-updating bar β€”
78
+ # just not literally tied to internal model layers, which SHARP doesn't
79
+ # expose to us.
80
+ # ────────────────────────────────────────────────────────────────
81
+ def _sharp_core(image_path, timeout_s, env_overrides=None, progress=None, label="", desc_prefix="", est_total=45.0):
82
+ t0 = time.time()
83
+ print(f"[{label}] START image_path={image_path}", flush=True)
84
+ if image_path is None:
85
+ return None, "⚠ Please upload a photo first."
86
+ out_dir = tempfile.mkdtemp(prefix="splat_")
87
+ resized = None
88
+
89
+ def _p(frac, desc):
90
+ if progress is not None:
91
+ try:
92
+ progress(frac, desc=desc_prefix + desc)
93
+ except Exception:
94
+ pass
95
+
96
+ try:
97
+ _p(0.05, "Resizing photo…")
98
+ resized = resize_image(image_path, 512)
99
+ print(f"[{label}] resized -> {resized} (+{time.time()-t0:.1f}s)", flush=True)
100
+
101
+ env = os.environ.copy()
102
+ if env_overrides:
103
+ env.update(env_overrides)
104
+
105
+ _p(0.12, "Starting SHARP…")
106
+ proc = subprocess.Popen(
107
+ ["sharp", "predict", "-i", resized, "-o", out_dir],
108
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env
109
+ )
110
+
111
+ while proc.poll() is None:
112
+ elapsed = time.time() - t0
113
+ if elapsed > timeout_s:
114
+ proc.kill()
115
+ proc.wait(timeout=5)
116
+ raise subprocess.TimeoutExpired(cmd="sharp", timeout=timeout_s)
117
+ frac = 0.12 + 0.78 * min(elapsed / est_total, 1.0)
118
+ _p(frac, f"Generating gaussians… {elapsed:.0f}s")
119
+ time.sleep(0.4)
120
+
121
+ stdout, _ = proc.communicate()
122
+ print(f"[{label}] sharp exited code={proc.returncode} (+{time.time()-t0:.1f}s)", flush=True)
123
+
124
+ _p(0.93, "Reading result…")
125
+ ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")]
126
+ if not ply_files:
127
+ err = (stdout or "No .ply produced.")[-800:]
128
+ print(f"[{label}] FAILED, no .ply produced:\n{err}", flush=True)
129
+ return None, f"SHARP failed:\n{err}"
130
+
131
+ ply_path = os.path.join(out_dir, ply_files[0])
132
+ size_mb = os.path.getsize(ply_path) / 1024 / 1024
133
+ print(f"[{label}] DONE ply={ply_path} size={size_mb:.2f}MB total={time.time()-t0:.1f}s", flush=True)
134
+ _p(1.0, "Done")
135
+ return ply_path, "βœ“ Done β€” 3D scene loading below ↓"
136
+
137
+ except subprocess.TimeoutExpired:
138
+ print(f"[{label}] TIMEOUT after {time.time()-t0:.1f}s", flush=True)
139
+ return None, "⚠ Timed out. Try a smaller/simpler photo, or (if on GPU) increase GPU_DURATION near the top of app.py if you have more quota."
140
+ except FileNotFoundError:
141
+ print(f"[{label}] SHARP binary not found on PATH", flush=True)
142
+ return None, "⚠ SHARP not found yet β€” wait 1 minute and try again."
143
+ except Exception as e:
144
+ print(f"[{label}] EXCEPTION: {e}", flush=True)
145
+ return None, f"⚠ Error: {str(e)}"
146
+ finally:
147
+ if resized:
148
+ try: os.unlink(resized)
149
+ except: pass
150
+
151
+
152
+ # NOTE on GPU_DURATION: HuggingFace's free-tier ZeroGPU accounts get a
153
+ # 5-minute (300s) TOTAL daily quota. Requesting a duration at or above that
154
+ # cap makes the platform reject the call outright ("illegal duration") β€”
155
+ # instantly, before your function even runs. Keeping this comfortably
156
+ # under the cap lets a free/unauthenticated user get a few conversions in
157
+ # per day instead of burning the whole quota on one call.
158
+ GPU_DURATION = 90
159
+ # CPU has no ZeroGPU quota at all, but is much slower β€” this is just a
160
+ # generous soft cap for our own subprocess, not a platform guarantee.
161
+ CPU_TIMEOUT = 300
162
+
163
+
164
+ @spaces.GPU(duration=GPU_DURATION)
165
+ def run_sharp_gpu(image_path, progress=None, desc_prefix=""):
166
+ return _sharp_core(image_path, GPU_DURATION - 15, env_overrides=None,
167
+ progress=progress, label="run_sharp_gpu", desc_prefix=desc_prefix,
168
+ est_total=45.0)
169
+
170
+ def run_sharp_cpu(image_path, progress=None, desc_prefix=""):
171
+ # Forcing CUDA_VISIBLE_DEVICES empty makes SHARP (and torch under it)
172
+ # fall back to CPU even if a GPU happens to be visible in this
173
+ # container, regardless of whether SHARP itself exposes a --device flag.
174
+ return _sharp_core(image_path, CPU_TIMEOUT, env_overrides={"CUDA_VISIBLE_DEVICES": ""},
175
+ progress=progress, label="run_sharp_cpu", desc_prefix=desc_prefix,
176
+ est_total=150.0)
177
+
178
+
179
+ # ────────────────────────────────────────────────────────────────
180
+ # Alt model: TripoSplat, called via its own live public HF Space
181
+ # (VAST-AI/TripoSplat) instead of vendored locally.
182
+ #
183
+ # Why not run it in this Space directly? TripoSplat's own pipeline needs
184
+ # several GB of diffusion/VAE/background-removal checkpoints and a
185
+ # heavier runtime than SHARP's single CLI call β€” that would blow past
186
+ # both our GPU_DURATION budget and this Space's disk allowance. Calling
187
+ # their existing Space offloads the heavy compute to VAST-AI's own
188
+ # ZeroGPU quota instead of ours.
189
+ #
190
+ # Honesty note: the exact positional argument order below is my best
191
+ # reading of their public UI (image, seed, steps, guidance scale, gaussian
192
+ # count, format) β€” I couldn't verify the literal function signature
193
+ # without live-testing it. If it's wrong, client.view_api()'s output
194
+ # (logged below) will show the real one, and it's a one-line fix.
195
+ # ────────────────────────────────────────────────────────────────
196
+ _tripo_client = None
197
+
198
+ def get_tripo_client():
199
+ global _tripo_client
200
+ if _tripo_client is None:
201
+ _tripo_client = GradioClient("VAST-AI/TripoSplat")
202
+ try:
203
+ print(f"[tripo] API schema:\n{_tripo_client.view_api(print_info=False)}", flush=True)
204
+ except Exception as e:
205
+ print(f"[tripo] view_api() failed (non-fatal): {e}", flush=True)
206
+ return _tripo_client
207
+
208
+
209
+ def _extract_ply_path(result):
210
+ """gradio_client results can be nested tuples/dicts/strings depending
211
+ on the target Space's output components β€” walk them looking for
212
+ anything that looks like a .ply (or .splat) file path/url."""
213
+ def _walk(x):
214
+ if isinstance(x, str) and x.lower().endswith((".ply", ".splat")):
215
+ return x
216
+ if isinstance(x, dict):
217
+ for k in ("path", "url", "name"):
218
+ v = x.get(k)
219
+ if isinstance(v, str) and v.lower().endswith((".ply", ".splat")):
220
+ return v
221
+ for v in x.values():
222
+ found = _walk(v)
223
+ if found:
224
+ return found
225
+ if isinstance(x, (list, tuple)):
226
+ for v in x:
227
+ found = _walk(v)
228
+ if found:
229
+ return found
230
+ return None
231
+ return _walk(result)
232
+
233
+
234
+ def run_tripo(image_path, progress=None, desc_prefix="", num_gaussians=131072):
235
+ t0 = time.time()
236
+ print(f"[run_tripo] START image_path={image_path}", flush=True)
237
+
238
+ def _p(frac, desc):
239
+ if progress is not None:
240
+ try:
241
+ progress(frac, desc=desc_prefix + desc)
242
+ except Exception:
243
+ pass
244
+
245
+ if not HAS_GRADIO_CLIENT:
246
+ return None, "⚠ gradio_client isn't installed on this Space yet β€” add `gradio_client` to requirements.txt and redeploy."
247
+
248
+ try:
249
+ _p(0.1, "Connecting to TripoSplat's live demo…")
250
+ client = get_tripo_client()
251
+ _p(0.25, "Generating on TripoSplat (may take 20–90s, longer if their Space is asleep)…")
252
+ # Verified against TripoSplat's own app.py source (they use a custom
253
+ # gradio.Server + @app.api() endpoint, not a plain gr.Blocks demo β€”
254
+ # their real signature is:
255
+ # generate(image, seed=42, steps=20, guidance_scale=3.0,
256
+ # num_gaussians=262144, output_format="ply")
257
+ # -> (preprocessed_image, ply_file, download_file, info_string)
258
+ # Calling with keyword args + their real api_name, not guessed
259
+ # positional args against an assumed "/predict" name.
260
+ result = client.predict(
261
+ image=gradio_handle_file(image_path),
262
+ seed=42,
263
+ steps=20,
264
+ guidance_scale=3.0,
265
+ num_gaussians=int(num_gaussians),
266
+ output_format="ply",
267
+ api_name="/generate"
268
+ )
269
+ print(f"[run_tripo] raw result (+{time.time()-t0:.1f}s): {str(result)[:500]}", flush=True)
270
+ _p(0.9, "Finalizing…")
271
+ ply_path = _extract_ply_path(result)
272
+ if not ply_path or not os.path.exists(ply_path):
273
+ return None, (f"⚠ TripoSplat responded but I couldn't find a .ply in the result. "
274
+ f"Raw response logged to the HF Space logs for debugging.")
275
+ _p(1.0, "Done")
276
+ return ply_path, "βœ“ Done (via TripoSplat) β€” 3D scene loading below ↓"
277
+ except Exception as e:
278
+ print(f"[run_tripo] EXCEPTION: {e}", flush=True)
279
+ return None, (f"⚠ TripoSplat call failed: {str(e)}\n"
280
+ f"(This calls VAST-AI's public demo directly β€” it may be asleep, "
281
+ f"rate-limited, or have changed its API since. Check the HF "
282
+ f"Space logs for the exact error and the real API schema.)")
283
+
284
+
285
+ # ────────────────────────────────────────────────────────────────
286
+ # Auto-clean: prune near-invisible / statistically-stray gaussians.
287
+ # This is a pure-CPU numpy pass over the vertex data β€” runs outside the
288
+ # GPU-decorated functions on purpose, so it never eats into ZeroGPU quota.
289
+ # ────────────────────────────────────────────────────────────────
290
+ def clean_ply(ply_path, opacity_thresh=0.04, mad_k=6.0):
291
+ if not HAS_PLYFILE:
292
+ return ply_path, "skipped (plyfile not installed)"
293
+
294
+ plydata = PlyData.read(ply_path)
295
+ if "vertex" not in plydata:
296
+ return ply_path, "skipped (no vertex element)"
297
+
298
+ v = plydata["vertex"]
299
+ n0 = len(v.data)
300
+ if n0 == 0:
301
+ return ply_path, "skipped (empty)"
302
+
303
+ names = v.data.dtype.names
304
+ if not all(k in names for k in ("x", "y", "z")):
305
+ return ply_path, "skipped (unexpected format)"
306
+
307
+ xyz = np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64)
308
+
309
+ if "opacity" in names:
310
+ op = np.asarray(v["opacity"], dtype=np.float64)
311
+ # 3DGS often stores opacity as a raw pre-sigmoid logit rather than
312
+ # a 0-1 probability β€” detect that and convert if so.
313
+ if op.min() < -0.01 or op.max() > 1.01:
314
+ op_prob = 1.0 / (1.0 + np.exp(-op))
315
+ else:
316
+ op_prob = op
317
+ keep_opacity = op_prob >= opacity_thresh
318
+ else:
319
+ keep_opacity = np.ones(n0, dtype=bool)
320
+
321
+ # Robust outlier rejection via median absolute deviation (MAD) β€” more
322
+ # resistant to the very outliers we're trying to remove than mean/std.
323
+ med = np.median(xyz, axis=0)
324
+ mad = np.median(np.abs(xyz - med), axis=0) + 1e-6
325
+ dist = np.abs(xyz - med) / mad
326
+ keep_pos = np.all(dist <= mad_k, axis=1)
327
+
328
+ keep = keep_opacity & keep_pos
329
+ n1 = int(keep.sum())
330
+
331
+ if n1 == 0 or n1 == n0:
332
+ return ply_path, f"{n0:,} gaussians (no change)"
333
+
334
+ new_data = v.data[keep]
335
+ new_el = PlyElement.describe(new_data, "vertex")
336
+ out_path = ply_path[:-4] + "_clean.ply" if ply_path.endswith(".ply") else ply_path + "_clean.ply"
337
+ PlyData([new_el], text=plydata.text, byte_order=plydata.byte_order).write(out_path)
338
+
339
+ size_before = os.path.getsize(ply_path) / 1024 / 1024
340
+ size_after = os.path.getsize(out_path) / 1024 / 1024
341
+ pct = (1 - n1 / n0) * 100
342
+ return out_path, f"{n0:,} β†’ {n1:,} gaussians ({pct:.0f}% pruned) Β· {size_before:.1f}MB β†’ {size_after:.1f}MB"
343
+
344
+
345
+ # ── Animation HTML (two angles, camera flies between them) ──
346
+ def generate_animation_html(ply1, ply2):
347
+ def enc(p):
348
+ with open(p, "rb") as f:
349
+ return base64.b64encode(f.read()).decode("utf-8")
350
+ b1, b2 = enc(ply1), enc(ply2)
351
+ return f"""<!DOCTYPE html>
352
+ <html lang="en">
353
+ <head>
354
+ <meta charset="UTF-8"/>
355
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
356
+ <title>SplatWeb Animation</title>
357
+ <style>
358
+ *{{box-sizing:border-box;margin:0;padding:0}}
359
+ body{{background:#050810;color:#dce8ff;font-family:monospace;height:100vh;display:flex;flex-direction:column;overflow:hidden}}
360
+ #hdr{{display:flex;align-items:center;justify-content:space-between;padding:.8rem 1.4rem;
361
+ border-bottom:1px solid rgba(77,138,255,.15);background:rgba(5,8,16,.9);backdrop-filter:blur(10px);flex-shrink:0;gap:.8rem}}
362
+ .logo{{font-size:1.1rem;font-weight:bold;letter-spacing:.06em;background:linear-gradient(90deg,#4d8aff,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
363
+ .hr{{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;justify-content:flex-end}}
364
+ .badge{{font-size:.58rem;color:#22d3a0;border:1px solid rgba(34,211,160,.3);padding:.22rem .65rem;border-radius:100px;white-space:nowrap}}
365
+ #btndl{{background:linear-gradient(135deg,#4d8aff,#8b5cf6);border:none;border-radius:8px;padding:.32rem .85rem;
366
+ color:#fff;font-family:monospace;font-size:.6rem;cursor:pointer;white-space:nowrap;transition:all .2s}}
367
+ #btndl:hover{{transform:translateY(-1px)}}
368
+ #wrap{{flex:1;position:relative;overflow:hidden}}
369
+ canvas{{width:100%!important;height:100%!important;display:block}}
370
+ #ov{{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:rgba(5,8,16,.78);backdrop-filter:blur(4px)}}
371
+ #ov.hidden{{display:none}}
372
+ .sp{{width:42px;height:42px;border:2px solid rgba(77,138,255,.18);border-top-color:#4d8aff;border-radius:50%;animation:spin 1s linear infinite}}
373
+ @keyframes spin{{to{{transform:rotate(360deg)}}}}
374
+ #lm{{font-size:.7rem;color:rgba(180,200,255,.5);letter-spacing:.1em}}
375
+ #sl{{position:absolute;top:1rem;left:50%;transform:translateX(-50%);font-size:.6rem;color:rgba(180,200,255,.4);
376
+ background:rgba(0,0,0,.55);padding:.25rem .9rem;border-radius:100px;border:1px solid rgba(77,138,255,.12);display:none;white-space:nowrap}}
377
+ #ctrls{{position:absolute;bottom:1.2rem;left:50%;transform:translateX(-50%);display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}}
378
+ .pill{{background:rgba(0,0,0,.62);border:1px solid rgba(255,255,255,.07);border-radius:100px;padding:.28rem .72rem;font-size:.57rem;color:rgba(180,200,255,.42)}}
379
+ #pbw{{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(77,138,255,.1)}}
380
+ #pb{{height:100%;background:linear-gradient(90deg,#4d8aff,#8b5cf6,#22d3a0);width:0%;transition:width .12s linear}}
381
+ </style>
382
+ </head>
383
+ <body>
384
+ <div id="hdr">
385
+ <div class="logo">SPLATWEB</div>
386
+ <div class="hr">
387
+ <div class="badge">✦ 3D KEYFRAME ANIMATION</div>
388
+ <button id="btndl" onclick="saveme()">⬇ SAVE HTML</button>
389
+ </div>
390
+ </div>
391
+ <div id="wrap">
392
+ <canvas id="c"></canvas>
393
+ <div id="ov"><div class="sp"></div><div id="lm">LOADING 3D SCENES…</div></div>
394
+ <div id="sl">ANGLE 1</div>
395
+ <div id="ctrls">
396
+ <div class="pill">Drag β†’ Orbit</div>
397
+ <div class="pill">Scroll β†’ Zoom</div>
398
+ <div class="pill">Camera auto-animates</div>
399
+ </div>
400
+ <div id="pbw"><div id="pb"></div></div>
401
+ </div>
402
+ <script type="importmap">{{"imports":{{"@mkkellogg/gaussian-splats-3d":"https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js"}}}}</script>
403
+ <script type="module">
404
+ import * as G from '@mkkellogg/gaussian-splats-3d';
405
+ function b2u(b){{const bin=atob(b),buf=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)buf[i]=bin.charCodeAt(i);return URL.createObjectURL(new Blob([buf],{{type:'application/octet-stream'}}));}}
406
+ const u1=b2u(`{b1}`),u2=b2u(`{b2}`);
407
+ const canvas=document.getElementById('c'),ov=document.getElementById('ov'),lm=document.getElementById('lm'),pb=document.getElementById('pb'),sl=document.getElementById('sl');
408
+ const A={{x:-2.5,y:-1.2,z:5}},B={{x:2.5,y:-.4,z:5}},TRAVEL=4000,HOLD=1500;
409
+ let viewer=null,phase='hold_a',ps=null;
410
+ const ease=t=>t<.5?2*t*t:-1+(4-2*t)*t,lerp=(a,b,t)=>a+(b-a)*t;
411
+ function tick(now){{
412
+ if(!viewer||!viewer.camera){{requestAnimationFrame(tick);return;}}
413
+ if(!ps)ps=now;const e=now-ps,cam=viewer.camera;
414
+ if(phase==='hold_a'){{cam.position.set(A.x,A.y,A.z);pb.style.width='0%';sl.textContent='ANGLE 1';if(e>=HOLD){{phase='a_to_b';ps=now;}}}}
415
+ else if(phase==='a_to_b'){{const t=ease(Math.min(e/TRAVEL,1));cam.position.set(lerp(A.x,B.x,t),lerp(A.y,B.y,t),lerp(A.z,B.z,t));pb.style.width=(t*100)+'%';sl.textContent='ANGLE 1 β†’ ANGLE 2';if(e>=TRAVEL){{phase='hold_b';ps=now;}}}}
416
+ else if(phase==='hold_b'){{cam.position.set(B.x,B.y,B.z);pb.style.width='100%';sl.textContent='ANGLE 2';if(e>=HOLD){{phase='b_to_a';ps=now;}}}}
417
+ else if(phase==='b_to_a'){{const t=ease(Math.min(e/TRAVEL,1));cam.position.set(lerp(B.x,A.x,t),lerp(B.y,A.y,t),lerp(B.z,A.z,t));pb.style.width=((1-t)*100)+'%';sl.textContent='ANGLE 2 β†’ ANGLE 1';if(e>=TRAVEL){{phase='hold_a';ps=now;}}}}
418
+ cam.lookAt(0,0,0);requestAnimationFrame(tick);
419
+ }}
420
+ async function init(){{
421
+ viewer=new G.Viewer({{canvas,cameraUp:[0,-1,0],initialCameraPosition:[A.x,A.y,A.z],initialCameraLookAt:[0,0,0],selfDrivenMode:true,dynamicScene:true}});
422
+ try{{
423
+ lm.textContent='LOADING ANGLE 1…';await viewer.addSplatScene(u1,{{progressiveLoad:false}});
424
+ lm.textContent='LOADING ANGLE 2…';await viewer.addSplatScene(u2,{{progressiveLoad:false}});
425
+ ov.classList.add('hidden');sl.style.display='block';viewer.start();requestAnimationFrame(tick);
426
+ }}catch(err){{lm.textContent='⚠ Load error.';console.error(err);}}
427
+ }}
428
+ init();
429
+ </script>
430
+ <script>function saveme(){{const blob=new Blob([document.documentElement.outerHTML],{{type:'text/html'}});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='splatweb-animation.html';document.body.appendChild(a);a.click();document.body.removeChild(a);}}</script>
431
+ </body></html>"""
432
+
433
+
434
+ # ────────────────────────────────────────────────────────────────
435
+ # CSS β€” back in gr.Blocks() where it belongs in Gradio 6
436
+ # (shows a harmless warning in logs but does NOT crash)
437
+ # ────────────────────────────────────────────────────────────────
438
+ CSS = """
439
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400;500&display=swap');
440
+
441
+ /* ── Theme via Gradio's own CSS variables β€” the stable, documented way
442
+ to reskin native components (radios, sliders, buttons, tabs, inputs)
443
+ without depending on internal class names that change between
444
+ Gradio versions. ── */
445
+ :root, .dark {
446
+ --body-background-fill: #050810;
447
+ --background-fill-primary: #050810;
448
+ --background-fill-secondary: #0a0f1c;
449
+ --block-background-fill: #0a0f1c;
450
+ --block-border-color: rgba(77,138,255,0.16);
451
+ --block-label-text-color: #8b9ac4;
452
+ --block-title-text-color: #dce8ff;
453
+ --body-text-color: #dce8ff;
454
+ --body-text-color-subdued: #8b9ac4;
455
+ --border-color-primary: rgba(77,138,255,0.18);
456
+ --border-color-accent: #4d8aff;
457
+ --input-background-fill: #0d1220;
458
+ --input-border-color: rgba(77,138,255,0.22);
459
+ --input-border-color-focus: #4d8aff;
460
+ --checkbox-background-color: #0d1220;
461
+ --checkbox-background-color-selected: #4d8aff;
462
+ --checkbox-border-color: rgba(77,138,255,0.3);
463
+ --checkbox-border-color-selected: #4d8aff;
464
+ --checkbox-label-background-fill: #0d1220;
465
+ --checkbox-label-background-fill-selected: linear-gradient(135deg,#4d8aff,#8b5cf6);
466
+ --checkbox-label-border-color: rgba(77,138,255,0.22);
467
+ --checkbox-label-border-color-selected: #4d8aff;
468
+ --checkbox-label-text-color: #8b9ac4;
469
+ --checkbox-label-text-color-selected: #fff;
470
+ --slider-color: #4d8aff;
471
+ --button-primary-background-fill: linear-gradient(135deg,#4d8aff,#8b5cf6);
472
+ --button-primary-background-fill-hover: linear-gradient(135deg,#5f97ff,#9a6cf7);
473
+ --button-primary-text-color: #fff;
474
+ --button-secondary-background-fill: #0d1220;
475
+ --button-secondary-border-color: rgba(77,138,255,0.25);
476
+ --button-secondary-text-color: #dce8ff;
477
+ --panel-background-fill: #0a0f1c;
478
+ --shadow-drop: 0 4px 20px rgba(77,138,255,0.08);
479
+ }
480
+
481
+ body, .gradio-container {
482
+ background: #050810 !important;
483
+ font-family: 'JetBrains Mono', monospace !important;
484
+ }
485
+ .gradio-container { max-width: 820px !important; margin: 0 auto !important; }
486
+
487
+ h1 {
488
+ font-family: 'Syne', sans-serif !important;
489
+ font-weight: 800 !important;
490
+ font-size: 2.6rem !important;
491
+ background: linear-gradient(135deg, #4d8aff, #8b5cf6, #22d3a0) !important;
492
+ -webkit-background-clip: text !important;
493
+ -webkit-text-fill-color: transparent !important;
494
+ letter-spacing: -0.02em !important;
495
+ line-height: 1.1 !important;
496
+ margin-bottom: 0.5rem !important;
497
+ }
498
+
499
+ /* Buttons */
500
+ button.lg { font-family: 'Syne', sans-serif !important; font-weight: 700 !important; letter-spacing: 0.02em; }
501
+ button.primary, .gradio-container button[variant="primary"] {
502
+ border: none !important;
503
+ box-shadow: 0 4px 18px rgba(77,138,255,0.35) !important;
504
+ transition: transform 0.15s ease, box-shadow 0.15s ease !important;
505
+ }
506
+ button.primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 22px rgba(139,92,246,0.4) !important; }
507
+
508
+ /* Panels / blocks (image uploader, textboxes, tips accordion, etc.) */
509
+ .block, .form {
510
+ border-radius: 14px !important;
511
+ border: 1px solid rgba(77,138,255,0.14) !important;
512
+ }
513
+
514
+ /* Tab navigation */
515
+ .tab-nav {
516
+ border-bottom: 1px solid rgba(77,138,255,0.14) !important;
517
+ gap: 0.4rem !important;
518
+ }
519
+ .tab-nav button {
520
+ font-family: 'JetBrains Mono', monospace !important;
521
+ color: #8b9ac4 !important;
522
+ border-radius: 10px 10px 0 0 !important;
523
+ }
524
+ .tab-nav button.selected {
525
+ color: #4d8aff !important;
526
+ font-weight: 600 !important;
527
+ border-bottom: 2px solid #4d8aff !important;
528
+ background: rgba(77,138,255,0.06) !important;
529
+ }
530
+
531
+ /* Radio / checkbox pill groups */
532
+ label.selected, .selected > label {
533
+ box-shadow: 0 2px 12px rgba(77,138,255,0.25) !important;
534
+ }
535
+ input[type="radio"], input[type="checkbox"] {
536
+ accent-color: #4d8aff !important;
537
+ }
538
+
539
+ /* Sliders */
540
+ input[type="range"] { accent-color: #4d8aff !important; }
541
+
542
+ /* Accordion (Tips section) */
543
+ .label-wrap {
544
+ border-radius: 10px !important;
545
+ }
546
+
547
+ /* Textboxes / status output */
548
+ textarea, input[type="text"] {
549
+ font-family: 'JetBrains Mono', monospace !important;
550
+ border-radius: 10px !important;
551
+ }
552
+
553
+ @keyframes sw_spin { to { transform: rotate(360deg); } }
554
+ """
555
+
556
+
557
+ # ────────────────────────────────────────────────────────────────
558
+ # 3D viewer β€” parameterized so multiple independent instances can exist
559
+ # on the page (Single Photo tab + the standalone .ply viewer tab) without
560
+ # colliding on element IDs. Each instance is looked up by its instance_id
561
+ # via data-role attributes scoped inside its own container div.
562
+ # ────────────────────────────────────────────────────────────────
563
+ def make_viewer_shell(instance_id: str) -> str:
564
+ return f"""
565
+ <div id="splatweb-viewer-{instance_id}"
566
+ style="background:#050810;border:1px solid rgba(77,138,255,0.18);
567
+ border-radius:18px;overflow:hidden;">
568
+
569
+ <div style="display:flex;align-items:center;justify-content:space-between;
570
+ padding:0.7rem 1.2rem;border-bottom:1px solid rgba(77,138,255,0.1);
571
+ background:rgba(5,8,16,0.95);gap:0.6rem;flex-wrap:wrap;">
572
+ <div>
573
+ <span style="font-family:monospace;font-size:0.68rem;font-weight:bold;
574
+ background:linear-gradient(90deg,#4d8aff,#8b5cf6);
575
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;
576
+ letter-spacing:0.06em;">✦ 3D SCENE VIEWER</span>
577
+ <span data-role="size" style="font-family:monospace;font-size:0.55rem;
578
+ color:rgba(180,200,255,0.3);margin-left:0.5rem;"></span>
579
+ </div>
580
+ <button data-role="dlbtn" onclick="swDownload('{instance_id}')"
581
+ style="display:none;background:linear-gradient(135deg,#4d8aff,#8b5cf6);
582
+ border:none;border-radius:8px;padding:0.3rem 0.9rem;color:#fff;
583
+ font-family:monospace;font-size:0.6rem;letter-spacing:0.07em;
584
+ cursor:pointer;box-shadow:0 2px 10px rgba(77,138,255,0.3);">
585
+ ⬇ DOWNLOAD .PLY
586
+ </button>
587
+ </div>
588
+
589
+ <div style="position:relative;width:100%;height:460px;">
590
+ <canvas data-role="canvas"
591
+ style="width:100%;height:100%;display:block;
592
+ background:radial-gradient(ellipse at center,#071020,#020408);"></canvas>
593
+
594
+ <div data-role="idle"
595
+ style="position:absolute;inset:0;display:flex;flex-direction:column;
596
+ align-items:center;justify-content:center;gap:0.8rem;
597
+ background:rgba(5,8,16,0.85);">
598
+ <div style="font-size:2.5rem;opacity:0.3;">✦</div>
599
+ <div style="font-family:monospace;font-size:0.7rem;color:rgba(180,200,255,0.35);
600
+ letter-spacing:0.12em;text-align:center;line-height:1.7;">
601
+ 3D SCENE WILL APPEAR HERE<br/>
602
+ <span style="font-size:0.58rem;opacity:0.6;">Upload a photo and press Build β†’</span>
603
+ </div>
604
+ </div>
605
+
606
+ <div data-role="loading"
607
+ style="position:absolute;inset:0;display:none;flex-direction:column;
608
+ align-items:center;justify-content:center;gap:1rem;
609
+ background:rgba(5,8,16,0.82);backdrop-filter:blur(4px);">
610
+ <div style="width:40px;height:40px;
611
+ border:2px solid rgba(77,138,255,0.2);
612
+ border-top-color:#4d8aff;border-radius:50%;
613
+ animation:sw_spin 1s linear infinite;"></div>
614
+ <div data-role="loadmsg"
615
+ style="font-family:monospace;font-size:0.68rem;
616
+ color:rgba(180,200,255,0.55);letter-spacing:0.1em;">
617
+ BUILDING 3D SCENE…
618
+ </div>
619
+ </div>
620
+
621
+ <div data-role="controls"
622
+ style="position:absolute;bottom:0.9rem;left:50%;transform:translateX(-50%);
623
+ display:none;gap:0.4rem;flex-wrap:wrap;justify-content:center;pointer-events:none;">
624
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
625
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
626
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
627
+ color:rgba(180,200,255,0.4);">Drag β†’ Orbit</span>
628
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
629
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
630
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
631
+ color:rgba(180,200,255,0.4);">Scroll β†’ Zoom</span>
632
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
633
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
634
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
635
+ color:rgba(180,200,255,0.4);">Shift+Drag β†’ Pan</span>
636
+ </div>
637
+ </div>
638
+
639
+ <details style="border-top:1px solid rgba(77,138,255,0.1);">
640
+ <summary style="cursor:pointer;padding:0.5rem 0.8rem;font-family:monospace;font-size:0.6rem;
641
+ color:rgba(180,200,255,0.45);letter-spacing:0.08em;">
642
+ // debug log (tap to expand)
643
+ </summary>
644
+ <pre data-role="debug" style="font-family:monospace;font-size:0.58rem;line-height:1.5;
645
+ color:rgba(180,200,255,0.55);padding:0 0.8rem 0.7rem;margin:0;
646
+ max-height:180px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;"></pre>
647
+ </details>
648
+ </div>
649
+ """
650
+
651
+
652
+ # One shared script block driving every viewer instance on the page,
653
+ # keyed by instance_id via data-role lookups scoped to each container.
654
+ VIEWER_SCRIPT = """
655
+ <script type="importmap">
656
+ {
657
+ "imports": {
658
+ "@mkkellogg/gaussian-splats-3d":
659
+ "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js"
660
+ }
661
+ }
662
+ </script>
663
+
664
+ <script type="module">
665
+ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
666
+
667
+ window._swState = window._swState || {};
668
+
669
+ function _swEls(id) {
670
+ const root = document.getElementById('splatweb-viewer-' + id);
671
+ if (!root) return null;
672
+ return {
673
+ idle: root.querySelector('[data-role="idle"]'),
674
+ loading: root.querySelector('[data-role="loading"]'),
675
+ controls: root.querySelector('[data-role="controls"]'),
676
+ dlBtn: root.querySelector('[data-role="dlbtn"]'),
677
+ sizeEl: root.querySelector('[data-role="size"]'),
678
+ canvas: root.querySelector('[data-role="canvas"]'),
679
+ msg: root.querySelector('[data-role="loadmsg"]'),
680
+ debug: root.querySelector('[data-role="debug"]'),
681
+ };
682
+ }
683
+
684
+ window.swLog = function(id, msg) {
685
+ const t = new Date().toTimeString().slice(0, 8);
686
+ console.log('[SplatWeb:' + id + ']', msg);
687
+ const els = _swEls(id);
688
+ if (els && els.debug) {
689
+ els.debug.textContent += `[${t}] ${msg}\\n`;
690
+ els.debug.scrollTop = els.debug.scrollHeight;
691
+ }
692
+ };
693
+
694
+ window.swLoadUrl = async function(id, fileUrl, sizeMb) {
695
+ const els = _swEls(id);
696
+ if (!els) { console.error('SplatWeb: no viewer found for instance', id); return; }
697
+ const { idle, loading, controls, dlBtn, sizeEl, canvas, msg } = els;
698
+
699
+ window.swLog(id, 'trigger fired β†’ ' + fileUrl);
700
+ idle.style.display = 'none';
701
+ loading.style.display = 'flex';
702
+ controls.style.display = 'none';
703
+ if (sizeEl) sizeEl.textContent = (sizeMb ? sizeMb + ' MB Β· ' : '') + 'WebGL Β· your GPU';
704
+
705
+ try {
706
+ msg.textContent = 'DOWNLOADING SCENE…';
707
+ window.swLog(id, 'fetching file…');
708
+ const resp = await fetch(fileUrl);
709
+ window.swLog(id, 'fetch responded: HTTP ' + resp.status);
710
+ if (!resp.ok) throw new Error('server returned HTTP ' + resp.status + ' for the file URL');
711
+
712
+ const buf = await resp.arrayBuffer();
713
+ window.swLog(id, 'downloaded ' + (buf.byteLength / 1024 / 1024).toFixed(2) + ' MB');
714
+ const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }));
715
+
716
+ const prev = window._swState[id];
717
+ if (prev) {
718
+ if (prev.blobUrl) { try { URL.revokeObjectURL(prev.blobUrl); } catch(e) {} }
719
+ if (prev.viewer) { try { prev.viewer.dispose(); } catch(e) {} }
720
+ }
721
+
722
+ window.swLog(id, 'initializing WebGL viewer…');
723
+ const viewer = new GaussianSplats3D.Viewer({
724
+ canvas,
725
+ cameraUp: [0, -1, 0],
726
+ initialCameraPosition: [-1, -4, 6],
727
+ initialCameraLookAt: [0, 0, 0],
728
+ selfDrivenMode: true,
729
+ });
730
+
731
+ msg.textContent = 'RENDERING GAUSSIANS…';
732
+ window.swLog(id, 'parsing splat data + uploading to GPU…');
733
+ await viewer.addSplatScene(blobUrl, { progressiveLoad: true });
734
+ window.swLog(id, 'scene loaded βœ“');
735
+
736
+ window._swState[id] = { viewer, blobUrl };
737
+
738
+ loading.style.display = 'none';
739
+ controls.style.display = 'flex';
740
+ if (dlBtn) dlBtn.style.display = 'block';
741
+ viewer.start();
742
+
743
+ } catch(err) {
744
+ const emsg = (err && err.message) ? err.message : String(err);
745
+ window.swLog(id, 'ERROR: ' + emsg);
746
+ msg.textContent = '⚠ ' + emsg;
747
+ console.error(err);
748
+ }
749
+ };
750
+
751
+ window.swDownload = function(id) {
752
+ const state = window._swState[id];
753
+ if (!state || !state.blobUrl) return;
754
+ const a = document.createElement('a');
755
+ a.href = state.blobUrl;
756
+ a.download = 'scene.ply';
757
+ document.body.appendChild(a);
758
+ a.click();
759
+ document.body.removeChild(a);
760
+ };
761
+ </script>
762
+ """
763
+
764
+
765
+ # Gradio serves any file under its allowed paths (which includes the
766
+ # system temp dir β€” where our .ply files live) at this documented route:
767
+ # /gradio_api/file=<path>
768
+ def make_ply_url(ply_path: str) -> str:
769
+ return "/gradio_api/file=" + urllib.parse.quote(ply_path, safe="/")
770
+
771
+
772
+ # ────────────────────────────────────────────────────────────────
773
+ # UI β€” css in gr.Blocks() (correct for all Gradio versions)
774
+ # ────────────────────────────────────────────────────────────────
775
+ with gr.Blocks(css=CSS, title="SplatWeb") as demo:
776
+
777
+ gr.HTML("""
778
+ <div style="text-align:center;padding:2.5rem 1rem 1.5rem;
779
+ border-bottom:1px solid rgba(80,130,255,0.1);margin-bottom:1.5rem;">
780
+ <h1>SPLATWEB</h1>
781
+ <p style="color:rgba(180,200,255,0.4);font-size:0.72rem;
782
+ letter-spacing:0.1em;margin-top:0.3rem;">
783
+ PHOTO β†’ 3D GAUSSIAN SPLAT Β· MULTI-MODEL Β· FREE
784
+ </p>
785
+ <div style="display:inline-flex;gap:0.6rem;margin-top:0.8rem;
786
+ flex-wrap:wrap;justify-content:center;">
787
+ <span style="background:rgba(34,211,160,0.08);border:1px solid rgba(34,211,160,0.2);
788
+ color:#22d3a0;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
789
+ βœ“ 100% FREE</span>
790
+ <span style="background:rgba(77,138,255,0.08);border:1px solid rgba(77,138,255,0.2);
791
+ color:#4d8aff;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
792
+ ⚑ AUTO 3D PREVIEW</span>
793
+ <span style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);
794
+ color:#8b5cf6;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
795
+ ✦ CAMERA ANIMATION</span>
796
+ </div>
797
+ </div>
798
+ """)
799
+
800
+ with gr.Tabs():
801
+
802
+ # ── Tab 1: Single photo ──────────────────────────────
803
+ with gr.TabItem("πŸ“· Single Photo β†’ 3D"):
804
+
805
+ img1 = gr.Image(
806
+ type="filepath", label="// upload photo",
807
+ sources=["upload", "webcam"], height=260
808
+ )
809
+ with gr.Accordion("πŸ“Έ Tips for best results", open=False):
810
+ gr.Markdown("""
811
+ - Any size photo β€” app auto-resizes to 512px before processing
812
+ - Clear, well-lit subject gives the best 3D quality
813
+ - Works on: objects, rooms, food, people, landscapes
814
+ - **GPU:** fast (~45s), but free HF accounts get 5 min/day total ZeroGPU quota
815
+ - **CPU:** slow (~2-4 min), but no daily quota limit
816
+ - **ZeroGPU hardware must be selected in your Space's Settings β†’ Hardware**
817
+ - **TripoSplat (alt model):** experimental β€” calls a different team's live public demo, so it can be slower/less reliable, but lets you control the Gaussian count
818
+ - **Auto-clean:** prunes near-invisible and statistically stray gaussians β€” usually shrinks the file and reduces noise with no visible quality loss
819
+ """)
820
+
821
+ with gr.Row():
822
+ device_choice = gr.Radio(
823
+ ["⚑ GPU (fast, uses daily quota)", "🐒 CPU (slower, no quota limit)"],
824
+ value="⚑ GPU (fast, uses daily quota)",
825
+ label="Processing device"
826
+ )
827
+ model_choice = gr.Radio(
828
+ ["Apple SHARP (built-in)", "TripoSplat (experimental, via live demo)"],
829
+ value="Apple SHARP (built-in)",
830
+ label="Model"
831
+ )
832
+ tripo_gaussians = gr.Slider(
833
+ minimum=32768, maximum=262144, step=32768, value=131072,
834
+ label="Gaussian count (TripoSplat only)",
835
+ info="Higher = more detail + bigger file. Ignored when using SHARP."
836
+ )
837
+ clean_toggle = gr.Checkbox(
838
+ value=True,
839
+ label="βœ‚ Auto-clean stray noise gaussians (recommended)"
840
+ )
841
+
842
+ btn1 = gr.Button("✦ Build My 3D Scene", variant="primary", size="lg")
843
+ st1 = gr.Textbox(
844
+ label="// status", interactive=False, lines=3,
845
+ placeholder="Upload a photo and press the button…"
846
+ )
847
+
848
+ # 3D viewer β€” always visible, waits for content
849
+ gr.HTML(make_viewer_shell("v1"))
850
+
851
+ # Real, native Gradio download β€” works regardless of whether
852
+ # the custom WebGL preview above loads, so you always have a
853
+ # reliable way to get the file and open it in another app.
854
+ dl1 = gr.DownloadButton("⬇ Download .ply", visible=False)
855
+
856
+ # Hidden trigger β€” a plain Textbox (not gr.HTML). Browsers (and
857
+ # Gradio's Svelte renderer) never execute <script> tags inserted
858
+ # via innerHTML on a dynamic update β€” only on the page's
859
+ # *initial* load. A Textbox's .change(js=...) event is Gradio's
860
+ # actual supported mechanism for running JS off a Python return
861
+ # value, and reliably passes the exact string we set.
862
+ url_holder = gr.Textbox(value="", visible=False, elem_id="sw-url-holder")
863
+
864
+ # ── Tab 2: Two angles + animation ───────────────────
865
+ with gr.TabItem("🎬 Two Angles β†’ Animation"):
866
+
867
+ gr.HTML("""
868
+ <div style="background:rgba(139,92,246,0.06);
869
+ border:1px solid rgba(139,92,246,0.18);
870
+ border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;">
871
+ <div style="font-size:0.62rem;color:#8b5cf6;
872
+ letter-spacing:0.1em;margin-bottom:0.4rem;">✦ HOW THIS WORKS</div>
873
+ <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;">
874
+ Upload 2 photos of the same object from different angles.
875
+ Both convert to 3D. Enable animation to get a downloadable HTML file β€”
876
+ open it on any phone, camera flies between both angles in a loop.
877
+ The HTML has a <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> button inside.
878
+ </div>
879
+ </div>
880
+ """)
881
+
882
+ with gr.Row():
883
+ imgA = gr.Image(
884
+ type="filepath", label="// angle 1 β€” front / left",
885
+ sources=["upload"], height=220
886
+ )
887
+ imgB = gr.Image(
888
+ type="filepath", label="// angle 2 β€” back / right",
889
+ sources=["upload"], height=220
890
+ )
891
+
892
+ anim_toggle = gr.Checkbox(
893
+ label="✦ Generate camera animation HTML",
894
+ value=True,
895
+ info="Downloadable .html β€” camera flies between angles in a loop, has ⬇ SAVE HTML inside"
896
+ )
897
+ btn2 = gr.Button("✦ Build 3D + Animation", variant="primary", size="lg")
898
+ st2 = gr.Textbox(
899
+ label="// status", interactive=False, lines=3,
900
+ placeholder="Upload both photos and press the button…"
901
+ )
902
+
903
+ anim_file = gr.File(
904
+ label="// animation .html β€” download & open on any phone",
905
+ visible=False, file_types=[".html"]
906
+ )
907
+
908
+ gr.HTML("""
909
+ <div style="margin-top:0.8rem;padding:0.9rem 1rem;
910
+ background:rgba(34,211,160,0.04);
911
+ border:1px solid rgba(34,211,160,0.1);
912
+ border-radius:10px;font-size:0.65rem;
913
+ color:rgba(180,200,255,0.38);line-height:1.8;">
914
+ πŸ“± Download animation .html β†’ open in Chrome or Safari on any phone β†’ 3D plays.<br/>
915
+ πŸ“€ Share via WhatsApp / email β€” recipient just opens the file, no app needed.<br/>
916
+ πŸ’Ύ Tap <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> inside to re-download anytime.
917
+ </div>
918
+ """)
919
+
920
+ # ── Tab 3: standalone .ply viewer (NEW) ──────────────
921
+ with gr.TabItem("πŸ—‚ View a .PLY File"):
922
+
923
+ gr.HTML("""
924
+ <div style="background:rgba(34,211,160,0.06);
925
+ border:1px solid rgba(34,211,160,0.18);
926
+ border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;">
927
+ <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;">
928
+ Already have a Gaussian Splat <strong style="color:#dce8ff;">.ply</strong> file
929
+ (from this app, SuperSplat, Luma, Polycam, etc.)? Drop it here to preview it
930
+ directly β€” no GPU processing needed, this just loads it into the same viewer.
931
+ </div>
932
+ </div>
933
+ """)
934
+
935
+ ply_upload = gr.File(label="// upload a .ply file", file_types=[".ply"], type="filepath")
936
+ btn3 = gr.Button("πŸ‘ Load in Viewer", variant="primary", size="lg")
937
+ st3 = gr.Textbox(label="// status", interactive=False, lines=1)
938
+
939
+ gr.HTML(make_viewer_shell("v3"))
940
+ url_holder3 = gr.Textbox(value="", visible=False, elem_id="sw-url-holder-v3")
941
+
942
+ # ── Handlers ──────────────────────────────────────────────
943
+ def handle_single(img, device_choice, model_choice, clean_toggle, tripo_gaussians, progress=gr.Progress()):
944
+ print(f"[handle_single] click received img={img} device={device_choice!r} model={model_choice!r} clean={clean_toggle}", flush=True)
945
+ if img is None:
946
+ return "⚠ Please upload a photo first.", gr.update(visible=False), ""
947
+
948
+ use_tripo = bool(model_choice and "TripoSplat" in model_choice)
949
+ use_cpu = bool(device_choice and "CPU" in device_choice)
950
+
951
+ try:
952
+ if use_tripo:
953
+ ply, status = run_tripo(img, progress=progress, num_gaussians=tripo_gaussians)
954
+ elif use_cpu:
955
+ ply, status = run_sharp_cpu(img, progress=progress)
956
+ else:
957
+ ply, status = run_sharp_gpu(img, progress=progress)
958
+ except Exception as e:
959
+ print(f"[handle_single] backend call raised: {e}", flush=True)
960
+ return (f"⚠ Request failed: {str(e)}\n(If this mentions quota/duration, you've hit your daily free ZeroGPU limit β€” wait for it to reset, switch to CPU, or sign in with a HF account for a bigger quota.)",
961
+ gr.update(visible=False), "")
962
+
963
+ print(f"[handle_single] backend returned ply={ply!r} status={status!r}", flush=True)
964
+
965
+ if ply and clean_toggle:
966
+ try:
967
+ cleaned_path, stats = clean_ply(ply)
968
+ print(f"[handle_single] clean_ply: {stats}", flush=True)
969
+ if cleaned_path != ply:
970
+ status += f"\nβœ‚ Cleaned: {stats}"
971
+ ply = cleaned_path
972
+ except Exception as e:
973
+ print(f"[handle_single] cleanup skipped: {e}", flush=True)
974
+ status += "\n(cleanup skipped β€” using raw output)"
975
+
976
+ if ply:
977
+ size_mb = round(os.path.getsize(ply) / 1024 / 1024, 2)
978
+ dl_update = gr.update(value=ply, visible=True, label=f"⬇ Download .ply ({size_mb} MB)")
979
+ payload = json.dumps({"url": make_ply_url(ply), "size_mb": size_mb, "ts": time.time()})
980
+ return status, dl_update, payload
981
+ return status, gr.update(visible=False), ""
982
+
983
+ def handle_dual(a, b, do_anim, progress=gr.Progress()):
984
+ if a is None or b is None:
985
+ return "⚠ Please upload BOTH photos.", gr.update(visible=False)
986
+
987
+ try:
988
+ ply1, s1 = run_sharp_gpu(a, progress=progress, desc_prefix="Angle 1: ")
989
+ except Exception as e:
990
+ return f"⚠ GPU rejected angle 1: {str(e)}", gr.update(visible=False)
991
+ if not ply1:
992
+ return f"⚠ Angle 1 failed:\n{s1}", gr.update(visible=False)
993
+
994
+ try:
995
+ ply2, s2 = run_sharp_gpu(b, progress=progress, desc_prefix="Angle 2: ")
996
+ except Exception as e:
997
+ return f"βœ“ Angle 1 done.\n⚠ GPU rejected angle 2: {str(e)}", gr.update(visible=False)
998
+ if not ply2:
999
+ return f"βœ“ Angle 1 done.\n⚠ Angle 2 failed:\n{s2}", gr.update(visible=False)
1000
+
1001
+ msg = "βœ“ Both angles done!"
1002
+ html_out = gr.update(visible=False)
1003
+
1004
+ if do_anim:
1005
+ try:
1006
+ content = generate_animation_html(ply1, ply2)
1007
+ tmp = tempfile.NamedTemporaryFile(
1008
+ suffix=".html", prefix="splatweb_anim_",
1009
+ delete=False, mode="w", encoding="utf-8"
1010
+ )
1011
+ tmp.write(content)
1012
+ tmp.close()
1013
+ html_out = gr.update(value=tmp.name, visible=True)
1014
+ msg += "\n✦ Animation HTML ready β€” download below and open on your phone!"
1015
+ except Exception as e:
1016
+ msg += f"\n⚠ Animation failed: {str(e)}"
1017
+
1018
+ return msg, html_out
1019
+
1020
+ def handle_view_ply(f):
1021
+ if f is None:
1022
+ return "⚠ Please upload a .ply file first.", ""
1023
+ size_mb = round(os.path.getsize(f) / 1024 / 1024, 2)
1024
+ payload = json.dumps({"url": make_ply_url(f), "size_mb": size_mb, "ts": time.time()})
1025
+ return f"βœ“ Loaded {os.path.basename(f)} ({size_mb} MB)", payload
1026
+
1027
+ btn1.click(fn=handle_single, inputs=[img1, device_choice, model_choice, clean_toggle, tripo_gaussians],
1028
+ outputs=[st1, dl1, url_holder])
1029
+ btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
1030
+ btn3.click(fn=handle_view_ply, inputs=[ply_upload], outputs=[st3, url_holder3])
1031
+
1032
+ # This is the load-bearing wire for both viewer instances: whenever
1033
+ # url_holder's value changes (including programmatically, from a
1034
+ # handler's return), Gradio calls this JS function with that exact
1035
+ # string β€” the documented, reliable way to run custom JS off a Python
1036
+ # result.
1037
+ url_holder.change(
1038
+ fn=None, inputs=[url_holder], outputs=[],
1039
+ js="""
1040
+ (payload) => {
1041
+ const log = (m) => { if (window.swLog) window.swLog('v1', m); else console.log('[SplatWeb]', m); };
1042
+ if (!payload) { log('trigger cleared, nothing to load'); return; }
1043
+ try {
1044
+ const data = JSON.parse(payload);
1045
+ if (window.swLoadUrl) { window.swLoadUrl('v1', data.url, data.size_mb); }
1046
+ else { log('ERROR: viewer script not ready yet β€” reload the page and try again'); }
1047
+ } catch (e) {
1048
+ log('ERROR parsing trigger payload: ' + e.message);
1049
+ }
1050
+ }
1051
+ """
1052
+ )
1053
+ url_holder3.change(
1054
+ fn=None, inputs=[url_holder3], outputs=[],
1055
+ js="""
1056
+ (payload) => {
1057
+ const log = (m) => { if (window.swLog) window.swLog('v3', m); else console.log('[SplatWeb]', m); };
1058
+ if (!payload) { log('trigger cleared, nothing to load'); return; }
1059
+ try {
1060
+ const data = JSON.parse(payload);
1061
+ if (window.swLoadUrl) { window.swLoadUrl('v3', data.url, data.size_mb); }
1062
+ else { log('ERROR: viewer script not ready yet β€” reload the page and try again'); }
1063
+ } catch (e) {
1064
+ log('ERROR parsing trigger payload: ' + e.message);
1065
+ }
1066
+ }
1067
+ """
1068
+ )
1069
+
1070
+ # Shared viewer script β€” placed once, after every viewer container it
1071
+ # controls already exists in the initial page HTML, so its <script>
1072
+ # tags execute normally (this is the page's *first* render, not a
1073
+ # dynamic update, so the innerHTML-script caveat above doesn't apply).
1074
+ gr.HTML(VIEWER_SCRIPT)
1075
+
1076
+ gr.HTML("""
1077
+ <div style="text-align:center;padding:1.5rem 1rem;
1078
+ border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;">
1079
+ <p style="font-size:0.6rem;color:rgba(180,200,255,0.22);line-height:1.8;">
1080
+ SplatWeb Β· Apple SHARP + TripoSplat Β· HuggingFace Β· 3D renders on your device GPU via WebGL
1081
+ </p>
1082
+ </div>
1083
+ """)
1084
+
1085
+ if __name__ == "__main__":
1086
+ demo.launch(server_name="0.0.0.0", server_port=7860)