Spaces:
Sleeping
Sleeping
| """ | |
| 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. | |
| # --------------------------------------------------------------------------- | |
| 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)) | |
| 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, | |
| ) | |