VISHAL18for4 commited on
Commit
c441c77
Β·
verified Β·
1 Parent(s): 0d6285f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +649 -0
app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from PIL import Image
11
+
12
+ # ── ZeroGPU optional ──
13
+ try:
14
+ import spaces
15
+ HAS_GPU = True
16
+ except Exception:
17
+ HAS_GPU = False
18
+ class spaces:
19
+ @staticmethod
20
+ def GPU(duration=60):
21
+ return lambda fn: fn
22
+
23
+ # ── Install SHARP ──
24
+ def install_sharp():
25
+ try:
26
+ r = subprocess.run(["sharp", "--help"], capture_output=True, timeout=10)
27
+ if r.returncode == 0:
28
+ return
29
+ except Exception:
30
+ pass
31
+ print("Installing Apple SHARP...")
32
+ subprocess.check_call([
33
+ sys.executable, "-m", "pip", "install",
34
+ "git+https://github.com/apple/ml-sharp.git",
35
+ "--quiet"
36
+ ])
37
+
38
+ install_sharp()
39
+
40
+ # ── Resize before SHARP ──
41
+ def resize_image(path, max_size=512):
42
+ img = Image.open(path).convert("RGB")
43
+ w, h = img.size
44
+ if max(w, h) > max_size:
45
+ r = max_size / max(w, h)
46
+ img = img.resize((int(w*r), int(h*r)), Image.LANCZOS)
47
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, prefix="sharp_in_")
48
+ img.save(tmp.name, "JPEG", quality=90)
49
+ return tmp.name
50
+
51
+ # ── Run SHARP ──
52
+ # NOTE on duration: HuggingFace's free-tier ZeroGPU accounts get a 5-minute
53
+ # (300s) TOTAL daily quota. Requesting a duration at or above that cap makes
54
+ # the platform reject the call outright with "requested GPU duration is
55
+ # larger than the maximum allowed" (an "illegal duration" error) β€” it fails
56
+ # instantly, before your function even runs, and retrying won't help.
57
+ # Keeping this comfortably under the cap lets a free/unauthenticated user
58
+ # actually get a few conversions in per day instead of burning the whole
59
+ # quota (or being rejected) on a single call.
60
+ GPU_DURATION = 90
61
+
62
+ @spaces.GPU(duration=GPU_DURATION)
63
+ def run_sharp(image_path):
64
+ t0 = time.time()
65
+ print(f"[run_sharp] START image_path={image_path}", flush=True)
66
+ if image_path is None:
67
+ return None, "⚠ Please upload a photo first."
68
+ out_dir = tempfile.mkdtemp(prefix="splat_")
69
+ resized = None
70
+ try:
71
+ resized = resize_image(image_path, 512)
72
+ print(f"[run_sharp] resized -> {resized} (+{time.time()-t0:.1f}s)", flush=True)
73
+ result = subprocess.run(
74
+ ["sharp", "predict", "-i", resized, "-o", out_dir],
75
+ capture_output=True, text=True, timeout=GPU_DURATION - 15
76
+ )
77
+ print(f"[run_sharp] sharp exited code={result.returncode} (+{time.time()-t0:.1f}s)", flush=True)
78
+ ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")]
79
+ if not ply_files:
80
+ err = (result.stderr or result.stdout or "No .ply produced.")[-800:]
81
+ print(f"[run_sharp] FAILED, no .ply produced:\n{err}", flush=True)
82
+ return None, f"SHARP failed:\n{err}"
83
+ ply_path = os.path.join(out_dir, ply_files[0])
84
+ size_mb = os.path.getsize(ply_path) / 1024 / 1024
85
+ print(f"[run_sharp] DONE ply={ply_path} size={size_mb:.2f}MB total={time.time()-t0:.1f}s", flush=True)
86
+ return ply_path, "βœ“ Done β€” 3D scene loading below ↓"
87
+ except subprocess.TimeoutExpired:
88
+ print(f"[run_sharp] TIMEOUT after {time.time()-t0:.1f}s", flush=True)
89
+ return None, "⚠ Timed out inside the GPU window. Try a smaller/simpler photo, or increase GPU_DURATION near the top of app.py if you have more quota (PRO/Team/Enterprise)."
90
+ except FileNotFoundError:
91
+ print("[run_sharp] SHARP binary not found on PATH", flush=True)
92
+ return None, "⚠ SHARP not found yet β€” wait 1 minute and try again."
93
+ except Exception as e:
94
+ print(f"[run_sharp] EXCEPTION: {e}", flush=True)
95
+ return None, f"⚠ Error: {str(e)}"
96
+ finally:
97
+ if resized:
98
+ try: os.unlink(resized)
99
+ except: pass
100
+
101
+ # ── Animation HTML (two angles, camera flies between them) ──
102
+ def generate_animation_html(ply1, ply2):
103
+ def enc(p):
104
+ with open(p, "rb") as f:
105
+ return base64.b64encode(f.read()).decode("utf-8")
106
+ b1, b2 = enc(ply1), enc(ply2)
107
+ return f"""<!DOCTYPE html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="UTF-8"/>
111
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
112
+ <title>SplatWeb Animation</title>
113
+ <style>
114
+ *{{box-sizing:border-box;margin:0;padding:0}}
115
+ body{{background:#050810;color:#dce8ff;font-family:monospace;height:100vh;display:flex;flex-direction:column;overflow:hidden}}
116
+ #hdr{{display:flex;align-items:center;justify-content:space-between;padding:.8rem 1.4rem;
117
+ border-bottom:1px solid rgba(77,138,255,.15);background:rgba(5,8,16,.9);backdrop-filter:blur(10px);flex-shrink:0;gap:.8rem}}
118
+ .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}}
119
+ .hr{{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;justify-content:flex-end}}
120
+ .badge{{font-size:.58rem;color:#22d3a0;border:1px solid rgba(34,211,160,.3);padding:.22rem .65rem;border-radius:100px;white-space:nowrap}}
121
+ #btndl{{background:linear-gradient(135deg,#4d8aff,#8b5cf6);border:none;border-radius:8px;padding:.32rem .85rem;
122
+ color:#fff;font-family:monospace;font-size:.6rem;cursor:pointer;white-space:nowrap;transition:all .2s}}
123
+ #btndl:hover{{transform:translateY(-1px)}}
124
+ #wrap{{flex:1;position:relative;overflow:hidden}}
125
+ canvas{{width:100%!important;height:100%!important;display:block}}
126
+ #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)}}
127
+ #ov.hidden{{display:none}}
128
+ .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}}
129
+ @keyframes spin{{to{{transform:rotate(360deg)}}}}
130
+ #lm{{font-size:.7rem;color:rgba(180,200,255,.5);letter-spacing:.1em}}
131
+ #sl{{position:absolute;top:1rem;left:50%;transform:translateX(-50%);font-size:.6rem;color:rgba(180,200,255,.4);
132
+ 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}}
133
+ #ctrls{{position:absolute;bottom:1.2rem;left:50%;transform:translateX(-50%);display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}}
134
+ .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)}}
135
+ #pbw{{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(77,138,255,.1)}}
136
+ #pb{{height:100%;background:linear-gradient(90deg,#4d8aff,#8b5cf6,#22d3a0);width:0%;transition:width .12s linear}}
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <div id="hdr">
141
+ <div class="logo">SPLATWEB</div>
142
+ <div class="hr">
143
+ <div class="badge">✦ 3D KEYFRAME ANIMATION</div>
144
+ <button id="btndl" onclick="saveme()">⬇ SAVE HTML</button>
145
+ </div>
146
+ </div>
147
+ <div id="wrap">
148
+ <canvas id="c"></canvas>
149
+ <div id="ov"><div class="sp"></div><div id="lm">LOADING 3D SCENES…</div></div>
150
+ <div id="sl">ANGLE 1</div>
151
+ <div id="ctrls">
152
+ <div class="pill">Drag β†’ Orbit</div>
153
+ <div class="pill">Scroll β†’ Zoom</div>
154
+ <div class="pill">Camera auto-animates</div>
155
+ </div>
156
+ <div id="pbw"><div id="pb"></div></div>
157
+ </div>
158
+ <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>
159
+ <script type="module">
160
+ import * as G from '@mkkellogg/gaussian-splats-3d';
161
+ 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'}}));}}
162
+ const u1=b2u(`{b1}`),u2=b2u(`{b2}`);
163
+ const canvas=document.getElementById('c'),ov=document.getElementById('ov'),lm=document.getElementById('lm'),pb=document.getElementById('pb'),sl=document.getElementById('sl');
164
+ const A={{x:-2.5,y:-1.2,z:5}},B={{x:2.5,y:-.4,z:5}},TRAVEL=4000,HOLD=1500;
165
+ let viewer=null,phase='hold_a',ps=null;
166
+ const ease=t=>t<.5?2*t*t:-1+(4-2*t)*t,lerp=(a,b,t)=>a+(b-a)*t;
167
+ function tick(now){{
168
+ if(!viewer||!viewer.camera){{requestAnimationFrame(tick);return;}}
169
+ if(!ps)ps=now;const e=now-ps,cam=viewer.camera;
170
+ 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;}}}}
171
+ 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;}}}}
172
+ 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;}}}}
173
+ 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;}}}}
174
+ cam.lookAt(0,0,0);requestAnimationFrame(tick);
175
+ }}
176
+ async function init(){{
177
+ viewer=new G.Viewer({{canvas,cameraUp:[0,-1,0],initialCameraPosition:[A.x,A.y,A.z],initialCameraLookAt:[0,0,0],selfDrivenMode:true,dynamicScene:true}});
178
+ try{{
179
+ lm.textContent='LOADING ANGLE 1…';await viewer.addSplatScene(u1,{{progressiveLoad:false}});
180
+ lm.textContent='LOADING ANGLE 2…';await viewer.addSplatScene(u2,{{progressiveLoad:false}});
181
+ ov.classList.add('hidden');sl.style.display='block';viewer.start();requestAnimationFrame(tick);
182
+ }}catch(err){{lm.textContent='⚠ Load error.';console.error(err);}}
183
+ }}
184
+ init();
185
+ </script>
186
+ <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>
187
+ </body></html>"""
188
+
189
+
190
+ # ────────────────────────────────────────────────────────────────
191
+ # CSS β€” back in gr.Blocks() where it belongs in Gradio 6
192
+ # (shows a harmless warning in logs but does NOT crash)
193
+ # ────────────────────────────────────────────────────────────────
194
+ CSS = """
195
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400&display=swap');
196
+
197
+ body, .gradio-container {
198
+ background: #050810 !important;
199
+ font-family: 'JetBrains Mono', monospace !important;
200
+ }
201
+ .gradio-container { max-width: 820px !important; margin: 0 auto !important; }
202
+
203
+ h1 {
204
+ font-family: 'Syne', sans-serif !important;
205
+ font-weight: 800 !important;
206
+ font-size: 2.6rem !important;
207
+ background: linear-gradient(135deg, #4d8aff, #8b5cf6, #22d3a0) !important;
208
+ -webkit-background-clip: text !important;
209
+ -webkit-text-fill-color: transparent !important;
210
+ letter-spacing: -0.02em !important;
211
+ line-height: 1.1 !important;
212
+ margin-bottom: 0.5rem !important;
213
+ }
214
+
215
+ button.lg { font-family: 'Syne', sans-serif !important; font-weight: 700 !important; }
216
+ """
217
+
218
+ # ────────────────────────────────────────────────────────────────
219
+ # The 3D viewer HTML β€” always visible, loads instantly
220
+ # After processing, JavaScript swaps in the PLY via base64
221
+ # ────────────────────────────────────────────────────────────────
222
+ VIEWER_SHELL = """
223
+ <div id="splatweb-viewer"
224
+ style="background:#050810;border:1px solid rgba(77,138,255,0.18);
225
+ border-radius:18px;overflow:hidden;">
226
+
227
+ <!-- Header bar -->
228
+ <div style="display:flex;align-items:center;justify-content:space-between;
229
+ padding:0.7rem 1.2rem;border-bottom:1px solid rgba(77,138,255,0.1);
230
+ background:rgba(5,8,16,0.95);gap:0.6rem;flex-wrap:wrap;">
231
+ <div>
232
+ <span style="font-family:monospace;font-size:0.68rem;font-weight:bold;
233
+ background:linear-gradient(90deg,#4d8aff,#8b5cf6);
234
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;
235
+ letter-spacing:0.06em;">✦ 3D SCENE VIEWER</span>
236
+ <span id="sw-size" style="font-family:monospace;font-size:0.55rem;
237
+ color:rgba(180,200,255,0.3);margin-left:0.5rem;"></span>
238
+ </div>
239
+ <button id="sw-dl-btn" onclick="swDownload()"
240
+ style="display:none;background:linear-gradient(135deg,#4d8aff,#8b5cf6);
241
+ border:none;border-radius:8px;padding:0.3rem 0.9rem;color:#fff;
242
+ font-family:monospace;font-size:0.6rem;letter-spacing:0.07em;
243
+ cursor:pointer;box-shadow:0 2px 10px rgba(77,138,255,0.3);">
244
+ ⬇ DOWNLOAD .PLY
245
+ </button>
246
+ </div>
247
+
248
+ <!-- Canvas area -->
249
+ <div style="position:relative;width:100%;height:460px;" id="sw-wrap">
250
+ <canvas id="sw-canvas"
251
+ style="width:100%;height:100%;display:block;
252
+ background:radial-gradient(ellipse at center,#071020,#020408);"></canvas>
253
+
254
+ <!-- Idle state (shown before any file is processed) -->
255
+ <div id="sw-idle"
256
+ style="position:absolute;inset:0;display:flex;flex-direction:column;
257
+ align-items:center;justify-content:center;gap:0.8rem;
258
+ background:rgba(5,8,16,0.85);">
259
+ <div style="font-size:2.5rem;opacity:0.3;">✦</div>
260
+ <div style="font-family:monospace;font-size:0.7rem;color:rgba(180,200,255,0.35);
261
+ letter-spacing:0.12em;text-align:center;line-height:1.7;">
262
+ 3D SCENE WILL APPEAR HERE<br/>
263
+ <span style="font-size:0.58rem;opacity:0.6;">Upload a photo and press Build β†’</span>
264
+ </div>
265
+ </div>
266
+
267
+ <!-- Loading spinner (shown while PLY loads into viewer) -->
268
+ <div id="sw-loading"
269
+ style="position:absolute;inset:0;display:none;flex-direction:column;
270
+ align-items:center;justify-content:center;gap:1rem;
271
+ background:rgba(5,8,16,0.82);backdrop-filter:blur(4px);">
272
+ <div style="width:40px;height:40px;
273
+ border:2px solid rgba(77,138,255,0.2);
274
+ border-top-color:#4d8aff;border-radius:50%;
275
+ animation:sw_spin 1s linear infinite;"></div>
276
+ <div id="sw-load-msg"
277
+ style="font-family:monospace;font-size:0.68rem;
278
+ color:rgba(180,200,255,0.55);letter-spacing:0.1em;">
279
+ BUILDING 3D SCENE…
280
+ </div>
281
+ </div>
282
+
283
+ <!-- Controls (shown after scene loads) -->
284
+ <div id="sw-controls"
285
+ style="position:absolute;bottom:0.9rem;left:50%;transform:translateX(-50%);
286
+ display:none;gap:0.4rem;flex-wrap:wrap;justify-content:center;pointer-events:none;">
287
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
288
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
289
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
290
+ color:rgba(180,200,255,0.4);">Drag β†’ Orbit</span>
291
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
292
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
293
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
294
+ color:rgba(180,200,255,0.4);">Scroll β†’ Zoom</span>
295
+ <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px);
296
+ border:1px solid rgba(255,255,255,0.07);border-radius:100px;
297
+ padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem;
298
+ color:rgba(180,200,255,0.4);">Shift+Drag β†’ Pan</span>
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ <details style="margin-top:0.6rem;border:1px solid rgba(77,138,255,0.12);border-radius:10px;
304
+ background:rgba(5,8,16,0.6);">
305
+ <summary style="cursor:pointer;padding:0.5rem 0.8rem;font-family:monospace;font-size:0.6rem;
306
+ color:rgba(180,200,255,0.45);letter-spacing:0.08em;">
307
+ // debug log (tap to expand β€” shows what the browser is doing)
308
+ </summary>
309
+ <pre id="sw-debug" style="font-family:monospace;font-size:0.58rem;line-height:1.5;
310
+ color:rgba(180,200,255,0.55);padding:0 0.8rem 0.7rem;margin:0;
311
+ max-height:180px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;"></pre>
312
+ </details>
313
+
314
+ <style>@keyframes sw_spin { to { transform: rotate(360deg); } }</style>
315
+
316
+ <script type="importmap">
317
+ {
318
+ "imports": {
319
+ "@mkkellogg/gaussian-splats-3d":
320
+ "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js"
321
+ }
322
+ }
323
+ </script>
324
+
325
+ <script type="module">
326
+ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
327
+
328
+ // Expose loader + logger globally so Gradio's js= callback can call them.
329
+ window._swViewer = null;
330
+ window._swBlobUrl = null;
331
+
332
+ window.swLog = function(msg) {
333
+ const t = new Date().toTimeString().slice(0, 8);
334
+ console.log('[SplatWeb]', msg);
335
+ const dbg = document.getElementById('sw-debug');
336
+ if (dbg) {
337
+ dbg.textContent += `[${t}] ${msg}\n`;
338
+ dbg.scrollTop = dbg.scrollHeight;
339
+ }
340
+ };
341
+ window.swLog('viewer module loaded, waiting for a build…');
342
+
343
+ window.swLoadUrl = async function(fileUrl, sizeMb) {
344
+ const idle = document.getElementById('sw-idle');
345
+ const loading = document.getElementById('sw-loading');
346
+ const controls = document.getElementById('sw-controls');
347
+ const dlBtn = document.getElementById('sw-dl-btn');
348
+ const sizeEl = document.getElementById('sw-size');
349
+ const canvas = document.getElementById('sw-canvas');
350
+ const msg = document.getElementById('sw-load-msg');
351
+
352
+ swLog('trigger fired β†’ ' + fileUrl);
353
+
354
+ idle.style.display = 'none';
355
+ loading.style.display = 'flex';
356
+ controls.style.display = 'none';
357
+ if (sizeEl) sizeEl.textContent = (sizeMb ? sizeMb + ' MB Β· ' : '') + 'WebGL Β· your GPU';
358
+
359
+ try {
360
+ msg.textContent = 'DOWNLOADING SCENE…';
361
+ swLog('fetching file…');
362
+ const resp = await fetch(fileUrl);
363
+ swLog('fetch responded: HTTP ' + resp.status);
364
+ if (!resp.ok) throw new Error('server returned HTTP ' + resp.status + ' for the file URL');
365
+
366
+ const buf = await resp.arrayBuffer();
367
+ swLog('downloaded ' + (buf.byteLength / 1024 / 1024).toFixed(2) + ' MB');
368
+ const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }));
369
+
370
+ if (window._swBlobUrl) { try { URL.revokeObjectURL(window._swBlobUrl); } catch(e) {} }
371
+ window._swBlobUrl = blobUrl;
372
+
373
+ if (window._swViewer) {
374
+ try { window._swViewer.dispose(); } catch(e) {}
375
+ window._swViewer = null;
376
+ }
377
+
378
+ swLog('initializing WebGL viewer…');
379
+ const viewer = new GaussianSplats3D.Viewer({
380
+ canvas,
381
+ cameraUp: [0, -1, 0],
382
+ initialCameraPosition: [-1, -4, 6],
383
+ initialCameraLookAt: [0, 0, 0],
384
+ selfDrivenMode: true,
385
+ });
386
+ window._swViewer = viewer;
387
+
388
+ msg.textContent = 'RENDERING GAUSSIANS…';
389
+ swLog('parsing splat data + uploading to GPU…');
390
+ await viewer.addSplatScene(blobUrl, { progressiveLoad: true });
391
+ swLog('scene loaded βœ“');
392
+
393
+ loading.style.display = 'none';
394
+ controls.style.display = 'flex';
395
+ dlBtn.style.display = 'block';
396
+ viewer.start();
397
+
398
+ } catch(err) {
399
+ const emsg = (err && err.message) ? err.message : String(err);
400
+ swLog('ERROR: ' + emsg);
401
+ msg.textContent = '⚠ ' + emsg;
402
+ console.error(err);
403
+ }
404
+ };
405
+ </script>
406
+
407
+ <script>
408
+ // Download using the blob URL we already fetched β€” no re-decoding needed.
409
+ function swDownload() {
410
+ if (!window._swBlobUrl) return;
411
+ const a = document.createElement('a');
412
+ a.href = window._swBlobUrl;
413
+ a.download = 'scene.ply';
414
+ document.body.appendChild(a);
415
+ a.click();
416
+ document.body.removeChild(a);
417
+ }
418
+ </script>
419
+ """
420
+
421
+
422
+ # Gradio serves any file under its allowed paths (which includes the
423
+ # system temp dir β€” where our .ply files live) at this documented route:
424
+ # /gradio_api/file=<path>
425
+ def make_ply_url(ply_path: str) -> str:
426
+ return "/gradio_api/file=" + urllib.parse.quote(ply_path, safe="/")
427
+
428
+
429
+ # ────────────────────────────────────────────────────────────────
430
+ # UI β€” css in gr.Blocks() (correct for all Gradio versions)
431
+ # ────────────────────────────────────────────────────────────────
432
+ with gr.Blocks(css=CSS, title="SplatWeb") as demo:
433
+
434
+ gr.HTML("""
435
+ <div style="text-align:center;padding:2.5rem 1rem 1.5rem;
436
+ border-bottom:1px solid rgba(80,130,255,0.1);margin-bottom:1.5rem;">
437
+ <h1>SPLATWEB</h1>
438
+ <p style="color:rgba(180,200,255,0.4);font-size:0.72rem;
439
+ letter-spacing:0.1em;margin-top:0.3rem;">
440
+ PHOTO β†’ 3D GAUSSIAN SPLAT Β· APPLE SHARP Β· FREE
441
+ </p>
442
+ <div style="display:inline-flex;gap:0.6rem;margin-top:0.8rem;
443
+ flex-wrap:wrap;justify-content:center;">
444
+ <span style="background:rgba(34,211,160,0.08);border:1px solid rgba(34,211,160,0.2);
445
+ color:#22d3a0;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
446
+ βœ“ 100% FREE</span>
447
+ <span style="background:rgba(77,138,255,0.08);border:1px solid rgba(77,138,255,0.2);
448
+ color:#4d8aff;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
449
+ ⚑ AUTO 3D PREVIEW</span>
450
+ <span style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);
451
+ color:#8b5cf6;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;">
452
+ ✦ CAMERA ANIMATION</span>
453
+ </div>
454
+ </div>
455
+ """)
456
+
457
+ with gr.Tabs():
458
+
459
+ # ── Tab 1: Single photo ──────────────────────────────
460
+ with gr.TabItem("πŸ“· Single Photo β†’ 3D"):
461
+
462
+ img1 = gr.Image(
463
+ type="filepath", label="// upload photo",
464
+ sources=["upload", "webcam"], height=260
465
+ )
466
+ with gr.Accordion("πŸ“Έ Tips for best results", open=False):
467
+ gr.Markdown("""
468
+ - Any size photo β€” app auto-resizes to 512px before processing
469
+ - Clear, well-lit subject gives the best 3D quality
470
+ - Works on: objects, rooms, food, people, landscapes
471
+ - **ZeroGPU hardware must be selected in your Space's Settings β†’ Hardware** (it isn't automatic just because it's free now)
472
+ - **Free HF account:** 5 min of GPU time per day total (~3 conversions at current settings) β€” resets 24h after your first run
473
+ - **Not signed in:** only 2 min/day β€” sign in to a free HF account for more
474
+ - **PRO/Team/Enterprise:** much larger daily quota, plus pay-as-you-go
475
+ """)
476
+
477
+ btn1 = gr.Button("✦ Build My 3D Scene", variant="primary", size="lg")
478
+ st1 = gr.Textbox(
479
+ label="// status", interactive=False, lines=2,
480
+ placeholder="Upload a photo and press the button…"
481
+ )
482
+
483
+ # 3D viewer β€” always visible, waits for content
484
+ gr.HTML(VIEWER_SHELL)
485
+
486
+ # Real, native Gradio download β€” works regardless of whether
487
+ # the custom WebGL preview above loads, so you always have a
488
+ # reliable way to get the file and open it in another app.
489
+ dl1 = gr.DownloadButton("⬇ Download .ply", visible=False)
490
+
491
+ # Hidden trigger β€” a plain Textbox (not gr.HTML). We used to
492
+ # push a <script> tag through a gr.HTML update, but browsers
493
+ # (and Gradio's Svelte renderer) never execute <script> tags
494
+ # inserted via innerHTML on a dynamic update β€” only on the
495
+ # page's *initial* load. That's almost certainly why nothing
496
+ # ever fired. A Textbox's .change(js=...) event is Gradio's
497
+ # actual supported mechanism for running JS off a Python
498
+ # return value, and reliably passes the exact string we set β€”
499
+ # no ambiguity, no timing races.
500
+ url_holder = gr.Textbox(value="", visible=False, elem_id="sw-url-holder")
501
+
502
+ # ── Tab 2: Two angles + animation ───────────────────
503
+ with gr.TabItem("🎬 Two Angles β†’ Animation"):
504
+
505
+ gr.HTML("""
506
+ <div style="background:rgba(139,92,246,0.06);
507
+ border:1px solid rgba(139,92,246,0.18);
508
+ border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;">
509
+ <div style="font-size:0.62rem;color:#8b5cf6;
510
+ letter-spacing:0.1em;margin-bottom:0.4rem;">✦ HOW THIS WORKS</div>
511
+ <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;">
512
+ Upload 2 photos of the same object from different angles.
513
+ Both convert to 3D. Enable animation to get a downloadable HTML file β€”
514
+ open it on any phone, camera flies between both angles in a loop.
515
+ The HTML has a <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> button inside.
516
+ </div>
517
+ </div>
518
+ """)
519
+
520
+ with gr.Row():
521
+ imgA = gr.Image(
522
+ type="filepath", label="// angle 1 β€” front / left",
523
+ sources=["upload"], height=220
524
+ )
525
+ imgB = gr.Image(
526
+ type="filepath", label="// angle 2 β€” back / right",
527
+ sources=["upload"], height=220
528
+ )
529
+
530
+ anim_toggle = gr.Checkbox(
531
+ label="✦ Generate camera animation HTML",
532
+ value=True,
533
+ info="Downloadable .html β€” camera flies between angles in a loop, has ⬇ SAVE HTML inside"
534
+ )
535
+ btn2 = gr.Button("✦ Build 3D + Animation", variant="primary", size="lg")
536
+ st2 = gr.Textbox(
537
+ label="// status", interactive=False, lines=3,
538
+ placeholder="Upload both photos and press the button…"
539
+ )
540
+
541
+ anim_file = gr.File(
542
+ label="// animation .html β€” download & open on any phone",
543
+ visible=False, file_types=[".html"]
544
+ )
545
+
546
+ gr.HTML("""
547
+ <div style="margin-top:0.8rem;padding:0.9rem 1rem;
548
+ background:rgba(34,211,160,0.04);
549
+ border:1px solid rgba(34,211,160,0.1);
550
+ border-radius:10px;font-size:0.65rem;
551
+ color:rgba(180,200,255,0.38);line-height:1.8;">
552
+ πŸ“± Download animation .html β†’ open in Chrome or Safari on any phone β†’ 3D plays.<br/>
553
+ πŸ“€ Share via WhatsApp / email β€” recipient just opens the file, no app needed.<br/>
554
+ πŸ’Ύ Tap <strong style="color:#dce8ff;">⬇ SAVE HTML</strong> inside to re-download anytime.
555
+ </div>
556
+ """)
557
+
558
+ # ── Handlers ──────────────────────────────────────────────
559
+ def handle_single(img):
560
+ print(f"[handle_single] click received, img={img}", flush=True)
561
+ if img is None:
562
+ return "⚠ Please upload a photo first.", gr.update(visible=False), ""
563
+ try:
564
+ ply, status = run_sharp(img)
565
+ except Exception as e:
566
+ print(f"[handle_single] GPU call raised: {e}", flush=True)
567
+ return (f"⚠ GPU rejected the request: {str(e)}\n(If this mentions quota/duration, you've hit your daily free ZeroGPU limit β€” wait for it to reset, or sign in with a HF account for a bigger quota.)",
568
+ gr.update(visible=False), "")
569
+ print(f"[handle_single] run_sharp returned ply={ply!r} status={status!r}", flush=True)
570
+ if ply:
571
+ size_mb = round(os.path.getsize(ply) / 1024 / 1024, 2)
572
+ dl_update = gr.update(value=ply, visible=True, label=f"⬇ Download .ply ({size_mb} MB)")
573
+ payload = json.dumps({"url": make_ply_url(ply), "size_mb": size_mb, "ts": time.time()})
574
+ return status, dl_update, payload
575
+ return status, gr.update(visible=False), ""
576
+
577
+ def handle_dual(a, b, do_anim):
578
+ if a is None or b is None:
579
+ return "⚠ Please upload BOTH photos.", gr.update(visible=False)
580
+
581
+ try:
582
+ ply1, s1 = run_sharp(a)
583
+ except Exception as e:
584
+ return f"⚠ GPU rejected angle 1: {str(e)}", gr.update(visible=False)
585
+ if not ply1:
586
+ return f"⚠ Angle 1 failed:\n{s1}", gr.update(visible=False)
587
+
588
+ try:
589
+ ply2, s2 = run_sharp(b)
590
+ except Exception as e:
591
+ return f"βœ“ Angle 1 done.\n⚠ GPU rejected angle 2: {str(e)}", gr.update(visible=False)
592
+ if not ply2:
593
+ return f"βœ“ Angle 1 done.\n⚠ Angle 2 failed:\n{s2}", gr.update(visible=False)
594
+
595
+ msg = "βœ“ Both angles done!"
596
+ html_out = gr.update(visible=False)
597
+
598
+ if do_anim:
599
+ try:
600
+ content = generate_animation_html(ply1, ply2)
601
+ tmp = tempfile.NamedTemporaryFile(
602
+ suffix=".html", prefix="splatweb_anim_",
603
+ delete=False, mode="w", encoding="utf-8"
604
+ )
605
+ tmp.write(content)
606
+ tmp.close()
607
+ html_out = gr.update(value=tmp.name, visible=True)
608
+ msg += "\n✦ Animation HTML ready β€” download below and open on your phone!"
609
+ except Exception as e:
610
+ msg += f"\n⚠ Animation failed: {str(e)}"
611
+
612
+ return msg, html_out
613
+
614
+ btn1.click(fn=handle_single, inputs=[img1], outputs=[st1, dl1, url_holder])
615
+ btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
616
+
617
+ # This is the load-bearing wire for the preview: whenever url_holder's
618
+ # value changes (including programmatically, from handle_single's
619
+ # return), Gradio calls this JS function with that exact string β€” the
620
+ # documented, reliable way to run custom JS off a Python result.
621
+ url_holder.change(
622
+ fn=None, inputs=[url_holder], outputs=[],
623
+ js="""
624
+ (payload) => {
625
+ const log = (m) => { if (window.swLog) window.swLog(m); else console.log('[SplatWeb]', m); };
626
+ if (!payload) { log('trigger cleared, nothing to load'); return; }
627
+ try {
628
+ const data = JSON.parse(payload);
629
+ log('trigger fired -> ' + data.url);
630
+ if (window.swLoadUrl) { window.swLoadUrl(data.url, data.size_mb); }
631
+ else { log('ERROR: viewer script not ready yet β€” reload the page and try again'); }
632
+ } catch (e) {
633
+ log('ERROR parsing trigger payload: ' + e.message);
634
+ }
635
+ }
636
+ """
637
+ )
638
+
639
+ gr.HTML("""
640
+ <div style="text-align:center;padding:1.5rem 1rem;
641
+ border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;">
642
+ <p style="font-size:0.6rem;color:rgba(180,200,255,0.22);line-height:1.8;">
643
+ SplatWeb Β· Apple SHARP Β· HuggingFace Β· 3D renders on your device GPU via WebGL
644
+ </p>
645
+ </div>
646
+ """)
647
+
648
+ if __name__ == "__main__":
649
+ demo.launch(server_name="0.0.0.0", server_port=7860)