open3dforge / app.py
Reverb's picture
Fix ZeroGPU proxy token expiry: single GPU allocation for full pipeline
5dad3a0
Raw
History Blame Contribute Delete
40.4 kB
"""
Open3DForge — Image-to-game-ready 3D asset pipeline
====================================================
Milestone 1: Foundation
-----------------------
This is the scaffold. It validates that:
- The HF Space builds with the right Gradio + ZeroGPU configuration
- `@spaces.GPU` allocates and releases an H200 successfully
- `gr.Model3D` renders GLB files inline
- The 5-tab UI structure is sound
- Workspace folder management works
- Quota tracking persists across page reloads
Subsequent milestones fill in the actual pipeline stages.
Deployed exclusively on HF Spaces ZeroGPU — no local execution path.
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
# nvdiffrast is compiled against CUDA 13.0 and its .so links to libcudart.so.13.
# os.environ["LD_LIBRARY_PATH"] only affects child processes, not the running
# Python interpreter. Use ctypes.CDLL with RTLD_GLOBAL to load libcudart.so.13
# into the current process before nvdiffrast is imported.
import ctypes, glob as _glob
def _preload_cudart() -> None:
cuda_lib_dir = "/cuda-image/usr/local/cuda-13.0/lib64"
# Try versioned name first, then unversioned fallback.
for pattern in (
f"{cuda_lib_dir}/libcudart.so.13*",
f"{cuda_lib_dir}/libcudart.so",
):
matches = _glob.glob(pattern)
if matches:
try:
ctypes.CDLL(matches[0], mode=ctypes.RTLD_GLOBAL)
print(f"Preloaded {matches[0]}", flush=True)
except OSError as e:
print(f"Preload failed for {matches[0]}: {e}", flush=True)
return
_preload_cudart()
import gradio as gr
import spaces # HF ZeroGPU
# GPU arch list used to compile nvdiffrast.
# +PTX on the last entry generates portable PTX code that the CUDA runtime
# JIT-compiles for any newer GPU (e.g. Blackwell SM 10.0) at first use.
_NVDR_ARCH = "8.0;8.9;9.0+PTX"
_NVDR_MARKER = Path("/tmp/nvdiffrast_arch.txt")
def _install_nvdiffrast() -> None:
"""Install nvdiffrast from source at startup.
Must run here (not requirements.txt) because pip's build isolation hides
torch from nvdiffrast's build system.
Shim strategy: ZeroGPU machine has CUDA 13.0 toolkit but torch 2.9.1 was
compiled with CUDA 12.8. PyTorch's _check_cuda_version raises on mismatch.
We put a shim nvcc on PATH that reports 12.8 for --version but delegates
real compilation to the actual CUDA 13.0 nvcc.
Arch strategy: compile native kernels for SM 8.0 (A100) and 8.9 (L4/L40),
plus PTX for SM 9.0 (H100/H200). The +PTX entry lets the CUDA 13.0 runtime
JIT-compile for any newer architecture (e.g. SM 10.0 Blackwell) at first use.
Marker file: /tmp/nvdiffrast_arch.txt stores the arch string used. If it
matches _NVDR_ARCH, skip rebuild on warm container restarts.
"""
import os
import shutil
import tempfile
import torch
# Check if already installed with the current arch — skip rebuild if so.
try:
import nvdiffrast # noqa: F401
if _NVDR_MARKER.exists() and _NVDR_MARKER.read_text().strip() == _NVDR_ARCH:
print("nvdiffrast: already installed with correct arch.", flush=True)
return
print("nvdiffrast: arch mismatch or missing marker — forcing reinstall.", flush=True)
subprocess.run(
[sys.executable, "-m", "pip", "uninstall", "nvdiffrast", "-y"],
check=False, capture_output=True,
)
except (ImportError, OSError):
pass
torch_cuda = torch.version.cuda or "12.8"
print(f"nvdiffrast: torch.version.cuda={torch_cuda}", flush=True)
# Locate real nvcc.
real_nvcc: str | None = None
real_cuda_home: str | None = None
for candidate in [
os.environ.get("CUDA_HOME", ""),
"/cuda-image/usr/local/cuda-13.0",
"/cuda-image/usr/local/cuda-12.8",
"/cuda-image/usr/local/cuda",
"/usr/local/cuda-13.0",
"/usr/local/cuda",
]:
if candidate and os.path.isfile(os.path.join(candidate, "bin", "nvcc")):
real_cuda_home = candidate
real_nvcc = os.path.join(candidate, "bin", "nvcc")
break
if real_nvcc is None:
real_nvcc = shutil.which("nvcc")
print(f"nvdiffrast: real_nvcc={real_nvcc}", flush=True)
env = os.environ.copy()
if real_nvcc:
fake_cuda = Path(tempfile.mkdtemp(prefix="cuda_shim_"))
(fake_cuda / "bin").mkdir()
if real_cuda_home:
for sub in ("include", "lib", "lib64"):
src = Path(real_cuda_home) / sub
if src.exists():
(fake_cuda / sub).symlink_to(src)
shim = fake_cuda / "bin" / "nvcc"
shim.write_text(
"#!/bin/sh\n"
'case "$1" in\n'
' --version)\n'
f' echo "Cuda compilation tools, release {torch_cuda}, V{torch_cuda}.0"\n'
' ;;\n'
' *)\n'
f' exec {real_nvcc} "$@"\n'
' ;;\n'
'esac\n'
)
shim.chmod(0o755)
env["CUDA_HOME"] = str(fake_cuda)
env["CUDA_PATH"] = str(fake_cuda)
print(f"nvdiffrast: shim CUDA_HOME={fake_cuda}", flush=True)
# PTX in the arch list lets the runtime JIT-compile for any newer GPU
# (e.g. Blackwell SM 10.0) that lacks a pre-compiled cubin.
env["TORCH_CUDA_ARCH_LIST"] = _NVDR_ARCH
print(f"Building nvdiffrast (arch={_NVDR_ARCH}, ~2 min)...", flush=True)
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--no-build-isolation",
"git+https://github.com/NVlabs/nvdiffrast.git"],
env=env, check=False, capture_output=True, text=True,
)
print(result.stdout[-3000:] if result.stdout else "", flush=True)
if result.returncode != 0:
print(f"nvdiffrast build FAILED (rc={result.returncode}):", flush=True)
print(result.stderr[-2000:] if result.stderr else "", flush=True)
else:
print("nvdiffrast build succeeded.", flush=True)
_NVDR_MARKER.write_text(_NVDR_ARCH)
_install_nvdiffrast()
from src import quota, ui_helpers, workspace
from src.stages.stage1_generate import generate_trellis, generate_hunyuan
# ---------------------------------------------------------------------------
# ZeroGPU test function — proves that GPU allocation works.
# Replaced in Milestone 2 with the actual TRELLIS.2 generator.
# ---------------------------------------------------------------------------
@spaces.GPU(duration=15)
def zerogpu_smoke_test() -> str:
"""Allocate a GPU, run a trivial torch op, release. Reports timing."""
start = time.time()
try:
import torch
if not torch.cuda.is_available():
return "❌ GPU not available inside @spaces.GPU. Something is wrong."
device = torch.device("cuda")
# Trivial GPU work
a = torch.randn(1024, 1024, device=device)
b = torch.randn(1024, 1024, device=device)
c = a @ b
torch.cuda.synchronize()
result_sum = float(c.sum().item())
gpu_name = torch.cuda.get_device_name(0)
vram_total = torch.cuda.get_device_properties(0).total_memory / 1e9
elapsed = time.time() - start
quota.record_usage("smoke_test", elapsed)
return (
f"✅ **GPU allocation successful**\n\n"
f"- Device: `{gpu_name}`\n"
f"- VRAM: `{vram_total:.1f} GB`\n"
f"- Test op (1024×1024 matmul): `{elapsed:.2f}s`\n"
f"- Result sum: `{result_sum:.2f}` (sanity check, non-zero = ✓)\n\n"
f"ZeroGPU integration is working. Ready for Milestone 2."
)
except Exception as e:
elapsed = time.time() - start
return f"❌ GPU test failed after {elapsed:.2f}s:\n```\n{type(e).__name__}: {e}\n```"
# ---------------------------------------------------------------------------
# Stage stubs — return placeholder messages until milestones implement them.
# ---------------------------------------------------------------------------
def _stub(stage: str) -> str:
return (
f"🚧 **{stage}** — not implemented yet.\n\n"
f"This stub will be replaced in a future milestone. "
f"See `PLAN.md` for the full pipeline spec."
)
def handle_generate(images, model, quality, seed, _steps, _octree, tex_size, _symmetry, do_rembg):
"""Dispatch to the correct generation backend.
Yields (status_markdown, viewer_path_or_None) tuples for streaming.
The viewer path is yielded only in the final tuple so Gradio can serve
the GLB directly without a separate state lookup.
"""
if "TRELLIS" in model:
yield f"⚙️ **TRELLIS.2** · {quality}\n\nContacting remote Space...", None
result = generate_trellis(images, quality, int(seed), int(tex_size))
viewer_path = ui_helpers.get_viewer_model_path()
yield result, viewer_path
return
yield from generate_hunyuan(images, quality, int(seed), int(tex_size), do_rembg=bool(do_rembg))
@spaces.GPU(duration=600)
def run_post_process(
do_repair, do_cleanup, do_decimate, target_faces,
do_symmetry, do_unwrap, do_normal_bake, normal_format,
do_albedo, do_material, do_ao, ao_quality,
do_inpaint, do_lods, do_collision, pivot, scale_m,
):
"""Run post-processing pipeline. Yields cumulative status markdown for streaming."""
state = workspace.get_state()
current_glb = state.raw_gen_glb or state.high_poly_glb
if not current_glb or not current_glb.exists():
yield "❌ No generated asset found. Run Stage 1 (Generate) first."
return
log = []
def _emit(line: str):
log.append(line)
return "\n".join(log)
if do_repair:
yield _emit("⏳ Repairing mesh (pymeshfix)...")
try:
from src.stages.stage2_repair import repair_mesh
current_glb, msg = repair_mesh(current_glb)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Repair error: {e}")
if do_cleanup:
yield _emit("⏳ Cleaning geometry (trimesh)...")
try:
from src.stages.stage2_cleanup import cleanup_mesh
current_glb, msg = cleanup_mesh(current_glb)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Cleanup error: {e}")
if do_decimate:
yield _emit(f"⏳ Decimating to {int(target_faces):,} faces...")
try:
from src.stages.stage2_decimate import decimate_mesh_final
current_glb, msg = decimate_mesh_final(current_glb, int(target_faces))
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Decimation error: {e}")
if do_symmetry:
yield _emit("⏳ Enforcing bilateral symmetry...")
try:
from src.stages.stage2_symmetry import apply_symmetry
current_glb, msg = apply_symmetry(current_glb, "bilateral-X")
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Symmetry error: {e}")
if do_unwrap:
yield _emit("⏳ UV unwrapping (xatlas)...")
try:
from src.stages.stage2_uv import unwrap_uvs
current_glb, msg = unwrap_uvs(current_glb)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ UV unwrap error: {e}")
if do_normal_bake:
yield _emit("⏳ Baking normal map (GPU)...")
try:
from src.stages.stage2_bake_normal import bake_normal_map
st = workspace.get_state()
hp = st.high_poly_glb
lo = st.unwrapped_glb or current_glb
if not hp or not hp.exists():
yield _emit("⚠️ Normal bake: no high-poly GLB. Generate first.")
else:
_gl, _dx, msg = bake_normal_map(hp, lo, map_size=2048, dx_format=True)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Normal bake error: {e}")
st = workspace.get_state()
hp = st.high_poly_glb
lo = st.unwrapped_glb or current_glb
if do_albedo:
yield _emit("⏳ Baking albedo map (GPU)...")
try:
from src.stages.stage2_bake_albedo import bake_albedo
if not hp or not hp.exists():
yield _emit("⚠️ Albedo bake: no high-poly. Generate first.")
else:
_, msg = bake_albedo(hp, lo, map_size=2048)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Albedo bake error: {e}")
if do_material:
yield _emit("⏳ Baking material maps (GPU)...")
try:
from src.stages.stage2_bake_albedo import bake_material
if not hp or not hp.exists():
yield _emit("⚠️ Material bake: no high-poly. Generate first.")
else:
_, _, msg = bake_material(hp, lo, map_size=2048)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Material bake error: {e}")
if do_ao:
yield _emit(f"⏳ Baking AO ({ao_quality}, ray casting)...")
try:
from src.stages.stage2_bake_ao import bake_ao
_, msg = bake_ao(current_glb, lo, map_size=2048, quality=ao_quality)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ AO bake error: {e}")
st2 = workspace.get_state()
if do_albedo or do_material or do_ao:
yield _emit("⏳ Packing ORM texture...")
try:
from src.stages.stage2_finalize import pack_orm
_, msg = pack_orm(st2.ao_png, st2.roughness_png, st2.metallic_png)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ ORM pack error: {e}")
if do_lods:
yield _emit("⏳ Generating LODs...")
try:
from src.stages.stage2_finalize import generate_lods
lod_src = st2.final_glb or st2.low_poly_glb or current_glb
_, msg = generate_lods(lod_src)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ LOD error: {e}")
if do_collision:
yield _emit("⏳ Generating collision mesh (CoACD)...")
try:
from src.stages.stage2_finalize import generate_collision
col_src = st2.low_poly_glb or current_glb
_, msg = generate_collision(col_src)
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Collision error: {e}")
yield _emit("⏳ Setting pivot and scale...")
try:
from src.stages.stage2_finalize import set_pivot, validate_scale
piv_src = st2.low_poly_glb or current_glb
piv_src, msg = set_pivot(piv_src, pivot)
yield _emit(f"✅ {msg}")
_, msg = validate_scale(piv_src, float(scale_m))
yield _emit(f"✅ {msg}")
except Exception as e:
yield _emit(f"⚠️ Pivot/scale error: {e}")
if do_inpaint:
yield _emit("🚧 SDXL inpaint — not yet implemented")
final = workspace.get_state()
out = final.final_glb or final.low_poly_glb or final.cleaned_glb or final.repaired_glb
if out and out.exists():
yield _emit(f"\n**Output:** `{out.name}` · ready for Stage 3 or Export.")
def handle_auto_rig(rig_type, seed, spring, fmt):
from src.stages.stage3_rig import auto_rig
_, msg = auto_rig(rig_type=rig_type, seed=int(seed))
return msg
def handle_export(engine, asset_name, asset_type, include_lods, include_collision):
from src.stages.stage4_export import export_ue5, _checklist
state = workspace.get_state()
if engine != "UE5":
return f"🚧 {engine} export — not implemented. UE5 is the only supported engine.", None
# Game-ready checklist
issues = _checklist(state)
checklist_md = ""
if issues:
checklist_md = "\n\n⚠️ **Checklist warnings:**\n" + "\n".join(f"- {i}" for i in issues)
try:
zip_path, msg, _ = export_ue5(
asset_name=asset_name.strip() or "Asset",
asset_type=asset_type,
include_lods=include_lods,
include_collision=include_collision,
)
return msg + checklist_md, str(zip_path)
except Exception as e:
return f"❌ Export failed: {e}{checklist_md}", None
# ---------------------------------------------------------------------------
# Presets tab logic (M11 — real save/load wired)
# ---------------------------------------------------------------------------
def ui_refresh_presets() -> gr.Dropdown:
return gr.Dropdown(choices=workspace.list_presets(), label="Saved presets")
def ui_save_preset(
name: str,
gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
pp_albedo, pp_material, pp_ao, pp_ao_quality,
pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m,
rig_type, rig_seed, rig_format,
ex_engine, ex_type,
) -> tuple[str, gr.Dropdown]:
if not name or not name.strip():
return "❌ Preset name cannot be empty.", ui_refresh_presets()
config = {
"name": name, "version": 1, "created_at": time.time(),
"stage1": {"model": gen_model, "quality": gen_quality, "seed": int(gen_seed), "tex_size": int(gen_tex_size), "rembg": bool(gen_rembg)},
"stage2": {
"repair": pp_repair, "cleanup": pp_cleanup, "decimate": pp_decimate,
"target_faces": int(pp_target_faces), "symmetry": pp_symmetry,
"unwrap": pp_unwrap, "normal_bake": pp_normal_bake, "normal_format": pp_normal_format,
"albedo_bake": pp_albedo, "material_bake": pp_material, "ao": pp_ao, "ao_quality": pp_ao_quality,
"inpaint": pp_inpaint, "lods": pp_lods, "collision": pp_collision,
"pivot": pp_pivot, "scale_m": float(pp_scale_m),
},
"stage3": {"rig_type": rig_type, "seed": int(rig_seed), "format": rig_format},
"stage4": {"engine": ex_engine, "asset_type": ex_type},
}
workspace.save_preset(name, config)
return f"✅ Saved preset: `{name}`", ui_refresh_presets()
def ui_load_preset(name: str):
"""Apply a saved preset to all UI components."""
_no_op = gr.update()
n = 27 # number of component outputs after pr_status
if not name:
return ("❌ Select a preset first.",) + (_no_op,) * n
try:
cfg = workspace.load_preset(name)
except FileNotFoundError:
return (f"❌ Preset '{name}' not found.",) + (_no_op,) * n
s1 = cfg.get("stage1", {})
s2 = cfg.get("stage2", {})
s3 = cfg.get("stage3", {})
s4 = cfg.get("stage4", {})
return (
f"✅ Loaded preset: `{name}`",
# Stage 1
gr.update(value=s1.get("model", "Hunyuan3D-2.1 (Organic / Characters)")),
gr.update(value=s1.get("quality", "Balanced (~60s)")),
gr.update(value=s1.get("seed", 42)),
gr.update(value=s1.get("tex_size", 2048)),
gr.update(value=s1.get("rembg", True)),
# Stage 2
gr.update(value=s2.get("repair", True)),
gr.update(value=s2.get("cleanup", True)),
gr.update(value=s2.get("decimate", True)),
gr.update(value=s2.get("target_faces", 25000)),
gr.update(value=s2.get("symmetry", False)),
gr.update(value=s2.get("unwrap", True)),
gr.update(value=s2.get("normal_bake", True)),
gr.update(value=s2.get("normal_format", "DirectX (UE5)")),
gr.update(value=s2.get("albedo_bake", True)),
gr.update(value=s2.get("material_bake", True)),
gr.update(value=s2.get("ao", True)),
gr.update(value=s2.get("ao_quality", "Standard")),
gr.update(value=s2.get("inpaint", False)),
gr.update(value=s2.get("lods", True)),
gr.update(value=s2.get("collision", True)),
gr.update(value=s2.get("pivot", "bottom_center")),
gr.update(value=s2.get("scale_m", 1.8)),
# Stage 3
gr.update(value=s3.get("rig_type", "Humanoid")),
gr.update(value=s3.get("seed", 0)),
gr.update(value=s3.get("format", "FBX (UE5 recommended)")),
# Stage 4
gr.update(value=s4.get("engine", "UE5")),
gr.update(value=s4.get("asset_type", "Prop (SM_)")),
)
def ui_delete_preset(name: str) -> tuple[str, gr.Dropdown]:
if not name:
return "❌ Select a preset to delete.", ui_refresh_presets()
if workspace.delete_preset(name):
return f"🗑️ Deleted preset: `{name}`", ui_refresh_presets()
return f"❌ Preset not found: `{name}`", ui_refresh_presets()
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
CUSTOM_CSS = """
.status-bar {
font-size: 0.85em;
color: #888;
padding: 8px 12px;
border-top: 1px solid #333;
margin-top: 12px;
}
.asset-summary {
font-size: 0.9em;
background: rgba(255,255,255,0.03);
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08);
}
"""
def build_ui() -> gr.Blocks:
with gr.Blocks(
title="Open3DForge",
) as demo:
# --- Header --------------------------------------------------------
gr.Markdown(
"# 🛠️ Open3DForge\n"
"*Personal image-to-game-ready 3D asset pipeline · UE5-first · "
"Built on HF ZeroGPU*"
)
# --- Tabs ----------------------------------------------------------
with gr.Tabs() as tabs:
# ============ Tab 1: Generate =================================
with gr.Tab("1. Generate", id=1):
gr.Markdown(
"### Stage 1 — Image to 3D\n"
"Upload 1–4 reference images. Multi-view dramatically "
"improves quality for characters (front / 3-quarter / side / back)."
)
with gr.Row():
with gr.Column(scale=1):
gen_images = gr.File(
label="Reference images (1–4)",
file_count="multiple",
file_types=["image"],
)
gen_model = gr.Radio(
choices=[
"Hunyuan3D-2.1 (Organic / Characters)",
"TRELLIS.2 (Hard Surface)",
],
value="Hunyuan3D-2.1 (Organic / Characters)",
label="Generation model",
)
gen_quality = gr.Radio(
choices=["Fast (~30s)", "Balanced (~60s)", "Hero (~90s)"],
value="Balanced (~60s)",
label="Quality preset",
)
gen_rembg = gr.Checkbox(
value=True,
label="Remove background automatically",
info="Uses rembg IS-Net to strip background before generation. "
"Disable if your image already has a transparent background.",
)
with gr.Accordion("Advanced", open=False):
gen_seed = gr.Number(value=42, label="Seed", precision=0)
gen_steps = gr.Slider(
20, 50, value=35, step=5,
label="Inference steps",
)
gen_octree = gr.Dropdown(
choices=[256, 384, 512], value=384,
label="Octree resolution",
)
gen_tex_size = gr.Dropdown(
choices=[1024, 2048, 4096], value=2048,
label="Texture size",
)
gen_symmetry = gr.Radio(
choices=["off", "bilateral", "radial"],
value="off",
label="Symmetry hint",
)
gen_btn = gr.Button("Generate", variant="primary")
with gr.Column(scale=1):
gen_status = gr.Markdown("*Awaiting input.*")
# Click wired below, after viewer component is defined.
# ============ Tab 2: Post-Process =============================
with gr.Tab("2. Post-Process", id=2):
gr.Markdown(
"### Stage 2 — Mesh cleanup, UV unwrap, texture baking\n"
"Toggle steps on/off. Decimation has a live preview, the "
"rest run on confirm."
)
with gr.Row():
with gr.Column(scale=1):
pp_repair = gr.Checkbox(value=True, label="Mesh repair (pymeshfix)")
pp_cleanup = gr.Checkbox(value=True, label="Geometry cleanup (PyMeshLab)")
pp_decimate = gr.Checkbox(value=True, label="Decimation")
pp_target_faces = gr.Slider(
1000, 200000, value=25000, step=1000,
label="Target faces",
)
pp_symmetry = gr.Checkbox(value=False, label="Enforce bilateral symmetry")
pp_unwrap = gr.Checkbox(value=True, label="UV unwrap (xatlas)")
pp_normal_bake = gr.Checkbox(value=True, label="Normal bake (nvdiffrast)")
pp_normal_format = gr.Radio(
choices=["DirectX (UE5)", "OpenGL (Unity/Godot)"],
value="DirectX (UE5)",
label="Normal format",
)
pp_albedo_bake = gr.Checkbox(value=True, label="Albedo bake")
pp_material_bake = gr.Checkbox(value=True, label="Material bake (TRELLIS.2 attrs)")
pp_ao = gr.Checkbox(value=True, label="AO bake")
pp_ao_quality = gr.Radio(
choices=["Fast", "Standard", "High"],
value="Standard",
label="AO quality",
)
pp_inpaint = gr.Checkbox(value=False, label="SDXL inpaint hidden UVs (~30s GPU)")
pp_lods = gr.Checkbox(value=True, label="Generate LODs (LOD0/1/2)")
pp_collision = gr.Checkbox(value=True, label="Collision mesh (CoACD)")
pp_pivot = gr.Radio(
choices=["bottom_center", "geometric_center", "custom"],
value="bottom_center",
label="Pivot point",
)
pp_scale_m = gr.Number(value=1.8, label="Real-world height (meters)")
pp_btn = gr.Button("Run Post-Processing", variant="primary")
with gr.Column(scale=1):
pp_face_preview = gr.Markdown("*Face count preview: move the slider.*")
pp_status = gr.Markdown("*No asset to process. Generate one first.*")
def _decimate_preview(target_faces):
state = workspace.get_state()
src = state.low_poly_glb or state.cleaned_glb or state.repaired_glb or state.raw_gen_glb
if not src or not src.exists():
return "*Generate an asset first.*"
try:
from src.stages.stage2_decimate import decimate_preview
fc, vc = decimate_preview(src, int(target_faces))
return f"**Preview:** ~{fc:,} faces · ~{vc:,} vertices at target {int(target_faces):,}"
except Exception as e:
return f"Preview error: {e}"
pp_target_faces.change(
fn=_decimate_preview,
inputs=pp_target_faces,
outputs=pp_face_preview,
)
_pp_event = pp_btn.click(
fn=run_post_process,
inputs=[pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m],
outputs=pp_status,
)
# ============ Tab 3: Auto-Rig =================================
with gr.Tab("3. Auto-Rig", id=3):
gr.Markdown(
"### Stage 3 — Auto-rigging (optional)\n"
"Uses UniRig (VAST-AI). For characters and creatures. "
"After rigging, drop the FBX into [Mixamo](https://mixamo.com) "
"for free animation presets."
)
with gr.Row():
with gr.Column(scale=1):
rig_type = gr.Dropdown(
choices=["Humanoid", "Quadruped", "Bird", "Insect", "Custom"],
value="Humanoid",
label="Character type",
)
rig_seed = gr.Number(value=0, label="Skeleton seed", precision=0)
rig_spring = gr.Checkbox(value=False, label="Spring bones (hair/cloth/tail)")
rig_format = gr.Radio(
choices=["FBX (UE5 recommended)", "GLB"],
value="FBX (UE5 recommended)",
label="Export format",
)
rig_btn = gr.Button("Auto-Rig", variant="primary")
with gr.Column(scale=1):
rig_status = gr.Markdown("*Process an asset in Stage 2 first.*")
_rig_event = rig_btn.click(
fn=handle_auto_rig,
inputs=[rig_type, rig_seed, rig_spring, rig_format],
outputs=rig_status,
)
# ============ Tab 4: Export ===================================
with gr.Tab("4. Export", id=4):
gr.Markdown(
"### Stage 4 — Engine-ready export\n"
"UE5 default: FBX with DirectX normals + ORM-packed textures."
)
with gr.Row():
with gr.Column(scale=1):
ex_engine = gr.Dropdown(
choices=["UE5", "Unity (HDRP)", "Godot 4", "Blender", "Web (Three.js)"],
value="UE5",
label="Target engine",
)
ex_name = gr.Textbox(value="Asset_01", label="Asset name")
ex_type = gr.Radio(
choices=["Character (SK_)", "Prop (SM_)", "Environment (SM_)"],
value="Prop (SM_)",
label="Asset type",
)
ex_include_lods = gr.Checkbox(value=True, label="Include LODs")
ex_include_collision = gr.Checkbox(value=True, label="Include collision mesh")
ex_btn = gr.Button("Export", variant="primary")
with gr.Column(scale=1):
ex_status = gr.Markdown("*Nothing to export yet.*")
ex_file = gr.File(label="Download", visible=True)
_ex_event = ex_btn.click(
fn=handle_export,
inputs=[ex_engine, ex_name, ex_type, ex_include_lods, ex_include_collision],
outputs=[ex_status, ex_file],
)
# ============ Tab 5: Presets ==================================
with gr.Tab("5. Presets", id=5):
gr.Markdown(
"### Saved configurations\n"
"Save the current settings across all tabs as a named preset. "
"Five defaults ship with the app: `character_UE5_hero`, "
"`character_UE5_npc`, `prop_UE5_hero`, `prop_UE5_standard`, "
"`environment_UE5_background`."
)
with gr.Row():
with gr.Column():
pr_list = gr.Dropdown(
choices=workspace.list_presets(),
label="Saved presets",
)
pr_refresh = gr.Button("Refresh list", size="sm")
with gr.Row():
pr_name = gr.Textbox(label="New preset name", scale=2)
pr_save = gr.Button("Save current settings", variant="primary", scale=1)
with gr.Row():
pr_load = gr.Button("Load selected", variant="secondary", scale=1)
pr_delete = gr.Button("Delete selected", variant="stop", scale=1)
pr_status = gr.Markdown()
pr_refresh.click(fn=ui_refresh_presets, outputs=pr_list)
pr_load.click(
fn=ui_load_preset,
inputs=pr_list,
outputs=[
pr_status,
gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m,
rig_type, rig_seed, rig_format,
ex_engine, ex_type,
],
)
pr_save.click(
fn=ui_save_preset,
inputs=[
pr_name,
gen_model, gen_quality, gen_seed, gen_tex_size, gen_rembg,
pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m,
rig_type, rig_seed, rig_format,
ex_engine, ex_type,
],
outputs=[pr_status, pr_list],
)
pr_delete.click(fn=ui_delete_preset, inputs=pr_list, outputs=[pr_status, pr_list])
# ============ Tab 6: Diagnostics (hidden in prod, useful now) =
with gr.Tab("Diagnostics", id=99):
gr.Markdown(
"### Milestone 1 — Foundation check\n"
"Verify the Space environment is working correctly before "
"building out the pipeline."
)
with gr.Row():
with gr.Column():
diag_btn = gr.Button("🧪 Run GPU smoke test", variant="primary")
diag_out = gr.Markdown()
with gr.Column():
gr.Markdown("**Workspace state:**")
diag_state = gr.JSON(value=workspace.get_state().to_dict())
diag_refresh = gr.Button("Refresh state", size="sm")
diag_btn.click(fn=zerogpu_smoke_test, outputs=diag_out)
diag_refresh.click(
fn=lambda: workspace.get_state().to_dict(),
outputs=diag_state,
)
# --- Persistent right-side viewer + asset summary ------------------
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=2):
viewer = gr.Model3D(
label="3D viewer",
value=ui_helpers.get_viewer_model_path(),
clear_color=[0.1, 0.1, 0.12, 1.0],
height=500,
)
with gr.Column(scale=1):
summary = gr.Markdown(
value=ui_helpers.get_asset_summary(),
elem_classes=["asset-summary"],
)
refresh_summary = gr.Button("🔄 Refresh viewer", size="sm")
refresh_summary.click(
fn=lambda: (
ui_helpers.get_viewer_model_path(),
ui_helpers.get_asset_summary(),
),
outputs=[viewer, summary],
)
# --- Global status bar --------------------------------------------
status_bar = gr.Markdown(
value=ui_helpers.get_status_bar(),
elem_classes=["status-bar"],
)
# --- Wire generate button now that viewer is in scope ---------------
# handle_generate yields (status_str, glb_path_or_None) tuples so that
# the viewer updates the instant the final mesh is ready — no separate
# state lookup needed.
_gen_event = gen_btn.click(
fn=handle_generate,
inputs=[gen_images, gen_model, gen_quality, gen_seed,
gen_steps, gen_octree, gen_tex_size, gen_symmetry,
gen_rembg],
outputs=[gen_status, viewer],
)
# --- Global refresh: every pipeline action updates summary + status bar.
# For gen_btn the viewer is already updated directly above; for the rest
# we include the viewer in _global_refresh so post-process / rig / export
# results also appear.
def _global_refresh():
return (
ui_helpers.get_viewer_model_path(),
ui_helpers.get_asset_summary(),
ui_helpers.get_status_bar(),
)
def _summary_refresh():
return ui_helpers.get_asset_summary(), ui_helpers.get_status_bar()
# gen_btn: viewer already updated by direct output; only refresh metadata.
_gen_event.then(fn=_summary_refresh, outputs=[summary, status_bar])
# Other processing buttons: refresh viewer + metadata after completion.
for _ev in (_pp_event, _rig_event, _ex_event):
_ev.then(fn=_global_refresh, outputs=[viewer, summary, status_bar])
# Utility buttons: refresh immediately on click.
for _btn in (diag_btn, diag_refresh, refresh_summary):
_btn.click(fn=_global_refresh, outputs=[viewer, summary, status_bar])
return demo
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
# On HF Spaces, app.py is executed directly. We construct the demo and call
# .launch() at module level. The HF Space runtime handles all networking;
# we just need to bind to 0.0.0.0:7860.
workspace.ensure_dirs()
demo = build_ui()
demo.queue(default_concurrency_limit=1).launch(
server_name="0.0.0.0",
server_port=7860,
show_error=True,
# Allow Gradio to serve files from the workspace directory so that
# gr.Model3D can display generated GLBs without a 403 error.
allowed_paths=[str(workspace.WORKSPACE)],
# Gradio 6: show_api removed. Use footer_links instead.
footer_links=["gradio", "settings"],
theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
css=CUSTOM_CSS,
)