VISHAL18for4 commited on
Commit
8d0867f
·
verified ·
1 Parent(s): 1561172

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -982
app.py DELETED
@@ -1,982 +0,0 @@
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=""):
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
- result = client.predict(
253
- gradio_handle_file(image_path), # image
254
- 42, # seed
255
- 20, # inference steps
256
- 3.0, # guidance scale
257
- "131072", # number of gaussians (best-effort guess)
258
- "PLY", # download format
259
- api_name="/predict"
260
- )
261
- print(f"[run_tripo] raw result (+{time.time()-t0:.1f}s): {str(result)[:500]}", flush=True)
262
- _p(0.9, "Finalizing…")
263
- ply_path = _extract_ply_path(result)
264
- if not ply_path or not os.path.exists(ply_path):
265
- return None, (f"⚠ TripoSplat responded but I couldn't find a .ply in the result "
266
- f"(their API may not match my guessed parameters). Raw response logged "
267
- f"to the HF Space logs for debugging.")
268
- _p(1.0, "Done")
269
- return ply_path, "✓ Done (via TripoSplat) — 3D scene loading below ↓"
270
- except Exception as e:
271
- print(f"[run_tripo] EXCEPTION: {e}", flush=True)
272
- return None, (f"⚠ TripoSplat call failed: {str(e)}\n"
273
- f"(This calls VAST-AI's public demo directly — it may be asleep, "
274
- f"rate-limited, or have a different API than expected. Check the HF "
275
- f"Space logs for the exact error and the real API schema.)")
276
-
277
-
278
- # ────────────────────────────────────────────────────────────────
279
- # Auto-clean: prune near-invisible / statistically-stray gaussians.
280
- # This is a pure-CPU numpy pass over the vertex data — runs outside the
281
- # GPU-decorated functions on purpose, so it never eats into ZeroGPU quota.
282
- # ────────────────────────────────────────────────────────────────
283
- def clean_ply(ply_path, opacity_thresh=0.04, mad_k=6.0):
284
- if not HAS_PLYFILE:
285
- return ply_path, "skipped (plyfile not installed)"
286
-
287
- plydata = PlyData.read(ply_path)
288
- if "vertex" not in plydata:
289
- return ply_path, "skipped (no vertex element)"
290
-
291
- v = plydata["vertex"]
292
- n0 = len(v.data)
293
- if n0 == 0:
294
- return ply_path, "skipped (empty)"
295
-
296
- names = v.data.dtype.names
297
- if not all(k in names for k in ("x", "y", "z")):
298
- return ply_path, "skipped (unexpected format)"
299
-
300
- xyz = np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64)
301
-
302
- if "opacity" in names:
303
- op = np.asarray(v["opacity"], dtype=np.float64)
304
- # 3DGS often stores opacity as a raw pre-sigmoid logit rather than
305
- # a 0-1 probability — detect that and convert if so.
306
- if op.min() < -0.01 or op.max() > 1.01:
307
- op_prob = 1.0 / (1.0 + np.exp(-op))
308
- else:
309
- op_prob = op
310
- keep_opacity = op_prob >= opacity_thresh
311
- else:
312
- keep_opacity = np.ones(n0, dtype=bool)
313
-
314
- # Robust outlier rejection via median absolute deviation (MAD) — more
315
- # resistant to the very outliers we're trying to remove than mean/std.
316
- med = np.median(xyz, axis=0)
317
- mad = np.median(np.abs(xyz - med), axis=0) + 1e-6
318
- dist = np.abs(xyz - med) / mad
319
- keep_pos = np.all(dist <= mad_k, axis=1)
320
-
321
- keep = keep_opacity & keep_pos
322
- n1 = int(keep.sum())
323
-
324
- if n1 == 0 or n1 == n0:
325
- return ply_path, f"{n0:,} gaussians (no change)"
326
-
327
- new_data = v.data[keep]
328
- new_el = PlyElement.describe(new_data, "vertex")
329
- out_path = ply_path[:-4] + "_clean.ply" if ply_path.endswith(".ply") else ply_path + "_clean.ply"
330
- PlyData([new_el], text=plydata.text, byte_order=plydata.byte_order).write(out_path)
331
-
332
- size_before = os.path.getsize(ply_path) / 1024 / 1024
333
- size_after = os.path.getsize(out_path) / 1024 / 1024
334
- pct = (1 - n1 / n0) * 100
335
- return out_path, f"{n0:,} → {n1:,} gaussians ({pct:.0f}% pruned) · {size_before:.1f}MB → {size_after:.1f}MB"
336
-
337
-
338
- # ── Animation HTML (two angles, camera flies between them) ──
339
- def generate_animation_html(ply1, ply2):
340
- def enc(p):
341
- with open(p, "rb") as f:
342
- return base64.b64encode(f.read()).decode("utf-8")
343
- b1, b2 = enc(ply1), enc(ply2)
344
- return f"""<!DOCTYPE html>
345
- <html lang="en">
346
- <head>
347
- <meta charset="UTF-8"/>
348
- <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
349
- <title>SplatWeb Animation</title>
350
- <style>
351
- *{{box-sizing:border-box;margin:0;padding:0}}
352
- body{{background:#050810;color:#dce8ff;font-family:monospace;height:100vh;display:flex;flex-direction:column;overflow:hidden}}
353
- #hdr{{display:flex;align-items:center;justify-content:space-between;padding:.8rem 1.4rem;
354
- border-bottom:1px solid rgba(77,138,255,.15);background:rgba(5,8,16,.9);backdrop-filter:blur(10px);flex-shrink:0;gap:.8rem}}
355
- .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}}
356
- .hr{{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;justify-content:flex-end}}
357
- .badge{{font-size:.58rem;color:#22d3a0;border:1px solid rgba(34,211,160,.3);padding:.22rem .65rem;border-radius:100px;white-space:nowrap}}
358
- #btndl{{background:linear-gradient(135deg,#4d8aff,#8b5cf6);border:none;border-radius:8px;padding:.32rem .85rem;
359
- color:#fff;font-family:monospace;font-size:.6rem;cursor:pointer;white-space:nowrap;transition:all .2s}}
360
- #btndl:hover{{transform:translateY(-1px)}}
361
- #wrap{{flex:1;position:relative;overflow:hidden}}
362
- canvas{{width:100%!important;height:100%!important;display:block}}
363
- #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)}}
364
- #ov.hidden{{display:none}}
365
- .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}}
366
- @keyframes spin{{to{{transform:rotate(360deg)}}}}
367
- #lm{{font-size:.7rem;color:rgba(180,200,255,.5);letter-spacing:.1em}}
368
- #sl{{position:absolute;top:1rem;left:50%;transform:translateX(-50%);font-size:.6rem;color:rgba(180,200,255,.4);
369
- 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}}
370
- #ctrls{{position:absolute;bottom:1.2rem;left:50%;transform:translateX(-50%);display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}}
371
- .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)}}
372
- #pbw{{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(77,138,255,.1)}}
373
- #pb{{height:100%;background:linear-gradient(90deg,#4d8aff,#8b5cf6,#22d3a0);width:0%;transition:width .12s linear}}
374
- </style>
375
- </head>
376
- <body>
377
- <div id="hdr">
378
- <div class="logo">SPLATWEB</div>
379
- <div class="hr">
380
- <div class="badge">✦ 3D KEYFRAME ANIMATION</div>
381
- <button id="btndl" onclick="saveme()">⬇ SAVE HTML</button>
382
- </div>
383
- </div>
384
- <div id="wrap">
385
- <canvas id="c"></canvas>
386
- <div id="ov"><div class="sp"></div><div id="lm">LOADING 3D SCENES…</div></div>
387
- <div id="sl">ANGLE 1</div>
388
- <div id="ctrls">
389
- <div class="pill">Drag → Orbit</div>
390
- <div class="pill">Scroll → Zoom</div>
391
- <div class="pill">Camera auto-animates</div>
392
- </div>
393
- <div id="pbw"><div id="pb"></div></div>
394
- </div>
395
- <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>
396
- <script type="module">
397
- import * as G from '@mkkellogg/gaussian-splats-3d';
398
- 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'}}));}}
399
- const u1=b2u(`{b1}`),u2=b2u(`{b2}`);
400
- const canvas=document.getElementById('c'),ov=document.getElementById('ov'),lm=document.getElementById('lm'),pb=document.getElementById('pb'),sl=document.getElementById('sl');
401
- const A={{x:-2.5,y:-1.2,z:5}},B={{x:2.5,y:-.4,z:5}},TRAVEL=4000,HOLD=1500;
402
- let viewer=null,phase='hold_a',ps=null;
403
- const ease=t=>t<.5?2*t*t:-1+(4-2*t)*t,lerp=(a,b,t)=>a+(b-a)*t;
404
- function tick(now){{
405
- if(!viewer||!viewer.camera){{requestAnimationFrame(tick);return;}}
406
- if(!ps)ps=now;const e=now-ps,cam=viewer.camera;
407
- 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;}}}}
408
- 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;}}}}
409
- 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;}}}}
410
- 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;}}}}
411
- cam.lookAt(0,0,0);requestAnimationFrame(tick);
412
- }}
413
- async function init(){{
414
- viewer=new G.Viewer({{canvas,cameraUp:[0,-1,0],initialCameraPosition:[A.x,A.y,A.z],initialCameraLookAt:[0,0,0],selfDrivenMode:true,dynamicScene:true}});
415
- try{{
416
- lm.textContent='LOADING ANGLE 1…';await viewer.addSplatScene(u1,{{progressiveLoad:false}});
417
- lm.textContent='LOADING ANGLE 2…';await viewer.addSplatScene(u2,{{progressiveLoad:false}});
418
- ov.classList.add('hidden');sl.style.display='block';viewer.start();requestAnimationFrame(tick);
419
- }}catch(err){{lm.textContent='⚠ Load error.';console.error(err);}}
420
- }}
421
- init();
422
- </script>
423
- <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>
424
- </body></html>"""
425
-
426
-
427
- # ────────────────────────────────────────────────────────────────
428
- # CSS — back in gr.Blocks() where it belongs in Gradio 6
429
- # (shows a harmless warning in logs but does NOT crash)
430
- # ────────────────────────────────────────────────────────────────
431
- CSS = """
432
- @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400&display=swap');
433
-
434
- body, .gradio-container {
435
- background: #050810 !important;
436
- font-family: 'JetBrains Mono', monospace !important;
437
- }
438
- .gradio-container { max-width: 820px !important; margin: 0 auto !important; }
439
-
440
- h1 {
441
- font-family: 'Syne', sans-serif !important;
442
- font-weight: 800 !important;
443
- font-size: 2.6rem !important;
444
- background: linear-gradient(135deg, #4d8aff, #8b5cf6, #22d3a0) !important;
445
- -webkit-background-clip: text !important;
446
- -webkit-text-fill-color: transparent !important;
447
- letter-spacing: -0.02em !important;
448
- line-height: 1.1 !important;
449
- margin-bottom: 0.5rem !important;
450
- }
451
-
452
- button.lg { font-family: 'Syne', sans-serif !important; font-weight: 700 !important; }
453
-
454
- @keyframes sw_spin { to { transform: rotate(360deg); } }
455
- """
456
-
457
-
458
- # ────────────────────────────────────────────────────────────────
459
- # 3D viewer — parameterized so multiple independent instances can exist
460
- # on the page (Single Photo tab + the standalone .ply viewer tab) without
461
- # colliding on element IDs. Each instance is looked up by its instance_id
462
- # via data-role attributes scoped inside its own container div.
463
- # ────────────────────────────────────────────────────────────────
464
- def make_viewer_shell(instance_id: str) -> str:
465
- return f"""
466
- <div id="splatweb-viewer-{instance_id}"
467
- style="background:#050810;border:1px solid rgba(77,138,255,0.18);
468
- border-radius:18px;overflow:hidden;">
469
-
470
- <div style="display:flex;align-items:center;justify-content:space-between;
471
- padding:0.7rem 1.2rem;border-bottom:1px solid rgba(77,138,255,0.1);
472
- background:rgba(5,8,16,0.95);gap:0.6rem;flex-wrap:wrap;">
473
- <div>
474
- <span style="font-family:monospace;font-size:0.68rem;font-weight:bold;
475
- background:linear-gradient(90deg,#4d8aff,#8b5cf6);
476
- -webkit-background-clip:text;-webkit-text-fill-color:transparent;
477
- letter-spacing:0.06em;">✦ 3D SCENE VIEWER</span>
478
- <span data-role="size" style="font-family:monospace;font-size:0.55rem;
479
- color:rgba(180,200,255,0.3);margin-left:0.5rem;"></span>
480
- </div>
481
- <button data-role="dlbtn" onclick="swDownload('{instance_id}')"
482
- style="display:none;background:linear-gradient(135deg,#4d8aff,#8b5cf6);
483
- border:none;border-radius:8px;padding:0.3rem 0.9rem;color:#fff;
484
- font-family:monospace;font-size:0.6rem;letter-spacing:0.07em;
485
- cursor:pointer;box-shadow:0 2px 10px rgba(77,138,255,0.3);">
486
- ⬇ DOWNLOAD .PLY
487
- </button>
488
- </div>
489
-
490
- <div style="position:relative;width:100%;height:460px;">
491
- <canvas data-role="canvas"
492
- style="width:100%;height:100%;display:block;
493
- background:radial-gradient(ellipse at center,#071020,#020408);"></canvas>
494
-
495
- <div data-role="idle"
496
- style="position:absolute;inset:0;display:flex;flex-direction:column;
497
- align-items:center;justify-content:center;gap:0.8rem;
498
- background:rgba(5,8,16,0.85);">
499
- <div style="font-size:2.5rem;opacity:0.3;">✦</div>
500
- <div style="font-family:monospace;font-size:0.7rem;color:rgba(180,200,255,0.35);
501
- letter-spacing:0.12em;text-align:center;line-height:1.7;">
502
- 3D SCENE WILL APPEAR HERE<br/>
503
- <span style="font-size:0.58rem;opacity:0.6;">Upload a photo and press Build →</span>
504
- </div>
505
- </div>
506
-
507
- <div data-role="loading"
508
- style="position:absolute;inset:0;display:none;flex-direction:column;
509
- align-items:center;justify-content:center;gap:1rem;
510
- background:rgba(5,8,16,0.82);backdrop-filter:blur(4px);">
511
- <div style="width:40px;height:40px;
512
- border:2px solid rgba(77,138,255,0.2);
513
- border-top-color:#4d8aff;border-radius:50%;
514
- animation:sw_spin 1s linear infinite;"></div>
515
- <div data-role="loadmsg"
516
- style="font-family:monospace;font-size:0.68rem;
517
- color:rgba(180,200,255,0.55);letter-spacing:0.1em;">
518
- BUILDING 3D SCENE…
519
- </div>
520
- </div>
521
-
522
- <div data-role="controls"
523
- style="position:absolute;bottom:0.9rem;left:50%;transform:translateX(-50%);
524
- display:none;gap:0.4rem;flex-wrap:wrap;justify-content:center;pointer-events:none;">
525
- <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
526
- border:1px solid rgba(255,255,255,0.07);border-radius:100px;
527
- padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
528
- color:rgba(180,200,255,0.4);">Drag → Orbit</span>
529
- <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
530
- border:1px solid rgba(255,255,255,0.07);border-radius:100px;
531
- padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
532
- color:rgba(180,200,255,0.4);">Scroll → Zoom</span>
533
- <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
534
- border:1px solid rgba(255,255,255,0.07);border-radius:100px;
535
- padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
536
- color:rgba(180,200,255,0.4);">Shift+Drag → Pan</span>
537
- </div>
538
- </div>
539
-
540
- <details style="border-top:1px solid rgba(77,138,255,0.1);">
541
- <summary style="cursor:pointer;padding:0.5rem 0.8rem;font-family:monospace;font-size:0.6rem;
542
- color:rgba(180,200,255,0.45);letter-spacing:0.08em;">
543
- // debug log (tap to expand)
544
- </summary>
545
- <pre data-role="debug" style="font-family:monospace;font-size:0.58rem;line-height:1.5;
546
- color:rgba(180,200,255,0.55);padding:0 0.8rem 0.7rem;margin:0;
547
- max-height:180px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;"></pre>
548
- </details>
549
- </div>
550
- """
551
-
552
-
553
- # One shared script block driving every viewer instance on the page,
554
- # keyed by instance_id via data-role lookups scoped to each container.
555
- VIEWER_SCRIPT = """
556
- <script type="importmap">
557
- {
558
- "imports": {
559
- "@mkkellogg/gaussian-splats-3d":
560
- "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js"
561
- }
562
- }
563
- </script>
564
-
565
- <script type="module">
566
- import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
567
-
568
- window._swState = window._swState || {};
569
-
570
- function _swEls(id) {
571
- const root = document.getElementById('splatweb-viewer-' + id);
572
- if (!root) return null;
573
- return {
574
- idle: root.querySelector('[data-role="idle"]'),
575
- loading: root.querySelector('[data-role="loading"]'),
576
- controls: root.querySelector('[data-role="controls"]'),
577
- dlBtn: root.querySelector('[data-role="dlbtn"]'),
578
- sizeEl: root.querySelector('[data-role="size"]'),
579
- canvas: root.querySelector('[data-role="canvas"]'),
580
- msg: root.querySelector('[data-role="loadmsg"]'),
581
- debug: root.querySelector('[data-role="debug"]'),
582
- };
583
- }
584
-
585
- window.swLog = function(id, msg) {
586
- const t = new Date().toTimeString().slice(0, 8);
587
- console.log('[SplatWeb:' + id + ']', msg);
588
- const els = _swEls(id);
589
- if (els && els.debug) {
590
- els.debug.textContent += `[${t}] ${msg}\\n`;
591
- els.debug.scrollTop = els.debug.scrollHeight;
592
- }
593
- };
594
-
595
- window.swLoadUrl = async function(id, fileUrl, sizeMb) {
596
- const els = _swEls(id);
597
- if (!els) { console.error('SplatWeb: no viewer found for instance', id); return; }
598
- const { idle, loading, controls, dlBtn, sizeEl, canvas, msg } = els;
599
-
600
- window.swLog(id, 'trigger fired → ' + fileUrl);
601
- idle.style.display = 'none';
602
- loading.style.display = 'flex';
603
- controls.style.display = 'none';
604
- if (sizeEl) sizeEl.textContent = (sizeMb ? sizeMb + ' MB · ' : '') + 'WebGL · your GPU';
605
-
606
- try {
607
- msg.textContent = 'DOWNLOADING SCENE…';
608
- window.swLog(id, 'fetching file…');
609
- const resp = await fetch(fileUrl);
610
- window.swLog(id, 'fetch responded: HTTP ' + resp.status);
611
- if (!resp.ok) throw new Error('server returned HTTP ' + resp.status + ' for the file URL');
612
-
613
- const buf = await resp.arrayBuffer();
614
- window.swLog(id, 'downloaded ' + (buf.byteLength / 1024 / 1024).toFixed(2) + ' MB');
615
- const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }));
616
-
617
- const prev = window._swState[id];
618
- if (prev) {
619
- if (prev.blobUrl) { try { URL.revokeObjectURL(prev.blobUrl); } catch(e) {} }
620
- if (prev.viewer) { try { prev.viewer.dispose(); } catch(e) {} }
621
- }
622
-
623
- window.swLog(id, 'initializing WebGL viewer…');
624
- const viewer = new GaussianSplats3D.Viewer({
625
- canvas,
626
- cameraUp: [0, -1, 0],
627
- initialCameraPosition: [-1, -4, 6],
628
- initialCameraLookAt: [0, 0, 0],
629
- selfDrivenMode: true,
630
- });
631
-
632
- msg.textContent = 'RENDERING GAUSSIANS…';
633
- window.swLog(id, 'parsing splat data + uploading to GPU…');
634
- await viewer.addSplatScene(blobUrl, { progressiveLoad: true });
635
- window.swLog(id, 'scene loaded ✓');
636
-
637
- window._swState[id] = { viewer, blobUrl };
638
-
639
- loading.style.display = 'none';
640
- controls.style.display = 'flex';
641
- if (dlBtn) dlBtn.style.display = 'block';
642
- viewer.start();
643
-
644
- } catch(err) {
645
- const emsg = (err && err.message) ? err.message : String(err);
646
- window.swLog(id, 'ERROR: ' + emsg);
647
- msg.textContent = '⚠ ' + emsg;
648
- console.error(err);
649
- }
650
- };
651
-
652
- window.swDownload = function(id) {
653
- const state = window._swState[id];
654
- if (!state || !state.blobUrl) return;
655
- const a = document.createElement('a');
656
- a.href = state.blobUrl;
657
- a.download = 'scene.ply';
658
- document.body.appendChild(a);
659
- a.click();
660
- document.body.removeChild(a);
661
- };
662
- </script>
663
- """
664
-
665
-
666
- # Gradio serves any file under its allowed paths (which includes the
667
- # system temp dir — where our .ply files live) at this documented route:
668
- # /gradio_api/file=<path>
669
- def make_ply_url(ply_path: str) -> str:
670
- return "/gradio_api/file=" + urllib.parse.quote(ply_path, safe="/")
671
-
672
-
673
- # ────────────────────────────────────────────────────────────────
674
- # UI — css in gr.Blocks() (correct for all Gradio versions)
675
- # ────────────────────────────────────────────────────────────────
676
- with gr.Blocks(css=CSS, title="SplatWeb") as demo:
677
-
678
- gr.HTML("""
679
- <div style="text-align:center;padding:2.5rem 1rem 1.5rem;
680
- border-bottom:1px solid rgba(80,130,255,0.1);margin-bottom:1.5rem;">
681
- <h1>SPLATWEB</h1>
682
- <p style="color:rgba(180,200,255,0.4);font-size:0.72rem;
683
- letter-spacing:0.1em;margin-top:0.3rem;">
684
- PHOTO → 3D GAUSSIAN SPLAT · MULTI-MODEL · FREE
685
- </p>
686
- <div style="display:inline-flex;gap:0.6rem;margin-top:0.8rem;
687
- flex-wrap:wrap;justify-content:center;">
688
- <span style="background:rgba(34,211,160,0.08);border:1px solid rgba(34,211,160,0.2);
689
- color:#22d3a0;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
690
- ✓ 100% FREE</span>
691
- <span style="background:rgba(77,138,255,0.08);border:1px solid rgba(77,138,255,0.2);
692
- color:#4d8aff;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
693
- ⚡ AUTO 3D PREVIEW</span>
694
- <span style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);
695
- color:#8b5cf6;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
696
- ✦ CAMERA ANIMATION</span>
697
- </div>
698
- </div>
699
- """)
700
-
701
- with gr.Tabs():
702
-
703
- # ── Tab 1: Single photo ──────────────────────────────
704
- with gr.TabItem("📷 Single Photo → 3D"):
705
-
706
- img1 = gr.Image(
707
- type="filepath", label="// upload photo",
708
- sources=["upload", "webcam"], height=260
709
- )
710
- with gr.Accordion("📸 Tips for best results", open=False):
711
- gr.Markdown("""
712
- - Any size photo — app auto-resizes to 512px before processing
713
- - Clear, well-lit subject gives the best 3D quality
714
- - Works on: objects, rooms, food, people, landscapes
715
- - **GPU:** fast (~45s), but free HF accounts get 5 min/day total ZeroGPU quota
716
- - **CPU:** slow (~2-4 min), but no daily quota limit
717
- - **ZeroGPU hardware must be selected in your Space's Settings → Hardware**
718
- - **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
719
- - **Auto-clean:** prunes near-invisible and statistically stray gaussians — usually shrinks the file and reduces noise with no visible quality loss
720
- """)
721
-
722
- with gr.Row():
723
- device_choice = gr.Radio(
724
- ["⚡ GPU (fast, uses daily quota)", "🐢 CPU (slower, no quota limit)"],
725
- value="⚡ GPU (fast, uses daily quota)",
726
- label="Processing device"
727
- )
728
- model_choice = gr.Radio(
729
- ["Apple SHARP (built-in)", "TripoSplat (experimental, via live demo)"],
730
- value="Apple SHARP (built-in)",
731
- label="Model"
732
- )
733
- clean_toggle = gr.Checkbox(
734
- value=True,
735
- label="✂ Auto-clean stray noise gaussians (recommended)"
736
- )
737
-
738
- btn1 = gr.Button("✦ Build My 3D Scene", variant="primary", size="lg")
739
- st1 = gr.Textbox(
740
- label="// status", interactive=False, lines=3,
741
- placeholder="Upload a photo and press the button…"
742
- )
743
-
744
- # 3D viewer — always visible, waits for content
745
- gr.HTML(make_viewer_shell("v1"))
746
-
747
- # Real, native Gradio download — works regardless of whether
748
- # the custom WebGL preview above loads, so you always have a
749
- # reliable way to get the file and open it in another app.
750
- dl1 = gr.DownloadButton("⬇ Download .ply", visible=False)
751
-
752
- # Hidden trigger — a plain Textbox (not gr.HTML). Browsers (and
753
- # Gradio's Svelte renderer) never execute <script> tags inserted
754
- # via innerHTML on a dynamic update — only on the page's
755
- # *initial* load. A Textbox's .change(js=...) event is Gradio's
756
- # actual supported mechanism for running JS off a Python return
757
- # value, and reliably passes the exact string we set.
758
- url_holder = gr.Textbox(value="", visible=False, elem_id="sw-url-holder")
759
-
760
- # ── Tab 2: Two angles + animation ───────────────────
761
- with gr.TabItem("🎬 Two Angles → Animation"):
762
-
763
- gr.HTML("""
764
- <div style="background:rgba(139,92,246,0.06);
765
- border:1px solid rgba(139,92,246,0.18);
766
- border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;">
767
- <div style="font-size:0.62rem;color:#8b5cf6;
768
- letter-spacing:0.1em;margin-bottom:0.4rem;">✦ HOW THIS WORKS</div>
769
- <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;">
770
- Upload 2 photos of the same object from different angles.
771
- Both convert to 3D. Enable animation to get a downloadable HTML file —
772
- open it on any phone, camera flies between both angles in a loop.
773
- The HTML has a <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> button inside.
774
- </div>
775
- </div>
776
- """)
777
-
778
- with gr.Row():
779
- imgA = gr.Image(
780
- type="filepath", label="// angle 1 — front / left",
781
- sources=["upload"], height=220
782
- )
783
- imgB = gr.Image(
784
- type="filepath", label="// angle 2 — back / right",
785
- sources=["upload"], height=220
786
- )
787
-
788
- anim_toggle = gr.Checkbox(
789
- label="✦ Generate camera animation HTML",
790
- value=True,
791
- info="Downloadable .html — camera flies between angles in a loop, has ⬇ SAVE HTML inside"
792
- )
793
- btn2 = gr.Button("✦ Build 3D + Animation", variant="primary", size="lg")
794
- st2 = gr.Textbox(
795
- label="// status", interactive=False, lines=3,
796
- placeholder="Upload both photos and press the button…"
797
- )
798
-
799
- anim_file = gr.File(
800
- label="// animation .html — download & open on any phone",
801
- visible=False, file_types=[".html"]
802
- )
803
-
804
- gr.HTML("""
805
- <div style="margin-top:0.8rem;padding:0.9rem 1rem;
806
- background:rgba(34,211,160,0.04);
807
- border:1px solid rgba(34,211,160,0.1);
808
- border-radius:10px;font-size:0.65rem;
809
- color:rgba(180,200,255,0.38);line-height:1.8;">
810
- 📱 Download animation .html → open in Chrome or Safari on any phone → 3D plays.<br/>
811
- 📤 Share via WhatsApp / email — recipient just opens the file, no app needed.<br/>
812
- 💾 Tap <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> inside to re-download anytime.
813
- </div>
814
- """)
815
-
816
- # ── Tab 3: standalone .ply viewer (NEW) ──────────────
817
- with gr.TabItem("🗂 View a .PLY File"):
818
-
819
- gr.HTML("""
820
- <div style="background:rgba(34,211,160,0.06);
821
- border:1px solid rgba(34,211,160,0.18);
822
- border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;">
823
- <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;">
824
- Already have a Gaussian Splat <strong style="color:#dce8ff;">.ply</strong> file
825
- (from this app, SuperSplat, Luma, Polycam, etc.)? Drop it here to preview it
826
- directly — no GPU processing needed, this just loads it into the same viewer.
827
- </div>
828
- </div>
829
- """)
830
-
831
- ply_upload = gr.File(label="// upload a .ply file", file_types=[".ply"], type="filepath")
832
- btn3 = gr.Button("👁 Load in Viewer", variant="primary", size="lg")
833
- st3 = gr.Textbox(label="// status", interactive=False, lines=1)
834
-
835
- gr.HTML(make_viewer_shell("v3"))
836
- url_holder3 = gr.Textbox(value="", visible=False, elem_id="sw-url-holder-v3")
837
-
838
- # ── Handlers ──────────────────────────────────────────────
839
- def handle_single(img, device_choice, model_choice, clean_toggle, progress=gr.Progress()):
840
- print(f"[handle_single] click received img={img} device={device_choice!r} model={model_choice!r} clean={clean_toggle}", flush=True)
841
- if img is None:
842
- return "⚠ Please upload a photo first.", gr.update(visible=False), ""
843
-
844
- use_tripo = bool(model_choice and "TripoSplat" in model_choice)
845
- use_cpu = bool(device_choice and "CPU" in device_choice)
846
-
847
- try:
848
- if use_tripo:
849
- ply, status = run_tripo(img, progress=progress)
850
- elif use_cpu:
851
- ply, status = run_sharp_cpu(img, progress=progress)
852
- else:
853
- ply, status = run_sharp_gpu(img, progress=progress)
854
- except Exception as e:
855
- print(f"[handle_single] backend call raised: {e}", flush=True)
856
- 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.)",
857
- gr.update(visible=False), "")
858
-
859
- print(f"[handle_single] backend returned ply={ply!r} status={status!r}", flush=True)
860
-
861
- if ply and clean_toggle:
862
- try:
863
- cleaned_path, stats = clean_ply(ply)
864
- print(f"[handle_single] clean_ply: {stats}", flush=True)
865
- if cleaned_path != ply:
866
- status += f"\n✂ Cleaned: {stats}"
867
- ply = cleaned_path
868
- except Exception as e:
869
- print(f"[handle_single] cleanup skipped: {e}", flush=True)
870
- status += "\n(cleanup skipped — using raw output)"
871
-
872
- if ply:
873
- size_mb = round(os.path.getsize(ply) / 1024 / 1024, 2)
874
- dl_update = gr.update(value=ply, visible=True, label=f"⬇ Download .ply ({size_mb} MB)")
875
- payload = json.dumps({"url": make_ply_url(ply), "size_mb": size_mb, "ts": time.time()})
876
- return status, dl_update, payload
877
- return status, gr.update(visible=False), ""
878
-
879
- def handle_dual(a, b, do_anim, progress=gr.Progress()):
880
- if a is None or b is None:
881
- return "⚠ Please upload BOTH photos.", gr.update(visible=False)
882
-
883
- try:
884
- ply1, s1 = run_sharp_gpu(a, progress=progress, desc_prefix="Angle 1: ")
885
- except Exception as e:
886
- return f"⚠ GPU rejected angle 1: {str(e)}", gr.update(visible=False)
887
- if not ply1:
888
- return f"⚠ Angle 1 failed:\n{s1}", gr.update(visible=False)
889
-
890
- try:
891
- ply2, s2 = run_sharp_gpu(b, progress=progress, desc_prefix="Angle 2: ")
892
- except Exception as e:
893
- return f"✓ Angle 1 done.\n⚠ GPU rejected angle 2: {str(e)}", gr.update(visible=False)
894
- if not ply2:
895
- return f"✓ Angle 1 done.\n⚠ Angle 2 failed:\n{s2}", gr.update(visible=False)
896
-
897
- msg = "✓ Both angles done!"
898
- html_out = gr.update(visible=False)
899
-
900
- if do_anim:
901
- try:
902
- content = generate_animation_html(ply1, ply2)
903
- tmp = tempfile.NamedTemporaryFile(
904
- suffix=".html", prefix="splatweb_anim_",
905
- delete=False, mode="w", encoding="utf-8"
906
- )
907
- tmp.write(content)
908
- tmp.close()
909
- html_out = gr.update(value=tmp.name, visible=True)
910
- msg += "\n✦ Animation HTML ready — download below and open on your phone!"
911
- except Exception as e:
912
- msg += f"\n⚠ Animation failed: {str(e)}"
913
-
914
- return msg, html_out
915
-
916
- def handle_view_ply(f):
917
- if f is None:
918
- return "⚠ Please upload a .ply file first.", ""
919
- size_mb = round(os.path.getsize(f) / 1024 / 1024, 2)
920
- payload = json.dumps({"url": make_ply_url(f), "size_mb": size_mb, "ts": time.time()})
921
- return f"✓ Loaded {os.path.basename(f)} ({size_mb} MB)", payload
922
-
923
- btn1.click(fn=handle_single, inputs=[img1, device_choice, model_choice, clean_toggle],
924
- outputs=[st1, dl1, url_holder])
925
- btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
926
- btn3.click(fn=handle_view_ply, inputs=[ply_upload], outputs=[st3, url_holder3])
927
-
928
- # This is the load-bearing wire for both viewer instances: whenever
929
- # url_holder's value changes (including programmatically, from a
930
- # handler's return), Gradio calls this JS function with that exact
931
- # string — the documented, reliable way to run custom JS off a Python
932
- # result.
933
- url_holder.change(
934
- fn=None, inputs=[url_holder], outputs=[],
935
- js="""
936
- (payload) => {
937
- const log = (m) => { if (window.swLog) window.swLog('v1', m); else console.log('[SplatWeb]', m); };
938
- if (!payload) { log('trigger cleared, nothing to load'); return; }
939
- try {
940
- const data = JSON.parse(payload);
941
- if (window.swLoadUrl) { window.swLoadUrl('v1', data.url, data.size_mb); }
942
- else { log('ERROR: viewer script not ready yet — reload the page and try again'); }
943
- } catch (e) {
944
- log('ERROR parsing trigger payload: ' + e.message);
945
- }
946
- }
947
- """
948
- )
949
- url_holder3.change(
950
- fn=None, inputs=[url_holder3], outputs=[],
951
- js="""
952
- (payload) => {
953
- const log = (m) => { if (window.swLog) window.swLog('v3', m); else console.log('[SplatWeb]', m); };
954
- if (!payload) { log('trigger cleared, nothing to load'); return; }
955
- try {
956
- const data = JSON.parse(payload);
957
- if (window.swLoadUrl) { window.swLoadUrl('v3', data.url, data.size_mb); }
958
- else { log('ERROR: viewer script not ready yet — reload the page and try again'); }
959
- } catch (e) {
960
- log('ERROR parsing trigger payload: ' + e.message);
961
- }
962
- }
963
- """
964
- )
965
-
966
- # Shared viewer script — placed once, after every viewer container it
967
- # controls already exists in the initial page HTML, so its <script>
968
- # tags execute normally (this is the page's *first* render, not a
969
- # dynamic update, so the innerHTML-script caveat above doesn't apply).
970
- gr.HTML(VIEWER_SCRIPT)
971
-
972
- gr.HTML("""
973
- <div style="text-align:center;padding:1.5rem 1rem;
974
- border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;">
975
- <p style="font-size:0.6rem;color:rgba(180,200,255,0.22);line-height:1.8;">
976
- SplatWeb · Apple SHARP + TripoSplat · HuggingFace · 3D renders on your device GPU via WebGL
977
- </p>
978
- </div>
979
- """)
980
-
981
- if __name__ == "__main__":
982
- demo.launch(server_name="0.0.0.0", server_port=7860)