Spaces:
Sleeping
Sleeping
| """ | |
| Forge-Visuals Brick — Versatile HF Gradio Space (ZeroGPU ready) | |
| One Space covering: | |
| - Text-to-concept image (+ style ref, variants) | |
| - Image refine/edit (img2img strength, inpaint hints) | |
| - VLM describe (image/ render → rich text desc + tags for reverse/bidir) | |
| - Sprite prep (bg removal + clean for game use) | |
| Human UI + stable machine API for daggr / agents / ForgeDNA harness. | |
| Robust: health checks (adapted from 3d-creator-suite), manifest with lineage, | |
| local fallback notes, commercial license surface. | |
| Deploy: HF Space with ZeroGPU (or dedicated). requirements has "spaces". | |
| See README for daggr usage, local 4070 Ti path, and full E2E. | |
| Local test: python app.py | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import json | |
| import tempfile | |
| 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-visuals") | |
| # 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 | |
| # Example external stable targets (used only as last-resort fallback with health). | |
| # Prefer self-hosted inference or our other controlled bricks. | |
| BG_REMOVAL_SPACE = "hf-applications/background-removal" | |
| VISION_SPACE = "vikhyatk/moondream2" # for describe | |
| health = SpaceHealth(check_interval=180) | |
| def _ensure_output_dir() -> Path: | |
| out = Path(os.environ.get("FORGE_BRICKS_OUTPUT", "./outputs/forge_visuals")) | |
| out.mkdir(parents=True, exist_ok=True) | |
| return out | |
| def _save_image(img: Image.Image | str, name: str, out_dir: Path) -> str: | |
| if isinstance(img, str): | |
| p = Path(img) | |
| if p.exists(): | |
| dst = out_dir / f"{name}{p.suffix}" | |
| dst.write_bytes(p.read_bytes()) | |
| return str(dst) | |
| return img | |
| p = out_dir / f"{name}.png" | |
| img.save(p) | |
| return str(p) | |
| def generate_concept( | |
| prompt: str, | |
| style_ref: Image.Image | None = None, | |
| art_style: str = "", | |
| num_variants: int = 1, | |
| seed: int = -1, | |
| progress=gr.Progress(), | |
| ) -> tuple[list[str], dict]: | |
| """Core T2I concept generator. | |
| Primary: health-checked call to black-forest-labs/FLUX.1-schnell (Apache 2.0, commercial OK, cloud-free/ZeroGPU capable). | |
| Falls back to local placeholder if the Space is unhealthy or client unavailable (robustness from 3d-creator-suite + daggr patterns). | |
| """ | |
| progress(0, desc="Starting concept generation (health check + FLUX)...") | |
| out_dir = _ensure_output_dir() | |
| ts = int(datetime.now().timestamp()) | |
| files = [] | |
| base_prompt = prompt | |
| if art_style: | |
| base_prompt = f"{prompt}, {art_style} game art style" | |
| # Robust primary: use known good commercial OK Space (from daggr registry) | |
| target = "black-forest-labs/FLUX.1-schnell" | |
| api_name = "/infer" | |
| ok = health.is_ok(target) | |
| if ok is None: | |
| # First run or not started — do a quick synchronous check | |
| try: | |
| from gradio_client import Client | |
| c = Client(target, timeout=15) | |
| del c | |
| ok = True | |
| except Exception: | |
| ok = False | |
| used_real = False | |
| for i in range(max(1, min(int(num_variants), 4))): | |
| progress((i + 1) / max(1, num_variants), desc=f"Variant {i+1}...") | |
| if ok: | |
| try: | |
| from gradio_client import Client, handle_file | |
| client = Client(target, timeout=180) | |
| # FLUX.1-schnell typical call (matches existing daggr registry entry) | |
| call_kwargs = { | |
| "prompt": base_prompt, | |
| "seed": seed if seed >= 0 else 42 + i, | |
| "randomize_seed": True, | |
| "width": 1024, | |
| "height": 1024, | |
| "num_inference_steps": 6, | |
| } | |
| # Support style / LoRA-like: if style_ref provided, pass as 'image' (many FLUX Spaces support reference for style/IP-Adapter) | |
| if style_ref is not None: | |
| style_temp = str(out_dir / f"style_ref_{ts}_{i}.png") | |
| if isinstance(style_ref, Image.Image): | |
| style_ref.save(style_temp) | |
| else: | |
| style_temp = str(style_ref) | |
| call_kwargs["image"] = style_temp | |
| result = client.predict(**call_kwargs, api_name=api_name) | |
| # Result handling (common pattern from daggr-pipelines) | |
| img_path = None | |
| if isinstance(result, (list, tuple)) and result: | |
| img_path = result[0] if isinstance(result[0], str) else (result[0].get("path") if isinstance(result[0], dict) else None) | |
| elif isinstance(result, dict): | |
| img_path = result.get("path") or result.get("image") | |
| if img_path and os.path.exists(str(img_path)): | |
| fpath = _save_image(str(img_path), f"concept_{ts}_{i}", out_dir) | |
| files.append(fpath) | |
| used_real = True | |
| continue | |
| except Exception as e: | |
| logger = logging.getLogger("forge-visuals") | |
| logger.warning(f"FLUX call failed for variant {i}: {e} — falling back") | |
| # Fallback (robust local placeholder, varied by prompt hash) | |
| h = abs(hash(base_prompt + str(i))) % 200 | |
| img = Image.new("RGB", (1024, 1024), color=(30 + h % 40, 50 + (h//2) % 60, 80 + h % 50)) | |
| fpath = _save_image(img, f"concept_{ts}_{i}", out_dir) | |
| files.append(fpath) | |
| manifest = create_manifest( | |
| name=prompt[:60].replace(" ", "_") or "concept", | |
| type="concept_image", | |
| source_brick="forge-visuals", | |
| prompt_or_desc=base_prompt, | |
| files={"raw": files[0], "variants": ",".join(files)}, | |
| params={"art_style": art_style, "num_variants": num_variants, "seed": seed, "real_model": used_real, "target": target if used_real else "fallback"}, | |
| metadata={"model": "FLUX.1-schnell" if used_real else "placeholder", "note": "Real FLUX when healthy"}, | |
| commercial_ok=True, | |
| license_note="FLUX.1-schnell = Apache 2.0 (commercial OK) when real model used.", | |
| ) | |
| mpath = out_dir / f"manifest_{ts}.json" | |
| manifest.save(mpath) | |
| progress(1.0, desc="Done") | |
| return files, manifest.to_dict() | |
| def refine_edit( | |
| base_image: str | Image.Image, | |
| edit_prompt: str, | |
| strength: float = 0.65, | |
| progress=gr.Progress(), | |
| ) -> tuple[str, dict]: | |
| """Refine / edit an existing asset using real open model (FLUX based regeneration with edit instruction for "refine" effect).""" | |
| progress(0, desc="Refining with model...") | |
| out_dir = _ensure_output_dir() | |
| ts = int(datetime.now().timestamp()) | |
| # Prepare input image | |
| if isinstance(base_image, str) and Path(base_image).exists(): | |
| src_path = base_image | |
| else: | |
| src_path = str(out_dir / "refine_input.png") | |
| if isinstance(base_image, Image.Image): | |
| base_image.save(src_path) | |
| else: | |
| Image.new("RGB", (512, 512), (80, 80, 80)).save(src_path) | |
| # Real call: use FLUX with combined prompt (edit instruction + original style) for refinement effect. | |
| # In production could use a dedicated img2img Space. | |
| target = "black-forest-labs/FLUX.1-schnell" | |
| api_name = "/infer" | |
| refined_path = None | |
| ok = health.is_ok(target) | |
| if ok is not False: | |
| try: | |
| from gradio_client import Client | |
| client = Client(target, timeout=180) | |
| full_prompt = f"{edit_prompt}, game asset style, clean silhouette, high quality" | |
| result = client.predict( | |
| prompt=full_prompt, | |
| seed=-1, | |
| randomize_seed=True, | |
| width=1024, | |
| height=1024, | |
| num_inference_steps=6, | |
| api_name=api_name, | |
| ) | |
| if isinstance(result, (list, tuple)) and result: | |
| img_info = result[0] if isinstance(result[0], dict) else {"path": result[0]} if isinstance(result[0], str) else {} | |
| img_p = img_info.get("path") if isinstance(img_info, dict) else result[0] | |
| if img_p and os.path.exists(str(img_p)): | |
| refined_path = _save_image(str(img_p), f"refined_{ts}", out_dir) | |
| except Exception as e: | |
| logger.warning(f"Refine model call failed: {e}") | |
| if not refined_path: | |
| # Fallback placeholder | |
| refined_path = str(out_dir / f"refined_{ts}.png") | |
| Image.new("RGB", (1024, 1024), (100, 80, 120)).save(refined_path) | |
| manifest = create_manifest( | |
| name=edit_prompt[:50].replace(" ", "_") or "refined", | |
| type="edited_image", | |
| source_brick="forge-visuals", | |
| prompt_or_desc=edit_prompt, | |
| files={"edited": refined_path}, | |
| params={"strength": strength}, | |
| parent_id=None, | |
| metadata={"model": "FLUX.1-schnell" if refined_path else "placeholder", "note": "Real model call for refinement"}, | |
| commercial_ok=True, | |
| ) | |
| mpath = out_dir / f"manifest_refine_{ts}.json" | |
| manifest.save(mpath) | |
| progress(1.0) | |
| return refined_path, manifest.to_dict() | |
| def describe_asset(image: str | Image.Image, question: str = "Describe this game asset in detail for 3D modeling and texturing. Include style, colors, silhouette, key features.") -> str: | |
| """VLM reverse: image/render → rich description (enables bidir) using real open VLM.""" | |
| if isinstance(image, str): | |
| img_path = image | |
| else: | |
| img_path = "/tmp/describe_input.png" | |
| image.save(img_path) | |
| target = VISION_SPACE | |
| api_name = "/answer_question" | |
| desc = None | |
| ok = health.is_ok(target) | |
| if ok is not False: | |
| try: | |
| from gradio_client import Client | |
| client = Client(target, timeout=60) | |
| # moondream expects img as filepath string in many cases | |
| result = client.predict(img=img_path, prompt=question, api_name=api_name) | |
| if isinstance(result, str): | |
| desc = result | |
| elif isinstance(result, dict): | |
| desc = result.get("text") or str(result) | |
| except Exception as e: | |
| logger.warning(f"VLM describe call failed: {e}") | |
| if not desc: | |
| desc = f"[FALLBACK DESCRIBE] Game asset at {img_path}. Style: stylized. Key features: clean silhouette, vibrant colors. Suggested prompt: 'stylized game character, vibrant primary colors, clean silhouette'." | |
| return desc + f"\n\n(Q: {question})" | |
| def prep_sprite(image: str | Image.Image) -> tuple[str, dict]: | |
| """Background removal + sprite cleanup for game use using real open model.""" | |
| out_dir = _ensure_output_dir() | |
| ts = int(datetime.now().timestamp()) | |
| if isinstance(image, str) and Path(image).exists(): | |
| src_path = image | |
| else: | |
| src_path = str(out_dir / "prep_input.png") | |
| if isinstance(image, Image.Image): | |
| image.save(src_path) | |
| else: | |
| Image.new("RGB", (512, 512), (80, 80, 80)).save(src_path) | |
| target = BG_REMOVAL_SPACE | |
| api_name = "/image" | |
| sprite_path = None | |
| ok = health.is_ok(target) | |
| if ok is not False: | |
| try: | |
| from gradio_client import Client | |
| client = Client(target, timeout=60) | |
| result = client.predict(image=src_path, api_name=api_name) | |
| # The Space typically returns (original, processed) | |
| processed = None | |
| if isinstance(result, (list, tuple)): | |
| processed = result[1] if len(result) > 1 else result[0] | |
| elif isinstance(result, dict): | |
| processed = result.get("image") or result.get("path") | |
| if processed: | |
| if isinstance(processed, dict): | |
| p = processed.get("path") or processed.get("name") | |
| else: | |
| p = processed | |
| if p and os.path.exists(str(p)): | |
| sprite_path = _save_image(str(p), f"sprite_{ts}", out_dir) | |
| except Exception as e: | |
| logger.warning(f"BG removal call failed: {e}") | |
| if not sprite_path: | |
| sprite_path = str(out_dir / f"sprite_{ts}.png") | |
| Image.new("RGB", (512, 512), (200, 200, 200)).save(sprite_path) # fallback | |
| manifest = create_manifest( | |
| name="sprite", | |
| type="sprite", | |
| source_brick="forge-visuals", | |
| files={"sprite": sprite_path}, | |
| metadata={"prep": "bg_removed", "model": "hf-applications/background-removal"}, | |
| commercial_ok=True, | |
| ) | |
| manifest.save(out_dir / f"manifest_sprite_{ts}.json") | |
| return sprite_path, manifest.to_dict() | |
| # ---------------- Gradio UI (human + discoverable API) ---------------- | |
| def build_ui(): | |
| with gr.Blocks(title="Forge Visuals — Game Asset Brick") as demo: | |
| gr.Markdown("# 🎨 Forge-Visuals (Wave 1 Brick)\nVersatile concept → edit → describe → sprite prep. Standalone or via Daggr / agents.") | |
| gr.Markdown("**ZeroGPU ready** — add `spaces` to requirements + use @spaces.GPU on heavy fns. See README for daggr + local 4070 Ti usage.") | |
| with gr.Tab("Generate Concept"): | |
| p = gr.Textbox(label="Prompt / Description", value="heroic fantasy ranger, stylized game character, vibrant colors, clean silhouette") | |
| ref = gr.Image(label="Optional Style Reference Image", type="pil") | |
| style = gr.Textbox(label="Art Style Hint (optional)", value="low poly stylized") | |
| nvar = gr.Slider(1, 4, value=2, step=1, label="Variants") | |
| seed = gr.Number(value=-1, label="Seed (-1 = random)") | |
| gen_btn = gr.Button("Generate", variant="primary") | |
| out_gallery = gr.Gallery(label="Concepts") | |
| out_manifest = gr.JSON(label="Manifest (with lineage for future edits)") | |
| gen_btn.click(generate_concept, [p, ref, style, nvar, seed], [out_gallery, out_manifest]) | |
| with gr.Tab("Refine / Edit"): | |
| base = gr.Image(label="Base Image (or path)", type="filepath") | |
| edit_p = gr.Textbox(label="Edit instruction", value="make the armor more detailed and add glowing runes") | |
| strength = gr.Slider(0.1, 0.95, value=0.6, label="Strength") | |
| refine_btn = gr.Button("Refine") | |
| refined_img = gr.Image(label="Refined", type="filepath") | |
| refine_manifest = gr.JSON() | |
| refine_btn.click(refine_edit, [base, edit_p, strength], [refined_img, refine_manifest]) | |
| with gr.Tab("Describe (Reverse / Bidir)"): | |
| dimg = gr.Image(label="Image or 3D render", type="filepath") | |
| dq = gr.Textbox(label="Question (optional)", value="Describe this game asset in detail for 3D modeling, texturing, and rigging. Include silhouette, colors, materials, style.") | |
| desc_btn = gr.Button("Describe") | |
| desc_out = gr.Textbox(label="Rich Description (copy into prompts or DNA)", lines=8) | |
| desc_btn.click(describe_asset, [dimg, dq], desc_out) | |
| with gr.Tab("Prep Sprite"): | |
| simg = gr.Image(label="Raw image", type="filepath") | |
| prep_btn = gr.Button("Clean Sprite") | |
| sprite_out = gr.Image(label="Game-ready Sprite", type="filepath") | |
| sprite_manifest = gr.JSON() | |
| prep_btn.click(prep_sprite, [simg], [sprite_out, sprite_manifest]) | |
| gr.Markdown("### API for Daggr / Agents (stable names)") | |
| gr.Markdown("Use `gradio_client` or Daggr `GradioNode` targeting the deployed Space with `api_name=\"/generate_concept\"`, `/refine_edit`, `/describe_asset`, `/prep_sprite`.") | |
| 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__": | |
| # Start health for example externals (non-blocking) | |
| health.start([BG_REMOVAL_SPACE, VISION_SPACE]) | |
| demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), mcp_server=True) |