forge-3d / app.py
jkorstad's picture
Update forge-3d from local forge-bricks (with vendored common)
4bd82b7 verified
Raw
History Blame Contribute Delete
6.76 kB
"""
forge-3d Brick — Image (and text) → 3D + LODs + renders + analysis (Wave 1/2).
Design: orchestration + adapter pattern (inspired directly by 3d-creator-suite src/models/base.py + health).
- Local / Comfy / TripoSR primary (user 4070 Ti path, zero cost).
- Health-checked cloud fallbacks (our controlled or stable public).
- CPU-friendly mode for pure orchestration (like the past 3d-creator-suite Space).
- Outputs glb + manifest + multi-view previews (for reverse describe in visuals brick).
- @spaces.GPU on heavy paths; ZeroGPU deployable.
Scaffold: functional UI + API + manifest. Real adapters + local Comfy wiring next.
"""
from __future__ import annotations
import os
from pathlib import Path
from datetime import datetime
import gradio as gr
import spaces
import logging
from PIL import Image
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("forge-3d")
# Robust import for local workspace + HF Space deployment (common is vendored on push)
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
for candidate in (HERE.parent, HERE):
if (candidate / "common" / "manifest.py").exists():
sys.path.insert(0, str(candidate))
break
try:
from common.manifest import create_manifest
from common.health import SpaceHealth
except ImportError as e:
raise ImportError(
"Failed to import shared 'common' package (manifest.py / health.py).\n"
"For local development: run ./install.sh from the forge-bricks/ root.\n"
"For HF Spaces: re-run scripts/push_to_hf.py so it vendors common/."
) from e
health = SpaceHealth(check_interval=180)
# Stable public fallbacks (health checked). Prefer our controlled bricks/local first.
TRIPOSG = "VAST-AI/TripoSG"
HUNYUAN = "Tencent/Hunyuan3D-2"
def _out_dir() -> Path:
d = Path(os.environ.get("FORGE_BRICKS_OUTPUT", "./outputs/forge_3d"))
d.mkdir(parents=True, exist_ok=True)
return d
@spaces.GPU(duration=180)
def generate_3d(
image: str | Image.Image,
prompt: str = "",
model_choice: str = "triposg",
target_faces: int = 50000,
progress=gr.Progress(),
) -> tuple[str, list[str], dict]:
"""Image (+ optional text) → 3D model + preview renders + manifest.
Adapter style (see 3d-creator-suite base.ModelAdapter): health + local-first + fallback.
"""
progress(0, desc="Health + 3D generation...")
out = _out_dir()
ts = int(datetime.now().timestamp())
# 1. Ensure we have a filepath
if isinstance(image, Image.Image):
ip = out / f"input_{ts}.png"
image.save(ip)
image_path = str(ip)
else:
image_path = image
# 2. Health + choose backend (real calls to open-source 3D models via their Spaces)
target = TRIPOSG if model_choice == "triposg" else HUNYUAN
api_name = "/generate" if "Tripo" in target else "/generation_all"
ok = health.is_ok(target)
model_used = "fallback"
model_path = None
if ok is not False: # try
try:
from gradio_client import Client
client = Client(target, timeout=300)
# Real call to the 3D generation Space (TripoSG / Hunyuan3D)
res = client.predict(image_path, api_name=api_name)
if res:
if isinstance(res, str) and os.path.exists(res) and res.endswith((".glb", ".obj", ".gltf")):
model_path = res
elif isinstance(res, dict) and "path" in res:
p = res["path"]
if os.path.exists(p):
model_path = p
model_used = target.split("/")[-1]
except Exception as e:
logger.warning(f"Cloud 3D attempt to {target} failed: {e}")
if not model_path:
# Robust fallback: produce a tiny placeholder glb-like (real impl uses local TripoSR/Comfy or mesh gen)
# For demo we just note the path; production will write a real glb via local adapter.
model_path = str(out / f"model_{ts}.glb")
# Touch a placeholder so the rest of the pipeline (manifest, Godot helper, daggr) can continue
Path(model_path).write_bytes(b"") # real code will overwrite with valid glb
model_used = "local_placeholder"
# 3. Simple multi-view "renders" (placeholder images; real = render the glb or request from 3D Space)
previews = []
for view in ["front", "side", "top"]:
p = out / f"preview_{ts}_{view}.png"
Image.new("RGB", (512, 512), (60, 70, 90)).save(p)
previews.append(str(p))
manifest = create_manifest(
name=Path(image_path).stem or "asset",
type="model_3d",
source_brick="forge-3d",
prompt_or_desc=prompt or "image-to-3d",
files={"model": model_path, "previews": ",".join(previews)},
params={"model_choice": model_choice, "target_faces": target_faces, "backend": model_used},
metadata={"note": "Scaffold — adapters + local Comfy primary coming; health used"},
commercial_ok=True,
license_note="Depends on backend (TripoSG etc. — verify per use).",
)
mpath = out / f"manifest_{ts}.json"
manifest.save(mpath)
progress(1.0)
return model_path, previews, manifest.to_dict()
def build_ui():
with gr.Blocks(title="Forge-3D") as demo:
gr.Markdown("# 🧊 Forge-3D (image/text → 3D + previews + analysis hooks)\nRobust (health + adapters + local first). Pairs with forge-visuals for full bidir.")
with gr.Row():
img = gr.Image(label="Input image (or concept)", type="filepath")
txt = gr.Textbox(label="Optional text prompt (for some models)")
choice = gr.Dropdown(["triposg", "hunyuan3d"], value="triposg", label="Backend preference")
faces = gr.Slider(10000, 200000, value=50000, step=10000, label="Target faces (approx)")
btn = gr.Button("Generate 3D", variant="primary")
model = gr.File(label="3D Model (glb/obj)")
previews = gr.Gallery(label="Multi-view previews (for describe/refine)")
man = gr.JSON(label="Manifest (lineage ready)")
btn.click(generate_3d, [img, txt, choice, faces], [model, previews, man])
gr.Markdown("API: `/generate_3d` (image, prompt, model_choice, target_faces). Use with daggr + health-aware routing.")
return demo
# Build at module level so that `demo` (and `gradio_app`) exist when the module is imported
# (required for Hugging Face Spaces and for agent/MCP discovery).
demo = build_ui()
gradio_app = demo # alias for compatibility with tools/skills that expect `gradio_app`
if __name__ == "__main__":
health.start([TRIPOSG, HUNYUAN])
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7861)), mcp_server=True)