"""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()