jkorstad's picture
Deploy full 3D Creator Studio app
c1d3bde verified
"""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()