Spaces:
Runtime error
Runtime error
| """3D Creator Suite v0.3.0 — Local 3D Design Studio | |
| Open-source 3D asset generation — upload an image, get a textured 3D model. | |
| Powered by Gradio + Hugging Face Space APIs. | |
| Usage: | |
| python app.py # Launch at http://localhost:7860 | |
| OUTPUT_DIR=~/3d-outputs python app.py # Custom output directory | |
| HF_TOKEN=hf_xxx python app.py # Token from env | |
| python app.py --port 7860 --share # Public tunnel URL | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import logging | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| from pathlib import Path | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(name)s %(levelname)7s: %(message)s", | |
| ) | |
| log = logging.getLogger("creator3d") | |
| ROOT = Path(__file__).parent | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| MODELS = [ | |
| { | |
| "key": "triposg", | |
| "name": "TripoSG", | |
| "display": "TripoSG — High-fidelity shapes", | |
| "desc": "Rectified flow transformer. Clean meshes, good detail. ~30-60s on ZeroGPU.", | |
| "vram": "8 GB", | |
| "default": True, | |
| "space": "VAST-AI/TripoSG", | |
| "api_name": "image_to_3d", | |
| "args_schema": ["image", "seed", "steps", "guidance", "simplify", "target_faces"], | |
| "defaults": {"seed": 0, "steps": 50, "guidance": 7.0, "simplify": True, "target_faces": 100000}, | |
| "wait_estimate": "30–60 sec", | |
| "icon": "⚡", | |
| }, | |
| { | |
| "key": "hunyuan3d", | |
| "name": "Hunyuan3D 2.1", | |
| "display": "Hunyuan3D 2.1 — Best quality + PBR", | |
| "desc": "Two-stage: shape then texture. Highest quality open model. ~60-120s on ZeroGPU.", | |
| "vram": "10 GB", | |
| "default": True, | |
| "space": "tencent/Hunyuan3D-2", | |
| "api_name": "generation_all", | |
| "args_schema": ["image", "text_prompt", "seed", "guidance_scale", "steps", | |
| "guidance_scale_t", "steps_t", "octree_resolution"], | |
| "defaults": {"text_prompt": "", "seed": 0, "guidance_scale": 3.5, "steps": 30, | |
| "guidance_scale_t": 3.5, "steps_t": 30, "octree_resolution": 256}, | |
| "wait_estimate": "60–120 sec", | |
| "icon": "🌟", | |
| }, | |
| { | |
| "key": "trellis2", | |
| "name": "TRELLIS.2 (4B)", | |
| "display": "TRELLIS.2 (4B) — Highest detail", | |
| "desc": "Microsoft's 4B-param model. ⚠️ Needs 24 GB VRAM locally. On ZeroGPU: ~90s.", | |
| "vram": "24 GB", | |
| "default": False, | |
| "space": "microsoft/TRELLIS.2", | |
| "api_name": "generate", | |
| "args_schema": ["image"], | |
| "defaults": {}, | |
| "wait_estimate": "90+ sec", | |
| "icon": "💎", | |
| }, | |
| ] | |
| FORMATS = { | |
| "glb": {"ext": ".glb", "mime": "model/gltf-binary", "label": "GLB (Binary glTF — recommended)"}, | |
| "gltf": {"ext": ".gltf", "mime": "model/gltf+json", "label": "GLTF (glTF + external textures)"}, | |
| "obj": {"ext": ".obj", "mime": "text/plain", "label": "OBJ (Wavefront — max compatibility)"}, | |
| "stl": {"ext": ".stl", "mime": "model/stl", "label": "STL (3D printing)"}, | |
| "ply": {"ext": ".ply", "mime": "text/plain", "label": "PLY (point cloud / scientific)"}, | |
| } | |
| VERSION = "0.3.0" | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def get_output_dir() -> Path: | |
| """Return the output directory. Configurable via OUTPUT_DIR env var.""" | |
| env = os.environ.get("OUTPUT_DIR") | |
| if env: | |
| return Path(env).expanduser().resolve() | |
| return (ROOT / "workspace").resolve() | |
| def get_token_env() -> str | None: | |
| return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") | |
| def _find_fn_index(config: dict, api_name: str) -> int | None: | |
| for i, ep in enumerate(config.get("dependencies") or []): | |
| ep_name = ep.get("api_name", "") or "" | |
| if ep_name == api_name or ep_name.lstrip("/") == api_name: | |
| return i | |
| return None | |
| class SpaceCallError(Exception): | |
| """Wraps errors from HF Space API calls with user-friendly context.""" | |
| def call_space_generate( | |
| model: dict, | |
| image_path: str, | |
| *, | |
| text_prompt: str = "", | |
| seed: int = 0, | |
| steps: int = 50, | |
| guidance: float | None = None, | |
| hf_token: str | None = None, | |
| timeout: int = 300, | |
| ) -> Path: | |
| """Call an HF Space to generate a 3D model. | |
| Auto-discovers the API endpoint, authenticates with HF token, submits the | |
| job, waits for results, and extracts the 3D file path. | |
| """ | |
| from gradio_client import Client, handle_file | |
| token = hf_token or get_token_env() | |
| headers = {"Authorization": f"Bearer {token}"} if token else {} | |
| log.info(f"Connecting to {model['space']}...") | |
| try: | |
| client = Client(model["space"], headers=headers) | |
| except Exception as e: | |
| raise SpaceCallError( | |
| f"Could not connect to {model['space']}.\n\n" | |
| f"Check your internet connection.\n" | |
| f"Error: {e}" | |
| ) from e | |
| fn_idx = _find_fn_index(client.config, model["api_name"]) | |
| if fn_idx is None: | |
| deps = client.config.get("dependencies") or [] | |
| available = [d.get("api_name", f"fn_{i}") for i, d in enumerate(deps) if d.get("api_name")] | |
| raise SpaceCallError( | |
| f"Could not find the expected endpoint in {model['space']}.\n\n" | |
| f"Expected: {model['api_name']}\n" | |
| f"Available: {', '.join(available)[:200]}\n\n" | |
| f"The Space may have been updated. Check the Space page on HuggingFace." | |
| ) | |
| log.info(f"Found fn_index={fn_idx} for {model['api_name']}") | |
| # Build args | |
| defaults = dict(model.get("defaults", {})) | |
| if guidance is not None: | |
| defaults["guidance"] = guidance | |
| defaults["guidance_scale"] = guidance | |
| defaults["guidance_scale_t"] = guidance | |
| args = [] | |
| for param in model["args_schema"]: | |
| if param == "image": | |
| args.append(handle_file(image_path)) | |
| elif param in defaults: | |
| args.append(defaults[param]) | |
| else: | |
| args.append(None) | |
| log.info(f"Submitting to {model['space']} with {len(args)} args") | |
| try: | |
| job = client.submit(*args, fn_index=fn_idx) | |
| waited = 0 | |
| while not job.done(): | |
| time.sleep(5) | |
| waited += 5 | |
| if waited > timeout: | |
| job.cancel() | |
| raise SpaceCallError( | |
| f"Generation timed out after {timeout}s.\n\n" | |
| f"The ZeroGPU queue may be long, or the Space may be overloaded.\n" | |
| f"Try again in a minute, or switch to a different model." | |
| ) | |
| result = job.result() | |
| except Exception as e: | |
| err = str(e) | |
| if "RuntimeError" in err or "AppError" in err: | |
| if not token: | |
| raise SpaceCallError( | |
| f"The Space returned an error. This usually means:\n\n" | |
| f"1. ZeroGPU quota exhausted for anonymous users\n" | |
| f"2. The Space is temporarily overloaded\n" | |
| f"3. Your GPU quota has reset\n\n" | |
| f"Fix: Enter your HF Token in Settings (Settings → Hugging Face Access Token)\n" | |
| f"Get a token at: hf.co/settings/tokens\n" | |
| f"(Your token is never saved or logged.)" | |
| ) from e | |
| raise SpaceCallError( | |
| f"The Space returned an error even with your token. This usually means:\n\n" | |
| f"1. The Space is overloaded — try again in 1-2 minutes\n" | |
| f"2. The ZeroGPU queue is backed up — wait a moment\n" | |
| f"3. The Space may be restarting — check its status on HF\n\n" | |
| f"Tip: Try a different model from the dropdown.\n" | |
| f"Space: {model['space']}\n" | |
| f"Error detail: {err[:300]}" | |
| ) from e | |
| raise SpaceCallError(f"Generation error: {err[:500]}") from e | |
| # Extract file path | |
| file_path = _extract_file_path(result) | |
| if not file_path or not Path(file_path).exists(): | |
| raise SpaceCallError( | |
| f"Generation completed but no 3D file found.\n\n" | |
| f"Result type: {type(result)}\n" | |
| f"Result: {_truncate(repr(result), 200)}\n\n" | |
| f"This is rare. The Space may have returned an unexpected format.\n" | |
| f"Try again or check the Space page for updates." | |
| ) | |
| return Path(file_path) | |
| def _extract_file_path(result) -> str | None: | |
| if result is None: | |
| return None | |
| if isinstance(result, dict) and "path" in result: | |
| return result["path"] | |
| if isinstance(result, (list, tuple)): | |
| for item in result: | |
| if isinstance(item, dict) and "path" in item: | |
| return item["path"] | |
| if isinstance(item, str) and Path(item).suffix in (".glb", ".gltf", ".obj", ".stl", ".ply", ".fbx"): | |
| return item | |
| if hasattr(item, "path"): | |
| return item.path | |
| # Try last item | |
| last = result[-1] | |
| if isinstance(last, dict): | |
| return last.get("path") or last.get("value") | |
| if isinstance(result, str) and Path(result).suffix: | |
| return result | |
| return None | |
| def _truncate(s: str, n: int) -> str: | |
| return s if len(s) <= n else s[:n] + "..." | |
| def convert_format(src_path: Path, target_ext: str) -> Path: | |
| """Convert a 3D file to the target format.""" | |
| src_ext = src_path.suffix.lstrip(".").lower() | |
| if src_ext == target_ext.lower(): | |
| return src_path | |
| out_dir = get_output_dir() | |
| ts = int(time.time()) | |
| out = out_dir / f"model_{ts}.{target_ext}" | |
| try: | |
| import trimesh | |
| mesh = trimesh.load(src_path, force="mesh") | |
| mesh.export(str(out), file_type=target_ext) | |
| log.info(f"Converted {src_path.name} → {out.name} ({out.stat().st_size / 1024:.0f} KB)") | |
| return out | |
| except Exception as e: | |
| log.warning(f"trimesh conversion failed: {e}") | |
| # Fallback: copy | |
| shutil.copy2(src_path, out) | |
| return out | |
| def validate_mesh(path: Path) -> dict: | |
| """Basic mesh validation.""" | |
| info = {"valid": True, "vertices": 0, "faces": 0, "file_size_kb": 0, "issues": []} | |
| if not path.exists(): | |
| info["valid"] = False | |
| info["issues"].append("File missing") | |
| return info | |
| info["file_size_kb"] = round(path.stat().st_size / 1024, 1) | |
| try: | |
| import trimesh | |
| mesh = trimesh.load(path, force="mesh") | |
| if isinstance(mesh, trimesh.Scene): | |
| dump = list(mesh.dump()) | |
| info["vertices"] = sum(len(m.vertices) for m in dump) | |
| info["faces"] = sum(len(m.faces) for m in dump) | |
| else: | |
| info["vertices"] = len(mesh.vertices) | |
| info["faces"] = len(mesh.faces) | |
| if not mesh.is_watertight: | |
| info["issues"].append("Not watertight (normal for AI-generated meshes)") | |
| except Exception as e: | |
| info["issues"].append(f"Parse warning: {e}") | |
| return info | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI | |
| # --------------------------------------------------------------------------- | |
| WELCOME_MSG = """\ | |
| ## Welcome to 3D Creator Suite | |
| **What it does:** Upload a concept image → get a production-ready 3D mesh. | |
| **How it works:** Your Hugging Face token gives you access to AI models running on shared GPUs. | |
| Each generation uses your ZeroGPU quota (free tier gives generous allotment). | |
| **Quick start:** | |
| 1. **Set your HF token** in Settings (top) if you haven't already | |
| 2. **Upload an image** — single object, clear background works best | |
| 3. **Choose a model** — TripoSG for speed, Hunyuan3D for quality | |
| 4. **Click Generate** — expect 30-120 sec wait depending on the model | |
| 5. **Download your GLB** — ready for game engines, Blender, web, 3D printing | |
| **💡 Tips for best results:** | |
| - Use images with transparent backgrounds (PNG with alpha) — skip the background removal step | |
| - Show one object from a slightly angled front view | |
| - Avoid blurry or low-resolution images | |
| - For game characters: front-facing concept art works best | |
| - For objects: photos with good lighting and clear edges | |
| """ | |
| def create_app(): | |
| import gradio as gr | |
| env_token = get_token_env() | |
| output_dir = get_output_dir() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| with gr.Blocks(title="3D Creator Suite") as app: | |
| gr.Markdown(WELCOME_MSG) | |
| # ── Settings ──────────────────────────────────────────────── | |
| with gr.Accordion("⚙️ Settings", open=not env_token): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| hf_token = gr.Textbox( | |
| label="Hugging Face Access Token", | |
| placeholder="hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", | |
| type="password", | |
| lines=1, | |
| info="Required for ZeroGPU access. Stored in session only — never logged or saved to disk. " | |
| "Get one at hf.co/settings/tokens", | |
| ) | |
| with gr.Column(scale=1): | |
| token_status = gr.Textbox( | |
| label="Token Status", | |
| value="Set from environment" if env_token else "Not configured", | |
| interactive=False, | |
| ) | |
| btn_verify = gr.Button("✓ Verify Token", variant="secondary", size="sm") | |
| def do_probe(token): | |
| tok = (token or env_token or "").strip() | |
| if not tok: | |
| return "Not set — enter your token or set HF_TOKEN environment variable" | |
| try: | |
| from huggingface_hub import HfApi | |
| user = HfApi(token=tok).whoami() | |
| return f"Verified ✓ User: {user['name']} ({tok[:8]}...)" | |
| except Exception as e: | |
| return f"Invalid token: {e}" | |
| btn_verify.click(do_probe, inputs=[hf_token], outputs=[token_status]) | |
| with gr.Row(): | |
| with gr.Column(): | |
| # Primary model selector | |
| sel_model = gr.Dropdown( | |
| choices=[m["display"] for m in MODELS if m.get("default")], | |
| value=MODELS[0]["display"], | |
| label="Model", | |
| info="TripoSG = fast (~30s), Hunyuan3D = best quality (~60-120s)", | |
| ) | |
| with gr.Accordion("▶ Show all models including high VRAM", open=False): | |
| sel_model_all = gr.Dropdown( | |
| choices=[m["display"] for m in MODELS], | |
| label="Model (all)", | |
| info="TRELLIS.2 needs 24 GB VRAM locally. Uses ZeroGPU on Hugging Face.", | |
| ) | |
| fmt_choice = gr.Dropdown( | |
| choices=[v["label"] for v in FORMATS.values()], | |
| value=FORMATS["glb"]["label"], | |
| label="Output Format", | |
| info="GLB = recommended for most use cases (web, games, AR)", | |
| ) | |
| # ── Input ─────────────────────────────────────────────────── | |
| input_image = gr.Image( | |
| label="Input Image", | |
| type="filepath", | |
| sources=["upload", "clipboard"], | |
| ) | |
| text_prompt = gr.Textbox( | |
| label="Text Prompt (optional — helps Hunyuan3D guide the generation)", | |
| placeholder="e.g. a small green goblin wearing leather armor, fantasy RPG character", | |
| lines=2, | |
| ) | |
| # ── Generate ──────────────────────────────────────────────── | |
| btn_gen = gr.Button("🚀 Generate 3D Model", variant="primary", size="lg") | |
| # ── Status + Output ──────────────────────────────────────── | |
| output_status = gr.Textbox(label="Status", interactive=False, lines=3) | |
| output_file = gr.File(label="Download 3D Model") | |
| # ── Example models ───────────────────────────────────────── | |
| example_files = sorted(str(p) for p in output_dir.glob("*.glb")) | |
| if example_files: | |
| gr.Markdown("### 📦 Pre-Generated Examples — click to inspect in 3D") | |
| with gr.Row(): | |
| for i, f in enumerate(example_files): | |
| with gr.Column(): | |
| name = Path(f).stem.replace("_", " ").title() | |
| gr.Markdown(f"**{name}**") | |
| gr.Model3D(value=f, label="") | |
| # ── Footer ────────────────────────────────────────────────── | |
| gr.Markdown( | |
| f"---\n" | |
| f"**3D Creator Suite** v{VERSION} — MIT License — " | |
| f"[GitHub](https://github.com/jkorstad/3d-creator-suite)\n\n" | |
| f"Output directory: ` {output_dir} `" | |
| ) | |
| # ── Generation handler ────────────────────────────────────── | |
| def resolve_model(model_display): | |
| for m in MODELS: | |
| if m["display"] == model_display: | |
| return m | |
| # Try matching by name part | |
| for m in MODELS: | |
| if m["name"] in model_display: | |
| return m | |
| return None | |
| def run_pipeline(image_path, model_display, fmt_label, token, prompt_text): | |
| """Full pipeline with progress updates, clear status messages.""" | |
| model = resolve_model(model_display) | |
| if not model: | |
| return None, " ERROR Invalid model selected. Please choose from the dropdown." | |
| fmt_key = "glb" | |
| for k, v in FORMATS.items(): | |
| if v["label"] == fmt_label: | |
| fmt_key = k | |
| break | |
| if not image_path: | |
| return None, ( | |
| " ERROR Please upload an image first.\n\n" | |
| "💡 Tips:\n" | |
| " • Use a clear photo or concept art of a single object\n" | |
| " • Transparent PNG backgrounds work best\n" | |
| " • Front-facing or slightly angled views give best results" | |
| ) | |
| token = token or env_token | |
| status_lines = [ | |
| f"Model: {model['icon']} {model['name']}", | |
| f"Space: {model['space']}", | |
| f"Format: {fmt_key.upper()}", | |
| ] | |
| # Stage 1: Submission | |
| wait_note = f"\nEstimated wait: {model['wait_estimate']}" | |
| if not token: | |
| wait_note += "\n⚠️ No token set — you may hit anonymous quota limits" | |
| status_lines.append(f"\nSubmitting to ZeroGPU...{wait_note}") | |
| yield None, "\n".join(status_lines) | |
| # Stage 2: Generation | |
| start = time.time() | |
| try: | |
| raw_path = call_space_generate( | |
| model, str(image_path), | |
| text_prompt=prompt_text or "", | |
| hf_token=token, | |
| ) | |
| gen_time = time.time() - start | |
| status_lines.append(f"Generated in {gen_time:.0f}s → {raw_path.name}") | |
| yield None, "\n".join(status_lines) | |
| except SpaceCallError as e: | |
| return None, f" ERROR {str(e)}" | |
| except Exception as e: | |
| return None, f" ERROR {type(e).__name__}: {e}" | |
| # Stage 3: Conversion | |
| status_lines.append(f"Converting format to {fmt_key.upper()}...") | |
| yield None, "\n".join(status_lines) | |
| try: | |
| out_path = convert_format(raw_path, fmt_key) | |
| except Exception: | |
| out_path = output_dir / f"model_{int(time.time())}.{fmt_key}" | |
| shutil.copy2(raw_path, out_path) | |
| # Stage 4: Validation | |
| info = validate_mesh(out_path) | |
| v_parts = [] | |
| if info["vertices"]: | |
| v_parts.append(f"{info['vertices']:,} vertices") | |
| if info["faces"]: | |
| v_parts.append(f"{info['faces']:,} faces") | |
| v_parts.append(f"{info['file_size_kb']:.0f} KB") | |
| status_lines.append(f"Validated: {' | '.join(v_parts)}") | |
| if info["issues"]: | |
| status_lines.append(f"Notes: {'; '.join(info['issues'])}") | |
| total = time.time() - start | |
| status_lines.insert(1, f"[DONE] Total: {total:.0f}s | Saved to: {output_dir}") | |
| yield str(out_path), "\n".join(status_lines) | |
| btn_gen.click( | |
| run_pipeline, | |
| inputs=[input_image, sel_model, fmt_choice, hf_token, text_prompt], | |
| outputs=[output_file, output_status], | |
| ) | |
| return app | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main(): | |
| parser = argparse.ArgumentParser(description="3D Creator Suite") | |
| parser.add_argument("--port", type=int, default=7860) | |
| parser.add_argument("--share", action="store_true", help="Public Gradio tunnel URL") | |
| args = parser.parse_args() | |
| output_dir = get_output_dir() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| ON_HF = bool(os.environ.get("HF_SPACE_ID")) | |
| log.info("") | |
| log.info("=" * 60) | |
| log.info(f" 3D Creator Suite v{VERSION}") | |
| log.info("=" * 60) | |
| log.info(f" Output directory: {output_dir}") | |
| log.info(f" HF Token: {'Set (env)' if get_token_env() else 'Not set — use Settings panel'}") | |
| log.info(f" On HF Spaces: {ON_HF}") | |
| log.info(f" Port: {args.port}") | |
| if args.share: | |
| log.info(" Share: ON — public tunnel URL will be generated") | |
| log.info("") | |
| log.info(" Available models:") | |
| for m in MODELS: | |
| status = "ENABLED" if m.get("default") else "DISABLED (opt-in)" | |
| log.info(f" {m['icon']} {m['name']} [{status}] VRAM: {m['vram']} Space: {m['space']}") | |
| log.info("") | |
| # Dep check | |
| for dep in ["gradio", "gradio_client", "trimesh"]: | |
| try: | |
| __import__(dep.replace("-", "_")) | |
| log.info(f" [OK] {dep}") | |
| except ImportError: | |
| log.warning(f" [MISS] {dep} — pip install {dep}") | |
| log.info("") | |
| app = create_app() | |
| log.info(" Launching Gradio server...\n") | |
| app.launch( | |
| server_name="0.0.0.0" if ON_HF else "127.0.0.1", | |
| server_port=args.port, | |
| share=args.share, | |
| quiet=False, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |