import gradio as gr
import subprocess
import sys
import os
import base64
import tempfile
from PIL import Image
# ── ZeroGPU optional ──
try:
import spaces
HAS_GPU = True
except Exception:
HAS_GPU = False
class spaces:
@staticmethod
def GPU(duration=60):
return lambda fn: fn
# ── Install SHARP ──
def install_sharp():
try:
r = subprocess.run(["sharp", "--help"], capture_output=True, timeout=10)
if r.returncode == 0:
return
except Exception:
pass
print("Installing Apple SHARP...")
subprocess.check_call([
sys.executable, "-m", "pip", "install",
"git+https://github.com/apple/ml-sharp.git",
"--quiet"
])
install_sharp()
# ── Resize before SHARP ──
def resize_image(path, max_size=512):
img = Image.open(path).convert("RGB")
w, h = img.size
if max(w, h) > max_size:
r = max_size / max(w, h)
img = img.resize((int(w*r), int(h*r)), Image.LANCZOS)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, prefix="sharp_in_")
img.save(tmp.name, "JPEG", quality=90)
return tmp.name
# ── Run SHARP ──
@spaces.GPU(duration=300)
def run_sharp(image_path):
if image_path is None:
return None, "⚠ Please upload a photo first."
out_dir = tempfile.mkdtemp(prefix="splat_")
resized = None
try:
resized = resize_image(image_path, 512)
result = subprocess.run(
["sharp", "predict", "-i", resized, "-o", out_dir],
capture_output=True, text=True, timeout=270
)
ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")]
if not ply_files:
err = (result.stderr or result.stdout or "No .ply produced.")[-800:]
return None, f"SHARP failed:\n{err}"
return os.path.join(out_dir, ply_files[0]), "✓ Done — 3D scene loading below ↓"
except subprocess.TimeoutExpired:
return None, "⚠ Timed out. Request ZeroGPU in your Space Community tab to make this instant."
except FileNotFoundError:
return None, "⚠ SHARP not found yet — wait 1 minute and try again."
except Exception as e:
return None, f"⚠ Error: {str(e)}"
finally:
if resized:
try: os.unlink(resized)
except: pass
# ── Animation HTML (two angles, camera flies between them) ──
def generate_animation_html(ply1, ply2):
def enc(p):
with open(p, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
b1, b2 = enc(ply1), enc(ply2)
return f"""
SplatWeb Animation
SPLATWEB
✦ 3D KEYFRAME ANIMATION
LOADING 3D SCENES…
ANGLE 1
Drag → Orbit
Scroll → Zoom
Camera auto-animates
"""
# ────────────────────────────────────────────────────────────────
# CSS — back in gr.Blocks() where it belongs in Gradio 6
# (shows a harmless warning in logs but does NOT crash)
# ────────────────────────────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400&display=swap');
body, .gradio-container {
background: #050810 !important;
font-family: 'JetBrains Mono', monospace !important;
}
.gradio-container { max-width: 820px !important; margin: 0 auto !important; }
h1 {
font-family: 'Syne', sans-serif !important;
font-weight: 800 !important;
font-size: 2.6rem !important;
background: linear-gradient(135deg, #4d8aff, #8b5cf6, #22d3a0) !important;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
letter-spacing: -0.02em !important;
line-height: 1.1 !important;
margin-bottom: 0.5rem !important;
}
button.lg { font-family: 'Syne', sans-serif !important; font-weight: 700 !important; }
"""
# ────────────────────────────────────────────────────────────────
# The 3D viewer HTML — always visible, loads instantly
# After processing, JavaScript swaps in the PLY via base64
# ────────────────────────────────────────────────────────────────
VIEWER_SHELL = """
✦ 3D SCENE VIEWER
✦
3D SCENE WILL APPEAR HERE Upload a photo and press Build →
BUILDING 3D SCENE…
Drag → OrbitScroll → ZoomShift+Drag → Pan
"""
# ── Build the JS trigger to auto-load PLY into the viewer ──
def make_load_script(ply_path: str) -> str:
"""Returns a tiny HTML snippet that calls swLoadB64() with the PLY data."""
with open(ply_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
size_mb = round(os.path.getsize(ply_path) / 1024 / 1024, 2)
return f"""
"""
# ────────────────────────────────────────────────────────────────
# UI — css in gr.Blocks() (correct for all Gradio versions)
# ────────────────────────────────────────────────────────────────
with gr.Blocks(css=CSS, title="SplatWeb") as demo:
gr.HTML("""
SPLATWEB
PHOTO → 3D GAUSSIAN SPLAT · APPLE SHARP · FREE
✓ 100% FREE
⚡ AUTO 3D PREVIEW
✦ CAMERA ANIMATION
""")
with gr.Tabs():
# ── Tab 1: Single photo ──────────────────────────────
with gr.TabItem("📷 Single Photo → 3D"):
img1 = gr.Image(
type="filepath", label="// upload photo",
sources=["upload", "webcam"], height=260
)
with gr.Accordion("📸 Tips for best results", open=False):
gr.Markdown("""
- Any size photo — app auto-resizes to 512px before processing
- Clear, well-lit subject gives the best 3D quality
- Works on: objects, rooms, food, people, landscapes
- **Without ZeroGPU:** ~2–4 min on CPU
- **With ZeroGPU:** under 1 second — request it in your Space's Community tab
""")
btn1 = gr.Button("✦ Build My 3D Scene", variant="primary", size="lg")
st1 = gr.Textbox(
label="// status", interactive=False, lines=2,
placeholder="Upload a photo and press the button…"
)
# 3D viewer — always visible, waits for content
gr.HTML(VIEWER_SHELL)
# Hidden trigger — populated after processing, fires JS to load PLY
trigger1 = gr.HTML(value="", visible=False)
# ── Tab 2: Two angles + animation ───────────────────
with gr.TabItem("🎬 Two Angles → Animation"):
gr.HTML("""
✦ HOW THIS WORKS
Upload 2 photos of the same object from different angles.
Both convert to 3D. Enable animation to get a downloadable HTML file —
open it on any phone, camera flies between both angles in a loop.
The HTML has a ⬇ SAVE HTML button inside.
""")
with gr.Row():
imgA = gr.Image(
type="filepath", label="// angle 1 — front / left",
sources=["upload"], height=220
)
imgB = gr.Image(
type="filepath", label="// angle 2 — back / right",
sources=["upload"], height=220
)
anim_toggle = gr.Checkbox(
label="✦ Generate camera animation HTML",
value=True,
info="Downloadable .html — camera flies between angles in a loop, has ⬇ SAVE HTML inside"
)
btn2 = gr.Button("✦ Build 3D + Animation", variant="primary", size="lg")
st2 = gr.Textbox(
label="// status", interactive=False, lines=3,
placeholder="Upload both photos and press the button…"
)
anim_file = gr.File(
label="// animation .html — download & open on any phone",
visible=False, file_types=[".html"]
)
gr.HTML("""
📱 Download animation .html → open in Chrome or Safari on any phone → 3D plays.
📤 Share via WhatsApp / email — recipient just opens the file, no app needed.
💾 Tap ⬇ SAVE HTML inside to re-download anytime.
""")
# ── Handlers ──────────────────────────────────────────────
def handle_single(img):
if img is None:
return "⚠ Please upload a photo first.", gr.update(value="", visible=False)
ply, status = run_sharp(img)
if ply:
script = make_load_script(ply)
return status, gr.update(value=script, visible=True)
return status, gr.update(value="", visible=False)
def handle_dual(a, b, do_anim):
if a is None or b is None:
return "⚠ Please upload BOTH photos.", gr.update(visible=False)
ply1, s1 = run_sharp(a)
if not ply1:
return f"⚠ Angle 1 failed:\n{s1}", gr.update(visible=False)
ply2, s2 = run_sharp(b)
if not ply2:
return f"✓ Angle 1 done.\n⚠ Angle 2 failed:\n{s2}", gr.update(visible=False)
msg = "✓ Both angles done!"
html_out = gr.update(visible=False)
if do_anim:
try:
content = generate_animation_html(ply1, ply2)
tmp = tempfile.NamedTemporaryFile(
suffix=".html", prefix="splatweb_anim_",
delete=False, mode="w", encoding="utf-8"
)
tmp.write(content)
tmp.close()
html_out = gr.update(value=tmp.name, visible=True)
msg += "\n✦ Animation HTML ready — download below and open on your phone!"
except Exception as e:
msg += f"\n⚠ Animation failed: {str(e)}"
return msg, html_out
btn1.click(fn=handle_single, inputs=[img1], outputs=[st1, trigger1])
btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
gr.HTML("""
SplatWeb · Apple SHARP · HuggingFace · 3D renders on your device GPU via WebGL
""")
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860)